#!/usr/bin/env python3 """Small, dependency-free Planka CLI for agents. Run from the target project directory.""" import argparse import json import os from pathlib import Path import re import shlex import sys import tempfile from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen SPEC_URL = "https://plankanban.github.io/planka/swagger-ui/swagger.json" CACHE = Path(".pi/cache/planka-swagger.json") VERSION = "0.1.0" class PlankaError(Exception): pass def emit(key, value): print(f"{key}: {json.dumps(value, ensure_ascii=False)}") def table(name, fields, rows): print(f"{name}[{len(rows)}]{{{','.join(fields)}}}:") for row in rows: print(" " + ",".join(json.dumps(value, ensure_ascii=False) for value in row)) if not rows: emit("state", f"0 {name} found") def load_config(): config = {k: os.environ.get(k, "") for k in ("PLANKA_URL", "PLANKA_API_KEY")} for file in (Path.home() / ".env", Path.cwd() / ".env"): if not file.is_file(): continue for line in file.read_text(encoding="utf-8").splitlines(): match = re.match(r"^\s*(?:export\s+)?(PLANKA_URL|PLANKA_API_KEY)\s*=\s*(.*)$", line) if match: try: parts = shlex.split(match[2], comments=True) except ValueError: raise PlankaError(f"Invalid {match[1]} in {file}") from None config[match[1]] = " ".join(parts) if parts else "" missing = [key for key, value in config.items() if not value] if missing: raise PlankaError(f"Set {', '.join(missing)} in .env, ~/.env, or the shell") url = config["PLANKA_URL"].rstrip("/") if not url.startswith(("https://", "http://")): raise PlankaError("PLANKA_URL must start with https:// or http://") return url, config["PLANKA_API_KEY"] def fetch_json(url, key=None, method="GET", payload=None): headers = {"Accept": "application/json"} if key: headers["X-Api-Key"] = key if payload is not None: headers["Content-Type"] = "application/json" request = Request(url, headers=headers, method=method, data=json.dumps(payload).encode() if payload is not None else None) try: with urlopen(request, timeout=20) as response: content_type = response.headers.get("Content-Type", "") raw = response.read() except HTTPError as exc: raise PlankaError(f"{method} {url}: HTTP {exc.code}; check the endpoint, permissions, and input") from None except URLError as exc: raise PlankaError(f"Could not reach {url}: {exc.reason}") from None if "json" not in content_type.lower(): raise PlankaError(f"{method} {url}: expected JSON, got {content_type or 'unknown content type'}; check PLANKA_URL and API path") try: return json.loads(raw) except (ValueError, UnicodeDecodeError): raise PlankaError(f"{method} {url}: invalid JSON response") from None def spec(refresh=False): if not refresh and CACHE.is_file(): try: cached = json.loads(CACHE.read_text(encoding="utf-8")) if isinstance(cached.get("paths"), dict): return cached except (ValueError, OSError, AttributeError): pass data = fetch_json(SPEC_URL) if not isinstance(data, dict) or not isinstance(data.get("paths"), dict): raise PlankaError("OpenAPI response has no paths; cache was not updated") CACHE.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=CACHE.parent, delete=False) as temp: json.dump(data, temp) temp_path = Path(temp.name) temp_path.replace(CACHE) return data def check_endpoint(method, template): if method.lower() not in spec().get("paths", {}).get(template, {}): raise PlankaError(f"{method} {template} is not in the cached spec; run `spec refresh` and inspect `spec endpoints`") class Client: def __init__(self): url, self.key = load_config() self.base = url + "/api" def request(self, method, template, path, payload=None): check_endpoint(method, template) return fetch_json(self.base + path, self.key, method, payload) def projects(self): return self.request("GET", "/projects", "/projects") def board(self, name): projects = self.projects() boards = (projects.get("included") or {}).get("boards") or [] matches = [b for b in boards if b.get("name") == name] if len(matches) != 1: raise PlankaError(f"Board {name!r}: found {len(matches)} matches; use `overview` to check names") return self.request("GET", "/boards/{id}", "/boards/" + matches[0]["id"]) def board_parts(data): inc = data.get("included") or {} lists = inc.get("lists") or [] cards = inc.get("cards") or [] return lists, cards def unique_list(lists, name): matches = [item for item in lists if item.get("name") == name] if len(matches) != 1: raise PlankaError(f"List {name!r}: found {len(matches)} matches; use `board` to check names") return matches[0] def unique_card(cards, name): matches = [item for item in cards if item.get("name") == name] if len(matches) != 1: raise PlankaError(f"Card {name!r}: found {len(matches)} matches; use `board` to check names") return matches[0] def next_position(cards, list_id): return max((c.get("position") or 0 for c in cards if c.get("listId") == list_id), default=0) + 65536 def preview(text, full): text = text or "" if full or len(text) <= 800: return text return text[:800] + f"... (truncated, {len(text)} chars total; use --full)" def command(args): if args.action == "spec": document = spec(refresh=args.spec_action == "refresh") if args.spec_action == "refresh": emit("spec", str(CACHE)) emit("endpoints", len(document["paths"])) else: rows = [(method.upper(), path) for path, methods in document["paths"].items() for method in methods if method in ("get", "post", "patch", "put", "delete") and (not args.search or args.search.lower() in path.lower())] table("endpoints", ("method", "path"), rows) return client = Client() if args.action == "overview": projects = client.projects() boards = (projects.get("included") or {}).get("boards") or [] rows = [] for project in projects.get("items") or []: for board in boards: if board.get("projectId") == project.get("id"): detail = client.request("GET", "/boards/{id}", "/boards/" + board["id"]) rows.append((project.get("name") or "", board.get("name") or "", len((detail.get("included") or {}).get("cards") or []))) table("boards", ("project", "board", "cards"), rows) return data = client.board(args.board) lists, cards = board_parts(data) if args.action == "board": emit("board", args.board) rows = [(item.get("name") or "", c.get("name") or "", c.get("id") or "") for item in lists if item.get("name") for c in cards if c.get("listId") == item.get("id")] table("cards", ("list", "title", "id"), rows) table("lists", ("name", "cards"), [(item.get("name") or "", sum(c.get("listId") == item.get("id") for c in cards)) for item in lists if item.get("name")]) return card = unique_card(cards, args.card) if args.action != "create-card" else None if args.action == "card": detail = client.request("GET", "/cards/{id}", "/cards/" + card["id"]) item = detail.get("item") or {} emit("title", item.get("name") or "") emit("id", item.get("id") or "") emit("description", preview(item.get("description"), args.full)) inc = detail.get("included") or {} table("task_lists", ("name", "id"), [(x.get("name") or "", x.get("id") or "") for x in inc.get("taskLists") or []]) table("tasks", ("name", "completed"), [(x.get("name") or "", bool(x.get("isCompleted"))) for x in inc.get("tasks") or []]) return if args.action == "create-card": target = unique_list(lists, args.list) if any(c.get("name") == args.title and c.get("listId") == target["id"] for c in cards): emit("result", "card already exists in target list (no-op)") return result = client.request("POST", "/lists/{listId}/cards", "/lists/" + target["id"] + "/cards", {"type": "project", "name": args.title, "description": args.description, "position": next_position(cards, target["id"])}) emit("created", (result.get("item") or {}).get("name") or args.title) return if args.action == "move-card": target = unique_list(lists, args.list) if card.get("listId") == target["id"]: emit("result", "card already in target list (no-op)") return result = client.request("PATCH", "/cards/{id}", "/cards/" + card["id"], {"listId": target["id"], "position": next_position(cards, target["id"])}) emit("moved", (result.get("item") or {}).get("name") or args.card) emit("list", args.list) class Parser(argparse.ArgumentParser): def error(self, message): emit("error", message) emit("help", self.format_usage().strip()) self.exit(2) def parser(): p = Parser(description="Inspect and manage Planka boards in the current project") p.add_argument("--version", "-v", "-V", action="version", version=VERSION) sub = p.add_subparsers(dest="action", parser_class=Parser) sub.add_parser("overview", help="List projects and boards") board = sub.add_parser("board", help="List cards on a board") board.add_argument("board") card = sub.add_parser("card", help="Show a card") card.add_argument("board") card.add_argument("card") card.add_argument("--full", action="store_true", help="Do not truncate description") create = sub.add_parser("create-card", help="Create a card; requires --confirm") create.add_argument("board") create.add_argument("list") create.add_argument("title") create.add_argument("--description", default="") create.add_argument("--confirm", action="store_true", help="Confirm the board write") move = sub.add_parser("move-card", help="Move a card; requires --confirm") move.add_argument("board") move.add_argument("card") move.add_argument("list") move.add_argument("--confirm", action="store_true", help="Confirm the board write") spec_cmd = sub.add_parser("spec", help="Inspect cached OpenAPI spec") spec_sub = spec_cmd.add_subparsers(dest="spec_action", required=True) endpoints = spec_sub.add_parser("endpoints", help="List endpoints, optionally filtered") endpoints.add_argument("search", nargs="?") spec_sub.add_parser("refresh", help="Refresh the spec cache") return p def main(): p = parser() try: args = p.parse_args() except SystemExit as exc: return exc.code if args.action is None: args.action = "overview" if args.action in ("create-card", "move-card") and not args.confirm: emit("error", "Board writes require --confirm after user approval") emit("help", f"Run `planka.py {args.action} --help` for usage") return 2 try: command(args) except (PlankaError, OSError, KeyError, TypeError) as exc: emit("error", str(exc)) emit("help", "Check `planka.py --help` or `planka.py spec endpoints `") return 1 return 0 if __name__ == "__main__": sys.exit(main())