import json import os import shutil import sys from datetime import datetime from pathlib import Path from typing import Optional, Union import click import typer from plankapy.v2 import Card, Planka from rich.console import Console from rich.table import Table from typer.core import TyperGroup TOKEN_ENV_VAR = "PLANKATOKENS" DEFAULT_TOKEN_DIR = Path.home() / ".config" / "planka-cli" / "tokens" CREDENTIALS_FILENAME = "credentials.json" TOKENSTORE_OVERRIDE: Optional[str] = None class HelpOnUnknownCommandGroup(TyperGroup): def get_command(self, ctx: click.Context, cmd_name: str): command = super().get_command(ctx, cmd_name) if command is None: click.echo(ctx.get_help()) ctx.exit(2) return command app = typer.Typer(cls=HelpOnUnknownCommandGroup) console = Console() projects_app = typer.Typer(cls=HelpOnUnknownCommandGroup, help="Manage projects") boards_app = typer.Typer(cls=HelpOnUnknownCommandGroup, help="Manage boards") lists_app = typer.Typer(cls=HelpOnUnknownCommandGroup, help="Manage lists") cards_app = typer.Typer(cls=HelpOnUnknownCommandGroup, help="Manage cards") notifications_app = typer.Typer(cls=HelpOnUnknownCommandGroup, help="Manage notifications") app.add_typer(projects_app, name="projects") app.add_typer(boards_app, name="boards") app.add_typer(lists_app, name="lists") app.add_typer(cards_app, name="cards") app.add_typer(notifications_app, name="notifications") @app.callback(invoke_without_command=True) def main( ctx: typer.Context, tokenstore: Optional[str] = typer.Option(None, "--tokenstore", help="Token storage path."), ): """Planka CLI.""" global TOKENSTORE_OVERRIDE TOKENSTORE_OVERRIDE = tokenstore if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) def get_token_dir(tokenstore: Optional[str] = None) -> Path: if tokenstore: return Path(tokenstore).expanduser().resolve() if TOKENSTORE_OVERRIDE: return Path(TOKENSTORE_OVERRIDE).expanduser().resolve() env = os.environ.get(TOKEN_ENV_VAR) if env: return Path(env).expanduser().resolve() return DEFAULT_TOKEN_DIR def get_credentials_path(tokenstore: Optional[str] = None) -> Path: return get_token_dir(tokenstore) / CREDENTIALS_FILENAME def load_stored_credentials(tokenstore: Optional[str] = None) -> dict[str, str]: credentials_path = get_credentials_path(tokenstore) if not credentials_path.exists(): return {} try: data = json.loads(credentials_path.read_text()) except json.JSONDecodeError as exc: console.print( f"[bold red]Error:[/bold red] Invalid credentials file at {credentials_path}: {exc}" ) raise typer.Exit(1) from exc if not isinstance(data, dict): console.print( f"[bold red]Error:[/bold red] Credentials file at {credentials_path} must be a JSON object." ) raise typer.Exit(1) return {str(key): str(value) for key, value in data.items()} def get_env_config() -> tuple[Optional[str], Optional[str], Optional[str]]: planka_url = os.getenv("PLANKA_URL") planka_username = os.getenv("PLANKA_USERNAME") planka_password = os.getenv("PLANKA_PASSWORD") if planka_url and planka_username and planka_password: return planka_url, planka_username, planka_password stored = load_stored_credentials() if not planka_url: planka_url = stored.get("PLANKA_URL") if not planka_username: planka_username = stored.get("PLANKA_USERNAME") if not planka_password: planka_password = stored.get("PLANKA_PASSWORD") return planka_url, planka_username, planka_password def parse_iso_datetime(value: Optional[str]) -> Optional[datetime]: if value is None: return None candidate = value.strip() if candidate.endswith("Z"): candidate = f"{candidate[:-1]}+00:00" try: return datetime.fromisoformat(candidate) except ValueError as exc: raise typer.BadParameter( "Invalid datetime. Use ISO-8601 like 2025-01-31 or 2025-01-31T10:30:00Z." ) from exc def parse_position(value: Optional[str]) -> Optional[Union[str, int]]: if value is None: return None normalized = value.strip().lower() if normalized in ("top", "bottom"): return normalized try: return int(value) except ValueError as exc: raise typer.BadParameter("Position must be 'top', 'bottom', or an integer.") from exc def find_list(planka: Planka, list_id: str): for project in planka.projects: for board in project.boards: for list_item in board.lists: if list_item.id == list_id: return list_item return None def find_list_with_board(planka: Planka, list_id: str): """Find a list and return both the list and its parent board.""" for project in planka.projects: for board in project.boards: for list_item in board.lists: if list_item.id == list_id: return list_item, board return None, None def get_card_by_id(planka: Planka, card_id: str) -> Optional[Card]: try: card_data = planka.endpoints.getCard(card_id)["item"] except Exception: return None return Card(card_data, planka) def make_table(title: str) -> Table: return Table( title=title, show_header=True, header_style="bold", show_edge=False, show_lines=False, box=None, ) def render_notifications(title: str, notifications: list) -> None: if not notifications: console.print("No notifications found.") return table = make_table(title) table.add_column("ID", justify="right", style="cyan", no_wrap=True) table.add_column("Type", style="magenta") table.add_column("Read", justify="center") table.add_column("Created At", justify="right") table.add_column("Card ID", justify="right") for notification in notifications: card_id = notification.schema.get("cardId") table.add_row( str(notification.id), str(notification.type), "yes" if notification.is_read else "no", str(notification.created_at), str(card_id), ) console.print(table) def get_planka() -> Planka: planka_url, planka_username, planka_password = get_env_config() if not planka_url or not planka_username or not planka_password: credentials_path = get_credentials_path() console.print( "[bold red]Error:[/bold red] Missing credentials. " f"Set PLANKA_URL, PLANKA_USERNAME, and PLANKA_PASSWORD or run " f"`planka-cli login --url ... --username ... --password ...` " f"to write {credentials_path}." ) sys.exit(1) try: planka = Planka(planka_url) planka.login(username=planka_username, password=planka_password) return planka except Exception as e: console.print(f"[bold red]Connection Error:[/bold red] {e}") sys.exit(1) @app.command() def login( url: str = typer.Option(..., "--url", "-u", help="Planka base URL"), username: str = typer.Option(..., "--username", "-n", help="Planka username"), password: str = typer.Option(..., "--password", "-p", help="Planka password"), ): """Store credentials in ~/.config/planka-cli/tokens/credentials.json.""" credentials_path = get_credentials_path() try: credentials_path.parent.mkdir(parents=True, exist_ok=True) credentials_path.write_text( json.dumps( { "PLANKA_URL": url, "PLANKA_USERNAME": username, "PLANKA_PASSWORD": password, }, indent=2, ) + "\n" ) except OSError as e: console.print(f"[bold red]Error:[/bold red] Could not write {credentials_path}: {e}") raise typer.Exit(1) try: os.chmod(credentials_path, 0o600) except OSError: pass console.print(f"[green]Saved credentials to[/green] {credentials_path}") @app.command() def logout(): """Delete the stored ~/.config/planka-cli/tokens/credentials.json file.""" token_dir = get_token_dir() credentials_path = get_credentials_path() if not credentials_path.exists(): console.print(f"No stored credentials found at {credentials_path}") return try: shutil.rmtree(token_dir) except OSError as e: console.print(f"[bold red]Error:[/bold red] Could not delete {credentials_path}: {e}") raise typer.Exit(1) console.print("[green]Logged out.[/green] Removed stored credentials.") @app.command() def status(): """Check connection and print current user info.""" planka = get_planka() try: user = planka.me console.print( f"[green]Connected![/green] Logged in as: [bold]{user.username}[/bold] (ID: {user.id})" ) if user.name: console.print(f"Name: {user.name}") console.print(f"Email: {user.email}") except Exception as e: console.print(f"[bold red]Error fetching status:[/bold red] {e}") @projects_app.command("list") def list_projects(): """List all projects.""" planka = get_planka() try: projects = planka.projects if not projects: console.print("No projects found.") return table = make_table("Projects") table.add_column("ID", justify="right", style="cyan", no_wrap=True) table.add_column("Name", style="magenta") table.add_column("Created At", justify="right") for p in projects: table.add_row(str(p.id), p.name, str(p.created_at)) console.print(table) except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") @boards_app.command("list") def list_boards( project_id: Optional[str] = typer.Argument(None, help="Project ID to filter by"), ): """List boards. Optionally filter by Project ID.""" planka = get_planka() try: if project_id is not None: # Find specific project project = next((p for p in planka.projects if p.id == project_id), None) if not project: console.print(f"[red]Project {project_id} not found.[/red]") return boards_list = project.boards title = f"Boards in Project {project.name}" else: # List all boards from all projects the user has access to # plankapy doesn't have a direct 'all_boards', so we iterate projects boards_list = [] for p in planka.projects: boards_list.extend(p.boards) title = "All Boards" if not boards_list: console.print("No boards found.") return planka_url, _, _ = get_env_config() table = make_table(title) table.add_column("ID", justify="right", style="cyan", no_wrap=True) table.add_column("Name", style="magenta") table.add_column("Project ID", justify="right") table.add_column("URL", style="magenta") for b in boards_list: project_id_value = b.schema.get("projectId") board_url = None if planka_url and b.id: board_url = f"{planka_url.rstrip('/')}/boards/{b.id}" table.add_row(str(b.id), b.name, str(project_id_value), str(board_url or "-")) console.print(table) except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") @lists_app.command("list") def list_lists(board_id: str): """List all lists in a board.""" planka = get_planka() try: # We need to find the board first to get its lists # This is a bit inefficient in plankapy if we don't know the project, but we can search target_board = None for p in planka.projects: for b in p.boards: if b.id == board_id: target_board = b break if target_board: break if not target_board: console.print(f"[red]Board {board_id} not found.[/red]") return table = make_table(f"Lists in Board: {target_board.name}") table.add_column("ID", justify="right", style="cyan", no_wrap=True) table.add_column("Name", style="magenta") table.add_column("Board ID", justify="right") table.add_column("Position", justify="right") for list_item in target_board.lists: table.add_row( str(list_item.id), list_item.name, str(target_board.id), str(list_item.position) ) console.print(table) except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") @cards_app.command("list") def list_cards(list_id: str): """List all cards in a list.""" planka = get_planka() try: target_list, target_board = find_list_with_board(planka, list_id) if not target_list: console.print(f"[red]List {list_id} not found.[/red]") return planka_url, _, _ = get_env_config() board_id = target_board.id if target_board else None table = make_table(f"Cards in List: {target_list.name}") table.add_column("ID", justify="right", style="cyan", no_wrap=True) table.add_column("Name", style="magenta") table.add_column("List ID", justify="right") table.add_column("Position", justify="right") table.add_column("URL", style="magenta") for c in target_list.cards: card_url = None if planka_url and board_id and c.id: card_url = f"{planka_url.rstrip('/')}/boards/{board_id}/cards/{c.id}" table.add_row(str(c.id), c.name, str(list_id), str(c.position), str(card_url or "-")) console.print(table) except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") @cards_app.command("show") def show_card(card_id: str): """Show details for a card.""" planka = get_planka() try: card = get_card_by_id(planka, card_id) if not card: console.print(f"[red]Card {card_id} not found.[/red]") return table = make_table(f"Card: {card.name}") table.add_column("Field", style="cyan", no_wrap=True) table.add_column("Value", style="magenta") def safe_attr(obj: object, name: str) -> object: try: return getattr(obj, name) except Exception: return None def normalize_url(base_url: Optional[str], value: Optional[str]) -> Optional[str]: if not value: return None candidate = str(value).strip() if not candidate: return None if candidate.startswith("http://") or candidate.startswith("https://"): return candidate if base_url: return f"{base_url.rstrip('/')}/{candidate.lstrip('/')}" return candidate def extract_attachment_url(attachment: object, base_url: Optional[str]) -> Optional[str]: data = safe_attr(attachment, "data") if isinstance(data, dict): for key in ( "url", "downloadUrl", "download_url", "link", "href", "path", ): value = data.get(key) if isinstance(value, str) and value.strip(): return normalize_url(base_url, value) file_info = data.get("file") if isinstance(file_info, dict): for key in ( "url", "downloadUrl", "download_url", "path", "thumbnailUrl", "thumbUrl", "thumbnail_url", ): value = file_info.get(key) if isinstance(value, str) and value.strip(): return normalize_url(base_url, value) direct_url = safe_attr(attachment, "url") if isinstance(direct_url, str) and direct_url.strip(): return normalize_url(base_url, direct_url) return None def add_row(label: str, value: object) -> None: if value is None or value == "": table.add_row(label, "-") else: table.add_row(label, str(value)) try: schema = card.schema or {} except Exception: schema = {} list_id_value = schema.get("listId") board_id_value = schema.get("boardId") list_name_value = None list_obj = safe_attr(card, "list") if list_obj is not None: list_id_value = safe_attr(list_obj, "id") or list_id_value list_name_value = safe_attr(list_obj, "name") if not board_id_value: list_schema = safe_attr(list_obj, "schema") if isinstance(list_schema, dict): board_id_value = list_schema.get("boardId") list_display = "-" if list_name_value and list_id_value: list_display = f"{list_name_value} ({list_id_value})" elif list_name_value: list_display = list_name_value elif list_id_value: list_display = list_id_value due_date = safe_attr(card, "due_date") or schema.get("dueDate") due_completed = safe_attr(card, "due_date_completed") if due_completed is None: due_completed = schema.get("isDueCompleted") if isinstance(due_completed, bool): due_completed = "yes" if due_completed else "no" attachments_error = None comments_error = None try: attachments = list(safe_attr(card, "attachments") or []) except Exception as exc: attachments = [] attachments_error = str(exc) try: comments = list(safe_attr(card, "comments") or []) except Exception as exc: comments = [] comments_error = str(exc) comments_count = safe_attr(card, "comments_count") if comments_count is None and comments_error is None: comments_count = len(comments) planka_url, _, _ = get_env_config() card_url = None if planka_url and board_id_value and card.id: card_url = f"{planka_url.rstrip('/')}/boards/{board_id_value}/cards/{card.id}" add_row("ID", card.id) add_row("URL", card_url) add_row("Name", card.name) add_row("Description", safe_attr(card, "description") or schema.get("description")) add_row("Board ID", board_id_value) add_row("List", list_display) add_row("Position", safe_attr(card, "position") or schema.get("position")) add_row("Type", safe_attr(card, "type") or schema.get("type")) add_row("Due Date", due_date) add_row("Due Completed", due_completed) if attachments_error: add_row("Attachments", f"Error: {attachments_error}") else: add_row("Attachments", len(attachments)) if comments_error: add_row("Comments", f"Error: {comments_error}") else: add_row("Comments", comments_count) add_row("Created At", safe_attr(card, "created_at") or schema.get("createdAt")) add_row("Updated At", safe_attr(card, "updated_at") or schema.get("updatedAt")) console.print(table) if attachments and attachments_error is None: attachments_table = make_table("Attachments") attachments_table.add_column("ID", justify="right", style="cyan", no_wrap=True) attachments_table.add_column("Name", style="magenta") attachments_table.add_column("Type", style="magenta") attachments_table.add_column("URL", style="magenta") attachments_table.add_column("Created At", justify="right") for attachment in attachments: attachment_url = extract_attachment_url(attachment, planka_url) attachments_table.add_row( str(safe_attr(attachment, "id") or "-"), str(safe_attr(attachment, "name") or "-"), str(safe_attr(attachment, "type") or "-"), str(attachment_url or "-"), str(safe_attr(attachment, "created_at") or "-"), ) console.print(attachments_table) if comments and comments_error is None: comments_table = make_table("Comments") comments_table.add_column("ID", justify="right", style="cyan", no_wrap=True) comments_table.add_column("User", style="magenta") comments_table.add_column("Text", style="magenta") comments_table.add_column("Created At", justify="right") for comment in comments: user = safe_attr(comment, "user") user_label = ( safe_attr(user, "name") or safe_attr(user, "username") or safe_attr(user, "id") or "-" ) text = safe_attr(comment, "text") text_label = " ".join(str(text).split()) if text else "-" comments_table.add_row( str(safe_attr(comment, "id") or "-"), str(user_label), text_label, str(safe_attr(comment, "created_at") or "-"), ) console.print(comments_table) except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") @cards_app.command("create") def create_card( list_id: str = typer.Argument(..., help="List ID to create the card in"), name: str = typer.Argument(..., help="Card name/title"), description: Optional[str] = typer.Option(None, "--description", "-d", help="Card description"), position: str = typer.Option( "bottom", "--position", "-p", help="Position: top, bottom, or integer" ), card_type: str = typer.Option("project", "--type", "-t", help="Card type (project, story)"), due_date: Optional[str] = typer.Option(None, "--due-date", help="Due date (ISO-8601)"), due_completed: bool = typer.Option(False, "--due-completed", help="Mark due date as completed"), ): """Create a new card in a list.""" planka = get_planka() try: target_list = find_list(planka, list_id) if not target_list: console.print(f"[red]List {list_id} not found.[/red]") return parsed_due_date = parse_iso_datetime(due_date) parsed_position = parse_position(position) or "bottom" card = target_list.create_card( name=name, position=parsed_position, type=card_type, description=description, due_date=parsed_due_date, due_date_completed=due_completed, ) console.print( f"[green]Created card[/green] [bold]{card.name}[/bold] " f"(ID: {card.id}) in list [bold]{target_list.name}[/bold]" ) except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") @cards_app.command("update") def update_card( card_id: str = typer.Argument(..., help="Card ID to update"), name: Optional[str] = typer.Option(None, "--name", help="New card name"), description: Optional[str] = typer.Option( None, "--description", "-d", help="New card description" ), clear_description: bool = typer.Option( False, "--clear-description", help="Clear the description" ), position: Optional[str] = typer.Option( None, "--position", "-p", help="Position: top, bottom, or integer" ), list_id: Optional[str] = typer.Option(None, "--list-id", help="Move to a new list"), card_type: Optional[str] = typer.Option( None, "--type", "-t", help="Card type (project, story)" ), due_date: Optional[str] = typer.Option(None, "--due-date", help="Due date (ISO-8601)"), clear_due_date: bool = typer.Option(False, "--clear-due-date", help="Clear the due date"), due_completed: Optional[bool] = typer.Option( None, "--due-completed/--no-due-completed", help="Mark due date as completed/uncompleted", ), ): """Update an existing card.""" if description is not None and clear_description: console.print( "[bold red]Error:[/bold red] Use either --description or --clear-description." ) raise typer.Exit(1) if due_date is not None and clear_due_date: console.print("[bold red]Error:[/bold red] Use either --due-date or --clear-due-date.") raise typer.Exit(1) planka = get_planka() try: card = get_card_by_id(planka, card_id) if not card: console.print(f"[red]Card {card_id} not found.[/red]") return update_fields: dict[str, object] = {} if name is not None: update_fields["name"] = name if description is not None: update_fields["description"] = description elif clear_description: update_fields["description"] = None if card_type is not None: update_fields["type"] = card_type if due_date is not None: update_fields["dueDate"] = parse_iso_datetime(due_date) elif clear_due_date: update_fields["dueDate"] = None if due_completed is not None: update_fields["isDueCompleted"] = due_completed move_position = parse_position(position) if list_id is not None or move_position is not None: target_list = find_list(planka, list_id) if list_id is not None else card.list if not target_list: console.print(f"[red]List {list_id} not found.[/red]") return card.move(target_list, position=move_position or "top") if update_fields: card.update(**update_fields) if not update_fields and list_id is None and move_position is None: console.print("[yellow]No updates provided.[/yellow]") return console.print(f"[green]Updated card[/green] [bold]{card.name}[/bold] (ID: {card.id})") except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") @cards_app.command("delete") def delete_card( card_id: str = typer.Argument(..., help="Card ID to delete"), yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), ): """Delete a card.""" planka = get_planka() try: card = get_card_by_id(planka, card_id) if not card: console.print(f"[red]Card {card_id} not found.[/red]") return if not yes: confirm = typer.confirm(f"Delete card '{card.name}' ({card.id})?") if not confirm: console.print("Cancelled.") return card.delete() console.print(f"[green]Deleted card[/green] {card_id}") except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") @notifications_app.command("all") def all_notifications(): """List all notifications.""" planka = get_planka() try: render_notifications("Notifications", planka.notifications) except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") @notifications_app.command("unread") def unread_notifications(): """List unread notifications.""" planka = get_planka() try: render_notifications("Unread Notifications", planka.unread_notifications) except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") if __name__ == "__main__": app()