app frame + storage
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
.pi/cache/
|
||||||
|
__pycache__
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"id": "f2ddb00d",
|
||||||
|
"title": "Celebrate",
|
||||||
|
"tags": [],
|
||||||
|
"status": "open",
|
||||||
|
"created_at": "2026-11-04T15:25:41.786Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Project instructions
|
||||||
|
|
||||||
|
## Planka workflow
|
||||||
|
|
||||||
|
Use the `TodoApp` board in Planka to track project work. Your Planka user is `Pi Agent`. The board has four columns: `TODO`, `IN PROGRESS`, `IN REVIEW`, and `DONE`.
|
||||||
|
|
||||||
|
- Put new tickets in `TODO`.
|
||||||
|
- When you start work on a ticket, assign it to yourself and move it to `IN PROGRESS`.
|
||||||
|
- When your work is finished, move it to `IN REVIEW`.
|
||||||
|
- Do not move tickets to `DONE`; the user reviews them and does that themselves.
|
||||||
|
|
||||||
|
Follow the Planka skill's confirmation requirement before making any board changes.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# TodoApp
|
||||||
|
|
||||||
|
A terminal app for inspecting Pi project todos. The Bubble Tea UI currently shows the selected project's `.pi/todos` directory and a file count; browsing and editing in the UI are planned in later tickets. The `internal/todos` storage package already reads and writes Pi-compatible Markdown files, using `.lock` files and atomic replacements for edits.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
Requires Go 1.24 or newer and a terminal.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . # inspect the current project
|
||||||
|
go run . --project /path/to/repo # inspect another project
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
If `.pi/todos` does not exist, the app shows an empty state without creating files. If the project path is invalid or the todo directory cannot be read, it exits with an error. Press `q` or `Ctrl+C` to exit the TUI.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
type model struct {
|
||||||
|
project string
|
||||||
|
todosDir string
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newModel(project string) (model, error) {
|
||||||
|
absolute, err := filepath.Abs(project)
|
||||||
|
if err != nil {
|
||||||
|
return model{}, fmt.Errorf("resolve project directory %q: %w", project, err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(absolute)
|
||||||
|
if err != nil {
|
||||||
|
return model{}, fmt.Errorf("project directory %q: %w", absolute, err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return model{}, fmt.Errorf("project path %q is not a directory", absolute)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join(absolute, ".pi", "todos")
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return model{project: absolute, todosDir: dir}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return model{}, fmt.Errorf("read todo directory %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return model{project: absolute, todosDir: dir, count: count}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if key, ok := message.(tea.KeyMsg); ok {
|
||||||
|
switch key.String() {
|
||||||
|
case "q", "ctrl+c":
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) View() string {
|
||||||
|
var state string
|
||||||
|
if m.count == 0 {
|
||||||
|
state = "No todos yet. Pi stores todo items in this directory."
|
||||||
|
} else {
|
||||||
|
state = fmt.Sprintf("Found %d todo file(s). Browsing is coming in the next ticket.", m.count)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("TodoApp\n\nProject: %s\nTodos: %s\n\n%s\n\nPress q or Ctrl+C to quit.\n", m.project, m.todosDir, state)
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewModelWithoutTodos(t *testing.T) {
|
||||||
|
project := t.TempDir()
|
||||||
|
app, err := newModel(project)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if app.todosDir != filepath.Join(project, ".pi", "todos") || app.count != 0 {
|
||||||
|
t.Fatalf("unexpected model: %+v", app)
|
||||||
|
}
|
||||||
|
if !strings.Contains(app.View(), "No todos yet") {
|
||||||
|
t.Fatalf("missing empty state: %s", app.View())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewModelWithTodos(t *testing.T) {
|
||||||
|
project := t.TempDir()
|
||||||
|
dir := filepath.Join(project, ".pi", "todos")
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"a.md", "b.md", "settings.json"} {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), nil, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app, err := newModel(project)
|
||||||
|
if err != nil || app.count != 2 {
|
||||||
|
t.Fatalf("unexpected result: %+v, %v", app, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidProjectAndTodoPath(t *testing.T) {
|
||||||
|
project := t.TempDir()
|
||||||
|
if _, err := newModel(filepath.Join(project, "missing")); err == nil || !strings.Contains(err.Error(), "project directory") {
|
||||||
|
t.Fatalf("expected missing project error, got %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(project, ".pi"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(project, ".pi", "todos"), nil, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := newModel(project); err == nil || !strings.Contains(err.Error(), "read todo directory") {
|
||||||
|
t.Fatalf("expected todo directory error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuitKey(t *testing.T) {
|
||||||
|
app := model{}
|
||||||
|
_, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
|
||||||
|
if cmd == nil || cmd() != tea.Quit() {
|
||||||
|
t.Fatal("q should quit")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
module todoapp
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
golang.org/x/text v0.3.8 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
// Package todos reads and writes the Markdown todo files used by Pi's todo extension.
|
||||||
|
package todos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var idPattern = regexp.MustCompile(`^[a-f0-9]{8}$`)
|
||||||
|
|
||||||
|
// ErrLocked means another session owns the todo's .lock file. We never steal locks.
|
||||||
|
var ErrLocked = errors.New("todo is locked by another session")
|
||||||
|
|
||||||
|
// Todo matches the pi extension's JSON header and optional Markdown body.
|
||||||
|
type Todo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
AssignedToSession string `json:"assigned_to_session,omitempty"`
|
||||||
|
Body string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dir returns Pi's default todo directory for a project.
|
||||||
|
func Dir(project string) string { return filepath.Join(project, ".pi", "todos") }
|
||||||
|
|
||||||
|
func validateID(id string) error {
|
||||||
|
if !idPattern.MatchString(id) {
|
||||||
|
return fmt.Errorf("invalid todo ID %q: expected 8 lowercase hex digits", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse reads a JSON object followed by an optional blank line and Markdown body.
|
||||||
|
// The filename is authoritative for the ID, as in the Pi extension.
|
||||||
|
func Parse(id string, data []byte) (Todo, error) {
|
||||||
|
if err := validateID(id); err != nil {
|
||||||
|
return Todo{}, err
|
||||||
|
}
|
||||||
|
if len(data) == 0 || data[0] != '{' {
|
||||||
|
return Todo{}, fmt.Errorf("todo %s: missing JSON header", id)
|
||||||
|
}
|
||||||
|
var header struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
AssignedToSession string `json:"assigned_to_session"`
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
if err := decoder.Decode(&header); err != nil {
|
||||||
|
return Todo{}, fmt.Errorf("todo %s: invalid JSON header: %w", id, err)
|
||||||
|
}
|
||||||
|
if header.ID != "" && header.ID != id {
|
||||||
|
return Todo{}, fmt.Errorf("todo %s: header ID %q differs from filename", id, header.ID)
|
||||||
|
}
|
||||||
|
rest := data[decoder.InputOffset():]
|
||||||
|
if len(rest) > 0 && rest[0] != '\n' && !bytes.HasPrefix(rest, []byte("\r\n")) {
|
||||||
|
return Todo{}, fmt.Errorf("todo %s: expected newline after JSON header", id)
|
||||||
|
}
|
||||||
|
body := strings.TrimLeft(string(rest), "\r\n")
|
||||||
|
if header.Status == "" {
|
||||||
|
header.Status = "open"
|
||||||
|
}
|
||||||
|
if header.Tags == nil {
|
||||||
|
header.Tags = []string{}
|
||||||
|
}
|
||||||
|
return Todo{ID: id, Title: header.Title, Tags: header.Tags, Status: header.Status,
|
||||||
|
CreatedAt: header.CreatedAt, AssignedToSession: header.AssignedToSession, Body: body}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize emits the extension's JSON header and a blank line before any body.
|
||||||
|
func Serialize(todo Todo) ([]byte, error) {
|
||||||
|
if err := validateID(todo.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if todo.Tags == nil {
|
||||||
|
todo.Tags = []string{}
|
||||||
|
}
|
||||||
|
if todo.Status == "" {
|
||||||
|
todo.Status = "open"
|
||||||
|
}
|
||||||
|
header, err := json.MarshalIndent(todo, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body := strings.TrimRight(strings.TrimLeft(todo.Body, "\r\n"), " \t\r\n")
|
||||||
|
if body == "" {
|
||||||
|
return append(header, '\n'), nil
|
||||||
|
}
|
||||||
|
return []byte(string(header) + "\n\n" + body + "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read loads one todo. It does not modify or acquire a lock on the file.
|
||||||
|
func Read(dir, id string) (Todo, error) {
|
||||||
|
if err := validateID(id); err != nil {
|
||||||
|
return Todo{}, err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, id+".md"))
|
||||||
|
if err != nil {
|
||||||
|
return Todo{}, fmt.Errorf("read todo %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return Parse(id, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List reads todos in filename order. Malformed files are reported, not overwritten.
|
||||||
|
func List(dir string) ([]Todo, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return []Todo{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read todo directory: %w", err)
|
||||||
|
}
|
||||||
|
result := make([]Todo, 0)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := strings.TrimSuffix(entry.Name(), ".md")
|
||||||
|
if err := validateID(id); err != nil {
|
||||||
|
continue // unrelated Markdown file
|
||||||
|
}
|
||||||
|
todo, err := Read(dir, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, todo)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func withLock(dir, id string, fn func() error) error {
|
||||||
|
lockPath := filepath.Join(dir, id+".lock")
|
||||||
|
lock, err := os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||||
|
if errors.Is(err, os.ErrExist) {
|
||||||
|
return fmt.Errorf("todo %s: %w", id, ErrLocked)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("lock todo %s: %w", id, err)
|
||||||
|
}
|
||||||
|
// Include the fields Pi's extension expects, without claiming a Pi session.
|
||||||
|
info, _ := json.Marshal(map[string]any{"id": id, "pid": os.Getpid(), "created_at": time.Now().UTC().Format(time.RFC3339Nano)})
|
||||||
|
if _, err := lock.Write(info); err != nil {
|
||||||
|
lock.Close()
|
||||||
|
os.Remove(lockPath)
|
||||||
|
return fmt.Errorf("write lock for todo %s: %w", id, err)
|
||||||
|
}
|
||||||
|
lock.Close()
|
||||||
|
defer os.Remove(lockPath)
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTemp(dir string, data []byte, perm os.FileMode) (string, error) {
|
||||||
|
temp, err := os.CreateTemp(dir, ".todoapp-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
name := temp.Name()
|
||||||
|
defer func() {
|
||||||
|
temp.Close()
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(name)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err = temp.Chmod(perm); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err = temp.Write(data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err = temp.Sync(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err = temp.Close(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create allocates a random Pi-compatible ID and never replaces an existing file.
|
||||||
|
func Create(dir string, todo Todo) (Todo, error) {
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return Todo{}, fmt.Errorf("create todo directory: %w", err)
|
||||||
|
}
|
||||||
|
for range 10 {
|
||||||
|
var random [4]byte
|
||||||
|
if _, err := rand.Read(random[:]); err != nil {
|
||||||
|
return Todo{}, err
|
||||||
|
}
|
||||||
|
todo.ID = hex.EncodeToString(random[:])
|
||||||
|
todo.CreatedAt = time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||||
|
if todo.Status == "" {
|
||||||
|
todo.Status = "open"
|
||||||
|
}
|
||||||
|
data, err := Serialize(todo)
|
||||||
|
if err != nil {
|
||||||
|
return Todo{}, err
|
||||||
|
}
|
||||||
|
err = withLock(dir, todo.ID, func() error {
|
||||||
|
name, err := writeTemp(dir, data, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(name)
|
||||||
|
return os.Link(name, filepath.Join(dir, todo.ID+".md")) // atomic, no overwrite
|
||||||
|
})
|
||||||
|
if errors.Is(err, os.ErrExist) || errors.Is(err, ErrLocked) {
|
||||||
|
continue // rare ID collision
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Todo{}, fmt.Errorf("create todo %s: %w", todo.ID, err)
|
||||||
|
}
|
||||||
|
return todo, nil
|
||||||
|
}
|
||||||
|
return Todo{}, errors.New("failed to allocate an unused todo ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update edits a todo under a Pi-compatible lock and atomically replaces the file.
|
||||||
|
// ID, creation time, and assignment always come from the existing file.
|
||||||
|
func Update(dir string, todo Todo) error {
|
||||||
|
if err := validateID(todo.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return withLock(dir, todo.ID, func() error {
|
||||||
|
old, err := Read(dir, todo.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
todo.CreatedAt = old.CreatedAt
|
||||||
|
todo.AssignedToSession = old.AssignedToSession
|
||||||
|
if todo.Status == "" {
|
||||||
|
todo.Status = old.Status
|
||||||
|
}
|
||||||
|
if todo.Tags == nil {
|
||||||
|
todo.Tags = old.Tags
|
||||||
|
}
|
||||||
|
data, err := Serialize(todo)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, todo.ID+".md")
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name, err := writeTemp(dir, data, info.Mode().Perm())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(name)
|
||||||
|
if err := os.Rename(name, path); err != nil {
|
||||||
|
return fmt.Errorf("replace todo %s: %w", todo.ID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package todos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fixture = `{
|
||||||
|
"id": "deadbeef",
|
||||||
|
"title": "A brace } and quote \"",
|
||||||
|
"tags": ["qa", "pi"],
|
||||||
|
"status": "in_progress",
|
||||||
|
"created_at": "2026-01-25T17:00:00.000Z",
|
||||||
|
"assigned_to_session": "session.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
## Details
|
||||||
|
|
||||||
|
Keep {braces} in the body.
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestParseAndSerializePiFixture(t *testing.T) {
|
||||||
|
todo, err := Parse("deadbeef", []byte(fixture))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if todo.ID != "deadbeef" || todo.Title != `A brace } and quote "` || todo.Status != "in_progress" || todo.AssignedToSession != "session.json" || !strings.Contains(todo.Body, "{braces}") {
|
||||||
|
t.Fatalf("unexpected parsed todo: %+v", todo)
|
||||||
|
}
|
||||||
|
data, err := Serialize(todo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := Parse(todo.ID, data)
|
||||||
|
if err != nil || again.Title != todo.Title || again.Status != todo.Status || again.Body != todo.Body {
|
||||||
|
t.Fatalf("round trip mismatch: %+v, %v", again, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUpdateAndList(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), ".pi", "todos")
|
||||||
|
if items, err := List(dir); err != nil || len(items) != 0 {
|
||||||
|
t.Fatalf("missing directory should be empty: %v, %v", items, err)
|
||||||
|
}
|
||||||
|
created, err := Create(dir, Todo{Title: "First", Body: "hello\n"})
|
||||||
|
if err != nil || !idPattern.MatchString(created.ID) || created.Status != "open" || created.CreatedAt == "" {
|
||||||
|
t.Fatalf("unexpected create: %+v, %v", created, err)
|
||||||
|
}
|
||||||
|
loaded, err := Read(dir, created.ID)
|
||||||
|
if err != nil || loaded.Title != "First" || loaded.Body != "hello\n" {
|
||||||
|
t.Fatalf("unexpected read: %+v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
loaded.Title = "Changed"
|
||||||
|
loaded.Status = "blocked" // Pi supports statuses besides open/closed
|
||||||
|
loaded.Tags = []string{"test"}
|
||||||
|
loaded.AssignedToSession = "session.json"
|
||||||
|
if err := Update(dir, loaded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items, err := List(dir)
|
||||||
|
if err != nil || len(items) != 1 || items[0].Title != "Changed" || items[0].Status != "blocked" || items[0].CreatedAt != created.CreatedAt || items[0].AssignedToSession != "" {
|
||||||
|
t.Fatalf("unexpected update: %+v, %v", items, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, created.ID+".lock")); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("lock left behind: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLockedFileUnchanged(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "deadbeef.md")
|
||||||
|
if err := os.WriteFile(path, []byte(fixture), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "deadbeef.lock"), []byte(`{"session":"other"}`), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := Update(dir, Todo{ID: "deadbeef", Title: "Changed"})
|
||||||
|
if !errors.Is(err, ErrLocked) {
|
||||||
|
t.Fatalf("expected lock error: %v", err)
|
||||||
|
}
|
||||||
|
content, _ := os.ReadFile(path)
|
||||||
|
if string(content) != fixture {
|
||||||
|
t.Fatal("locked file was changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePreservesAssignment(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "deadbeef.md"), []byte(fixture), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := Update(dir, Todo{ID: "deadbeef", Title: "Edited", Body: "text"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := Read(dir, "deadbeef")
|
||||||
|
if err != nil || result.AssignedToSession != "session.json" || result.CreatedAt != "2026-01-25T17:00:00.000Z" || result.Status != "in_progress" || len(result.Tags) != 2 {
|
||||||
|
t.Fatalf("metadata lost: %+v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSerializePreservesBodyIndentation(t *testing.T) {
|
||||||
|
data, err := Serialize(Todo{ID: "deadbeef", Body: "\n indented text\n"})
|
||||||
|
if err != nil || !strings.HasSuffix(string(data), "\n\n indented text\n") {
|
||||||
|
t.Fatalf("indentation lost: %s, %v", data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformedAndUnsafeIDs(t *testing.T) {
|
||||||
|
if _, err := Parse("../bad", []byte(fixture)); err == nil {
|
||||||
|
t.Fatal("unsafe ID accepted")
|
||||||
|
}
|
||||||
|
if _, err := Parse("deadbeef", []byte(`{bad`)); err == nil {
|
||||||
|
t.Fatal("bad JSON accepted")
|
||||||
|
}
|
||||||
|
if _, err := Parse("deadbeef", []byte(`{"id":"cafebabe"}`)); err == nil {
|
||||||
|
t.Fatal("mismatched ID accepted")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "deadbeef.md"), []byte(`{bad`), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := List(dir); err == nil {
|
||||||
|
t.Fatal("malformed file silently ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
project := flag.String("project", ".", "project directory to inspect (default: current directory)")
|
||||||
|
flag.Parse()
|
||||||
|
if flag.NArg() != 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "unexpected arguments; use --project PATH")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := newModel(*project)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "todoapp:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if _, err := tea.NewProgram(model, tea.WithAltScreen()).Run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "todoapp: terminal error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user