app frame + storage

This commit is contained in:
2026-11-04 16:33:26 +01:00
commit 21694908a3
16 changed files with 1198 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
# Planka Skill for AI agents
An AI agent skill for managing [Planka](https://github.com/plankanban/planka) kanban boards through natural language. No rigid CLI syntax — just describe what you want.
## Setup
1. Clone this repo into your Claude Code skills directory:
```bash
git clone <repo-url> ~/.agents/skills/planka-skill
```
Or copy the folder manually to `~/.agents/skills/`.
2. Generate an API key in Planka: User Settings → API Key
3. Set environment variables (in project `.env`, `~/.env`, or shell):
```bash
PLANKA_URL=https://planka.example.com
PLANKA_API_KEY=your-api-key
```
A trailing slash in `PLANKA_URL` is fine. The skill strips it before adding `/api`.
When both env files exist, the project's `.env` overrides `~/.env`;
values in either file override existing shell values.
## Helper CLI
Run from the target project's root. If the skill lives elsewhere, replace the script path with its installed location. The CLI needs only Python 3's standard library.
```bash
python3 .pi/skills/planka/planka.py # projects, boards, card counts
python3 .pi/skills/planka/planka.py board TodoApp # lists and cards
python3 .pi/skills/planka/planka.py card TodoApp "Login Bug" # card details
python3 .pi/skills/planka/planka.py card TodoApp "Login Bug" --full
python3 .pi/skills/planka/planka.py spec endpoints card-labels
python3 .pi/skills/planka/planka.py spec refresh
```
After the user approves a board write:
```bash
python3 .pi/skills/planka/planka.py create-card TodoApp TODO "Fix login" --description "Steps to reproduce..." --confirm
python3 .pi/skills/planka/planka.py move-card TodoApp "Fix login" "IN PROGRESS" --confirm
```
The script resolves board, list, and card names to current IDs. Ambiguous names fail instead of picking one. Writes require `--confirm` **and** prior user approval in conversation. It refuses unknown flags. Run each subcommand with `--help` for arguments.
The first API call downloads the published OpenAPI spec to `.pi/cache/planka-swagger.json` in the current project; later calls use the cache. Run `spec refresh` to fetch a new copy. The cache is public schema data, not credentials. If the server's API differs from the published spec, refresh and inspect its version rather than guessing paths. A first-time run needs network access to both the spec and Planka; subsequent endpoint checks work offline, but board operations still need Planka.
For unsupported operations, inspect the cached spec first (`spec endpoints <term>` and the request schema in the JSON file). Use the API base `${PLANKA_URL%/}/api`, `X-Api-Key` authentication, encoded JSON bodies, and validate the response content type and JSON. Never print the key. The helper reads simple `KEY=value` / quoted values for `PLANKA_URL` and `PLANKA_API_KEY` from `~/.env` then `.env`; it does not execute shell expressions in those files.
## Quick Commands
```
/planka overview # all projects and boards at a glance
/planka board "My Project" # detailed board view with cards and progress
```
## What You Can Do
Talk naturally — the skill understands intent and figures out the API calls.
### Browse & Inspect
```
/planka show me all my boards
/planka what's in the ToDo list?
/planka show me card "Login Bug" with all its tasks
```
### Create & Organize
```
/planka create a card "Dark Mode" in Backlog with fitting labels
/planka set up a new board "Backend" with lists ToDo, In Progress, Done, Blocked
/planka add labels Bug (red), Feature (blue), Urgent (red) to the "My Project" board
/planka add tasks to "Dark Mode": update theme provider, test contrast, update docs
```
### Move & Update
```
/planka move "Dark Mode" to In Progress
/planka mark "Dark Mode" as done
/planka rename "Dark Mode" to "Dark Mode v2"
/planka add a comment to "Dark Mode": waiting for design review
```
### Bulk Operations
```
/planka move everything from In Progress to Done
/planka read the git log and create cards for all completed features
/planka look at board X and create the same structure for a new board
/planka check all cards for typos and fix them
```
### Project Sync
```
/planka sync the board with our recent git commits
/planka create cards for all open issues
/planka what changed on the board since last week?
```
The helper caches the [Planka OpenAPI spec](https://plankanban.github.io/planka/swagger-ui/swagger.json). Refresh it explicitly when the API changes.
## Requirements
- Planka instance with API key support
- Python 3
+74
View File
@@ -0,0 +1,74 @@
---
name: planka
description: Manage Planka kanban boards — create, organize, and track project tasks
argument-hint: overview | board <name>
---
Interact with a self-hosted Planka instance. Planka is a Trello-like kanban board.
> See [README.md](README.md) for setup instructions, usage examples, and advanced use cases.
## Setup
Required environment variables:
- `PLANKA_URL` — Base URL (e.g. `https://planka.example.com`)
- `PLANKA_API_KEY` — API key (generated in Planka UI → User Settings)
These can be set in:
1. The current project's `.env` file (most common)
2. `~/.env` (global, for cross-project use)
3. Shell environment (e.g. `~/.bashrc` or `~/.zshrc`)
Use the bundled helper from the **project root** (replace the path if the skill was installed elsewhere):
```bash
python3 .pi/skills/planka/planka.py # projects and boards
python3 .pi/skills/planka/planka.py board TodoApp
python3 .pi/skills/planka/planka.py card TodoApp "Card title" --full
python3 .pi/skills/planka/planka.py spec endpoints card-labels
python3 .pi/skills/planka/planka.py spec refresh
```
The helper loads `~/.env`, then the project's `.env` (project values win), then normalizes `PLANKA_URL` before appending `/api`. It validates JSON and does not print the key. It stores the published OpenAPI spec in `.pi/cache/planka-swagger.json` in the current project on first use, reuses it offline, and refreshes it only on `spec refresh`. The cache contains public API documentation, never credentials. Run from the project root so the right env and cache are used.
**Full OpenAPI spec**: https://plankanban.github.io/planka/swagger-ui/swagger.json
### Finding endpoints
**Never guess endpoints.** Use `spec endpoints <search-term>` to inspect the cached raw spec. Do not rely on summaries of the spec. For operations beyond the helper's commands, inspect the relevant request schema in `.pi/cache/planka-swagger.json` before calling the API. On 404, do not repeat the same request; refresh the spec and check the installed server's version. The helper refuses endpoints absent from its cached spec, so refresh if it is stale.
## Key Concepts
- **Hierarchy**: Project → Board → List → Card → Task List → Task
- **Responses**: `{"item": {...}}` for single, `{"items": [...]}` for collections, related data in `"included"`
- **Positioning**: Numeric, multiples of 65536. Lower = higher on the board.
- **Colors** (for lists/labels): e.g. `dark-granite`, `lagoon-blue`, `orange-peel`, `bright-moss`, `berry-red`, `light-mud`, `midnight-blue`, `light-cocoa`, `summer-sky`, `pumpkin-orange`
## How to Use
The user can talk naturally or use optional shorthand commands:
| Command | Description |
|---------|-------------|
| `/planka overview` | Show all projects and boards |
| `/planka board <name>` | Show a specific board with lists and cards |
These are shortcuts — the user can also just describe what they want freely: "create a card for Feature X", "move Bug Y to Done", "set up a new board with labels", etc.
Understand the intent, look up the right endpoints from the OpenAPI spec if needed, and execute.
**Finding boards**: Never hardcode IDs. Always list projects first, find boards from the included data, and match by name.
**Null safety**: Many Planka fields can be `None`/`null` — name, description, color, position, etc. Always use `or ""`, `or 0`, `(x or "").strip()` patterns in Python code. Never call methods on potentially null values without guarding.
**Display formatting**:
- `overview`: Show projects as headers with boards as bullet points. Include board card count.
- `board <name>`: Show each list as a section with its cards listed below. For each card show: name, labels (as colored badges), task progress (e.g. 3/5), and description preview if available. Use markdown formatting — headers for lists, bullet points for cards. Skip empty/unnamed system lists (archive, trash).
Always:
- Prefer the helper for supported operations. Use `create-card <board> <list> <title> [--description TEXT] --confirm` and `move-card <board> <card> <list> --confirm` only after user approval. Names must be unambiguous; the helper resolves current IDs each time. `--confirm` is a safety gate, **not** a substitute for conversational approval.
- For unsupported operations, inspect the cached spec first and use `${PLANKA_URL%/}/api` (never `//api`), JSON encoding, response validation, and the same env precedence as the helper. See [README.md](README.md).
- Ask the user when something is ambiguous (e.g. multiple boards/projects).
- **Always confirm before executing a board write**: describe what you're about to do (e.g. "I'll create a card 'Dark Mode' in Backlog") and wait for approval. This applies to create, update, move, and delete.
+288
View File
@@ -0,0 +1,288 @@
#!/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 <term>`")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+60
View File
@@ -0,0 +1,60 @@
"""Run: python3 -m unittest discover -s .pi/skills/planka -p 'test_*.py'"""
import importlib.util
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location("planka", Path(__file__).with_name("planka.py"))
planka = importlib.util.module_from_spec(spec)
spec.loader.exec_module(planka)
class PlankaTests(unittest.TestCase):
def test_config_precedence_and_trailing_slash(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / ".env").write_text('PLANKA_URL="https://project.example/"\nPLANKA_API_KEY=project-key\n')
home = root / "home"
home.mkdir()
(home / ".env").write_text('PLANKA_URL=https://global.example/\nPLANKA_API_KEY=global-key\n')
with patch.object(Path, "home", return_value=home), patch.object(Path, "cwd", return_value=root), patch.dict(os.environ, {"PLANKA_URL": "https://shell.example", "PLANKA_API_KEY": "shell-key"}):
self.assertEqual(planka.load_config(), ("https://project.example", "project-key"))
def test_cache_fetch_once_and_refresh(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "spec.json"
with patch.object(planka, "CACHE", path), patch.object(planka, "fetch_json", return_value={"paths": {"/projects": {"get": {}}}}) as fetch:
self.assertEqual(planka.spec()["paths"], {"/projects": {"get": {}}})
planka.spec()
self.assertEqual(fetch.call_count, 1)
planka.spec(refresh=True)
self.assertEqual(fetch.call_count, 2)
self.assertEqual(json.loads(path.read_text())["paths"], {"/projects": {"get": {}}})
def test_non_json_html_200_is_rejected(self):
class Response:
headers = {"Content-Type": "text/html"}
def __enter__(self): return self
def __exit__(self, *args): pass
def read(self): return b"<html>app</html>"
with patch.object(planka, "urlopen", return_value=Response()):
with self.assertRaisesRegex(planka.PlankaError, "expected JSON"):
planka.fetch_json("https://example.test/api/projects", "secret")
def test_positions_and_duplicate_selection(self):
cards = [{"name": "Same", "listId": "a", "position": 65536}, {"name": "Same", "listId": "b", "position": 131072}]
self.assertEqual(planka.next_position(cards, "a"), 131072)
with self.assertRaisesRegex(planka.PlankaError, "2 matches"):
planka.unique_card(cards, "Same")
def test_write_requires_confirm(self):
with patch.object(planka, "Client") as client, patch.object(planka.sys, "argv", ["planka.py", "move-card", "Board", "Card", "List"]):
self.assertEqual(planka.main(), 2)
client.assert_not_called()
if __name__ == "__main__":
unittest.main()
+8
View File
@@ -0,0 +1,8 @@
{
"id": "ddb4f64c",
"title": "Implement the project using the Planka board",
"tags": [],
"status": "in_progress",
"created_at": "2026-11-04T15:25:39.707Z",
"assigned_to_session": "01a1a70c-fa7a-743f-86e3-469bd89f9d8e"
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "f2ddb00d",
"title": "Celebrate",
"tags": [],
"status": "open",
"created_at": "2026-11-04T15:25:41.786Z"
}