#!/usr/bin/env -S uv run --script # /// script # dependencies = ["pyyaml"] # /// """ project.py — backend for /project skill. Deterministic CRUD for project markdown files with YAML frontmatter. """ from __future__ import annotations import argparse import json import re import sys from datetime import date from pathlib import Path import yaml WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent PROJECTS_DIR = WORKSPACE / "projects" # Valid statuses and priorities STATUSES = {"active", "paused", "done"} PRIORITIES = {"high", "medium", "low"} def _slugify(name: str) -> str: """Kebab-case slug from first few words of name. Max 4 words.""" words = re.sub(r"[^a-zA-Z0-9\s]", "", name).lower().split() words = words[:4] return "-".join(words) if words else "project" def _list_projects() -> list[dict]: """Parse frontmatter from all .md files in projects/.""" if not PROJECTS_DIR.exists(): return [] projects = [] for path in sorted(PROJECTS_DIR.glob("*.md")): text = path.read_text(encoding="utf-8") frontmatter, _ = _split_frontmatter(text) if frontmatter: meta = yaml.safe_load(frontmatter) or {} meta["_file"] = path.name projects.append(meta) return projects def _split_frontmatter(text: str) -> tuple[str | None, str]: """Split YAML frontmatter from body. Returns (frontmatter_yaml, body).""" if not text.startswith("---\n"): return None, text end = text.find("\n---\n", 4) if end == -1: return None, text return text[4:end], text[end + 5 :] def _load_file(slug: str) -> tuple[Path, str, str | None, str]: """Load project file. Returns (path, full_text, frontmatter_yaml, body).""" path = PROJECTS_DIR / f"{slug}.md" if not path.exists(): raise FileNotFoundError(f"Project '{slug}' not found ({path.name})") text = path.read_text(encoding="utf-8") fm, body = _split_frontmatter(text) return path, text, fm, body def _write_file(path: Path, frontmatter: dict, body: str) -> None: """Write project file with YAML frontmatter.""" fm_yaml = yaml.safe_dump(frontmatter, allow_unicode=True, sort_keys=False, default_flow_style=False) path.write_text(f"---\n{fm_yaml}---\n{body}", encoding="utf-8") # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- def cmd_add(args: argparse.Namespace) -> int: name = (args.name or "").strip() if not name: print(json.dumps({"error": "name must not be empty"}), file=sys.stderr) return 1 slug = _slugify(name) PROJECTS_DIR.mkdir(parents=True, exist_ok=True) path = PROJECTS_DIR / f"{slug}.md" if path.exists(): print(json.dumps({"error": f"project '{slug}' already exists"}), file=sys.stderr) return 1 priority = (args.priority or "medium").lower() if priority not in PRIORITIES: print(json.dumps({"error": f"invalid priority '{priority}' — use high/medium/low"}), file=sys.stderr) return 1 frontmatter = { "status": "active", "priority": priority, "created": date.today().isoformat(), "slug": slug, } body = f"# {name}\n\n## Poznámky\n\n## Další krok\n\n" _write_file(path, frontmatter, body) print(json.dumps({"added": {"slug": slug, "name": name, "path": str(path.relative_to(WORKSPACE))}}, ensure_ascii=False)) return 0 def cmd_list(_args: argparse.Namespace) -> int: projects = _list_projects() active = [p for p in projects if p.get("status") == "active"] # Sort by priority: high > medium > low priority_order = {"high": 0, "medium": 1, "low": 2} active.sort(key=lambda p: priority_order.get(p.get("priority", "medium"), 1)) print(json.dumps({"projects": active}, ensure_ascii=False)) return 0 def cmd_show(args: argparse.Namespace) -> int: slug = (args.slug or "").strip() try: _path, text, _fm, _body = _load_file(slug) except FileNotFoundError as exc: print(json.dumps({"error": str(exc)}), file=sys.stderr) return 1 print(text) return 0 def cmd_status(args: argparse.Namespace) -> int: slug = (args.slug or "").strip() new_status = (args.status or "").strip().lower() if new_status not in STATUSES: print(json.dumps({"error": f"invalid status '{new_status}' — use active/paused/done"}), file=sys.stderr) return 1 try: path, text, fm_yaml, body = _load_file(slug) except FileNotFoundError as exc: print(json.dumps({"error": str(exc)}), file=sys.stderr) return 1 if not fm_yaml: print(json.dumps({"error": "no frontmatter found"}), file=sys.stderr) return 1 frontmatter = yaml.safe_load(fm_yaml) or {} frontmatter["status"] = new_status _write_file(path, frontmatter, body) print(json.dumps({"updated": {"slug": slug, "status": new_status}}, ensure_ascii=False)) return 0 # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main() -> int: parser = argparse.ArgumentParser(description="Project file backend") sub = parser.add_subparsers(dest="command", required=True) p_add = sub.add_parser("add", help="Create a new project") p_add.add_argument("name", help="Project name") p_add.add_argument("--priority", default="medium", help="Priority: high/medium/low") sub.add_parser("list", help="List active projects") p_show = sub.add_parser("show", help="Show full project file") p_show.add_argument("slug", help="Project slug") p_status = sub.add_parser("status", help="Change project status") p_status.add_argument("slug", help="Project slug") p_status.add_argument("status", help="New status: active/paused/done") args = parser.parse_args() dispatch = {"add": cmd_add, "list": cmd_list, "show": cmd_show, "status": cmd_status} return dispatch[args.command](args) if __name__ == "__main__": sys.exit(main())