clone

a hermes agent plugin for openspec: tools for spec workflows and a dashboard tab for browsing changes and specs

main
hermes-openspec / dashboard / plugin_api.py
plugin_api.py 33.2K
1"""OpenSpec dashboard plugin — backend API routes.23Mounted at /api/plugins/openspec/ by the dashboard plugin system4(``hermes_cli.web_server._mount_plugin_api_routes`` imports this file and5``app.include_router``\\ s it). Every handler is a thin wrapper around the6plugin-local registry (``plugins.openspec.registry``) plus filesystem/git7scanners that live in this module — no core ``web_server`` helpers are used,8so the plugin is self-contained.910Routes11------12- GET /sources — list registered sources (with live board scan)13- POST /sources — register a source by path14- DELETE /sources/{source_id} — unregister a source15- PUT /sources/{source_id} — update a source's path/name16- GET /sources/{source_id}/changes/{change} — change detail (proposal/tasks/design/specs)17- GET /sources/{source_id}/ideas/{idea} — idea detail (markdown content)18- GET /sources/{source_id}/specs?path= — spec detail (current worktree content)19- GET /sources/{source_id}/spec-browser — current/dirty/ref-diff spec browser2021Auth22----23HTTP routes go through the dashboard's session-token auth middleware24(``web_server.auth_middleware``) just like core API routes — every25``/api/plugins/...`` request must present the session bearer token (or the26session cookie set when you load the dashboard HTML).27"""2829from __future__ import annotations3031import difflib32import importlib.util33import os34import re35import shutil36import subprocess37from datetime import datetime, timezone38from pathlib import Path39from typing import Any, Optional4041from fastapi import APIRouter, HTTPException, Query42from pydantic import BaseModel4344# ---------------------------------------------------------------------------45# Registry import — path-based, because the dashboard plugin loader imports46# this file standalone via importlib (no package context for relative imports).47# ---------------------------------------------------------------------------48_REGISTRY_PATH = Path(__file__).resolve().parent.parent / "registry.py"495051def _load_registry():52 if not _REGISTRY_PATH.is_file():53 return None54 spec = importlib.util.spec_from_file_location("openspec_registry", _REGISTRY_PATH)55 if spec is None or spec.loader is None:56 return None57 mod = importlib.util.module_from_spec(spec)58 spec.loader.exec_module(mod)59 return mod606162_registry = _load_registry()6364router = APIRouter()656667# ---------------------------------------------------------------------------68# Path guards — local equivalents of web_server._fs_path / _fs_find_git_root.69# Kept here so the plugin has zero core-dependency for filesystem traversal.70# ---------------------------------------------------------------------------7172def _find_openspec_bin() -> Optional[str]:73 """Locate the ``openspec`` CLI binary."""74 candidates = [75 os.getenv("OPENSPEC_BIN", "").strip(),76 shutil.which("openspec") or "",77 str(Path.home() / ".npm-global" / "bin" / "openspec"),78 ]79 for c in candidates:80 if c and Path(c).exists() and os.access(c, os.X_OK):81 return c82 return None838485def _resolve_path(raw_path: str) -> Path:86 """Resolve a user-supplied path string to an absolute Path, rejecting87 null bytes and unparseable values. Mirrors web_server._fs_path semantics."""88 raw = str(raw_path or "").strip()89 if not raw:90 raise HTTPException(status_code=400, detail="Path is required")91 if "\0" in raw:92 raise HTTPException(status_code=400, detail="Invalid path")93 try:94 candidate = Path(raw).expanduser()95 if not candidate.is_absolute():96 candidate = Path.cwd() / candidate97 return candidate.resolve(strict=False)98 except (OSError, RuntimeError, ValueError):99 raise HTTPException(status_code=400, detail="Invalid path")100101102def _find_git_root(start: Path) -> Optional[Path]:103 directory = start104 for _ in range(50):105 try:106 if (directory / ".git").exists():107 return directory108 except OSError:109 return None110 parent = directory.parent111 if parent == directory:112 return None113 directory = parent114 return None115116117def _repo_root(raw_path: str) -> tuple[Path | None, str | None]:118 """Resolve a raw path to a repo root (git root or the path itself).119120 Unlike ``_source_root``, this does NOT require ``openspec/`` to exist —121 it's used for add/update so sources can be registered before init.122 """123 try:124 target = _resolve_path(raw_path)125 except HTTPException as exc:126 return None, str(exc.detail)127 if not target.exists():128 return None, "Path does not exist"129 git_root = _find_git_root(target)130 if git_root:131 return git_root, None132 return target, None133134135def _source_root(raw_path: str) -> tuple[Path | None, str | None]:136 """Resolve a raw path to a repo root containing ``openspec/``.137138 Accepts either a repo root, the ``openspec/`` dir itself, or any path139 inside a repo whose root has ``openspec/``.140 """141 try:142 target = _resolve_path(raw_path)143 except HTTPException as exc:144 return None, str(exc.detail)145 openspec_root = target if target.name == "openspec" else target / "openspec"146 if openspec_root.is_dir():147 return openspec_root.parent, None148 git_root = _find_git_root(target)149 if git_root and (git_root / "openspec").is_dir():150 return git_root, None151 return None, "No openspec/ directory found"152153154# ---------------------------------------------------------------------------155# Markdown / task parsing helpers156# ---------------------------------------------------------------------------157158_OPENSPEC_DOC_MAX_BYTES = 512 * 1024159_TASK_DONE_RE = re.compile(r"^- \[[xX]\]", re.MULTILINE)160_TASK_TODO_RE = re.compile(r"^- \[ \]", re.MULTILINE)161_STATUSES = ("ideas", "draft", "todo", "in_progress", "done", "archived")162163164def _task_stats(tasks_path: Path) -> Optional[dict[str, int]]:165 try:166 content = tasks_path.read_text(encoding="utf-8", errors="replace")167 except OSError:168 return None169 done = len(_TASK_DONE_RE.findall(content))170 todo = len(_TASK_TODO_RE.findall(content))171 return {"total": done + todo, "done": done}172173174def _status(has_tasks: bool, stats: Optional[dict[str, int]], *, archived: bool) -> str:175 if archived:176 return "archived"177 if not has_tasks or stats is None:178 return "draft"179 if stats["done"] == 0:180 return "todo"181 if stats["total"] > 0 and stats["done"] == stats["total"]:182 return "done"183 return "in_progress"184185186def _title_from_markdown(path: Path, fallback: str) -> str:187 try:188 text = path.read_text(encoding="utf-8", errors="replace")189 lines = text.splitlines()190 content_start = 0191 if lines and lines[0].strip() == "---":192 closing_index = next(193 (i for i, line in enumerate(lines[1:], start=1) if line.strip() == "---"),194 None,195 )196 if closing_index is not None:197 frontmatter_title: str | None = None198 for line in lines[1:closing_index]:199 stripped = line.strip()200 if stripped.startswith("title:"):201 val = stripped[6:].strip().strip('"').strip("'")202 if val:203 frontmatter_title = val204 if frontmatter_title:205 return frontmatter_title206 content_start = closing_index + 1207208 for line in lines[content_start:]:209 stripped = line.strip()210 if stripped.startswith("# "):211 return stripped[2:].strip() or fallback212 except OSError:213 pass214 return fallback215216217def _read_doc(path: Path) -> Optional[str]:218 try:219 if not path.is_file():220 return None221 if path.stat().st_size > _OPENSPEC_DOC_MAX_BYTES:222 return path.read_text(encoding="utf-8", errors="replace")[:_OPENSPEC_DOC_MAX_BYTES]223 return path.read_text(encoding="utf-8", errors="replace")224 except OSError:225 return None226227228def _change_token(name: str) -> str:229 if _registry is not None:230 return _registry.change_token(name)231 return ""232233234# ---------------------------------------------------------------------------235# Board scan236# ---------------------------------------------------------------------------237238def _change_summary(path: Path, source_id: str, *, archived: bool = False) -> Optional[dict[str, Any]]:239 if not path.is_dir():240 return None241 proposal_path = path / "proposal.md"242 tasks_path = path / "tasks.md"243 design_path = path / "design.md"244 specs_path = path / "specs"245 if not proposal_path.is_file():246 return None247 has_tasks = tasks_path.is_file()248 stats = _task_stats(tasks_path) if has_tasks else None249 name = path.name250 return {251 "id": f"{source_id}/{name}",252 "token": _change_token(name),253 "name": name,254 "title": _title_from_markdown(proposal_path, name),255 "status": _status(has_tasks, stats, archived=archived),256 "hasProposal": True,257 "hasTasks": has_tasks,258 "hasDesign": design_path.is_file(),259 "hasSpecs": specs_path.is_dir(),260 "taskStats": stats,261 }262263264def _idea_summary(path: Path, source_id: str) -> Optional[dict[str, Any]]:265 if not path.is_file() or path.suffix.lower() != ".md":266 return None267 return {268 "id": f"{source_id}/{path.stem}",269 "token": _change_token(path.stem),270 "name": path.stem,271 "title": _title_from_markdown(path, path.stem),272 "status": "ideas",273 }274275276def _scan(root: Path, source_id: str) -> Optional[dict[str, Any]]:277 openspec_root = root / "openspec"278 if not openspec_root.is_dir():279 return None280 changes: list[dict[str, Any]] = []281 ideas: list[dict[str, Any]] = []282 specs: list[dict[str, Any]] = []283284 changes_root = openspec_root / "changes"285 if changes_root.is_dir():286 try:287 for child in sorted(changes_root.iterdir(), key=lambda item: item.name.lower()):288 if child.name == "archive":289 continue290 change = _change_summary(child, source_id)291 if change is not None:292 changes.append(change)293 except OSError:294 pass295 archive_root = changes_root / "archive"296 if archive_root.is_dir():297 try:298 for child in sorted(archive_root.iterdir(), key=lambda item: item.name.lower()):299 change = _change_summary(child, source_id, archived=True)300 if change is not None:301 changes.append(change)302 except OSError:303 pass304305 ideas_root = openspec_root / "ideas"306 if ideas_root.is_dir():307 try:308 for child in sorted(ideas_root.iterdir(), key=lambda item: item.name.lower()):309 idea = _idea_summary(child, source_id)310 if idea is not None:311 ideas.append(idea)312 except OSError:313 pass314315 specs_root = openspec_root / "specs"316 if specs_root.is_dir():317 try:318 for child in sorted(specs_root.rglob("*.md")):319 rel = child.relative_to(specs_root).as_posix()320 specs.append({321 "id": f"{source_id}/spec/{rel}",322 "token": _change_token(f"spec:{rel}"),323 "name": rel,324 "path": rel,325 "title": _title_from_markdown(child, rel),326 })327 except OSError:328 pass329330 if _registry is not None and hasattr(_registry, "ensure_change_sequence"):331 try:332 sequence = _registry.ensure_change_sequence(source_id, [str(ch.get("name") or "") for ch in changes])333 for change in changes:334 meta = sequence.get(str(change.get("name") or ""))335 if meta:336 change["sequence"] = meta337 except Exception:338 pass339340 by_status = {s: 0 for s in _STATUSES}341 by_status["ideas"] = len(ideas)342 for change in changes:343 status = str(change.get("status") or "draft")344 by_status[status] = by_status.get(status, 0) + 1345346 return {347 "path": str(openspec_root),348 "changes": changes,349 "ideas": ideas,350 "specs": specs,351 "counts": {352 "changes": len(changes),353 "ideas": len(ideas),354 "specs": len(specs),355 "byStatus": by_status,356 },357 }358359360def _source_payload(source: dict[str, Any]) -> dict[str, Any]:361 root, error = _repo_root(source["path"])362 name = source.get("name") or (root.name if root else Path(source["path"]).expanduser().name)363 base = {364 "token": source.get("token") or source.get("id"),365 "id": source.get("id") or source.get("token"),366 "name": name,367 "path": str(Path(source["path"]).expanduser()),368 }369 if root is None:370 return {**base, "valid": False, "repoRoot": None, "openspec": None, "error": error}371 # Repo exists — check if openspec/ is initialized.372 if not (root / "openspec").is_dir():373 return {**base, "valid": False, "repoRoot": str(root), "openspec": None, "error": "No openspec/ directory found"}374 return {375 **base,376 "valid": True,377 "repoRoot": str(root),378 "openspec": _scan(root, str(base["id"])),379 "error": None,380 }381382383def _ensure_openspec_layout(root: Path) -> None:384 """Create every OpenSpec directory root used by this plugin."""385 openspec_root = root / "openspec"386 (openspec_root / "changes" / "archive").mkdir(parents=True, exist_ok=True)387 (openspec_root / "specs").mkdir(parents=True, exist_ok=True)388 (openspec_root / "ideas").mkdir(parents=True, exist_ok=True)389390391# ---------------------------------------------------------------------------392# Detail helpers393# ---------------------------------------------------------------------------394395def _find_change_dir(root: Path, change_name: str) -> tuple[Path | None, bool]:396 changes_root = root / "openspec" / "changes"397 candidate = changes_root / change_name398 if candidate.is_dir():399 return candidate, False400 archive_root = changes_root / "archive"401 if archive_root.is_dir():402 archived = archive_root / change_name403 if archived.is_dir():404 return archived, True405 try:406 for child in archive_root.iterdir():407 if child.is_dir() and (child.name == change_name or child.name.endswith(change_name)):408 return child, True409 except OSError:410 pass411 return None, False412413414def _change_detail(root: Path, change_name: str) -> Optional[dict[str, Any]]:415 change_dir, archived = _find_change_dir(root, change_name)416 if change_dir is None:417 return None418 proposal_path = change_dir / "proposal.md"419 tasks_path = change_dir / "tasks.md"420 design_path = change_dir / "design.md"421 specs_root = change_dir / "specs"422 has_tasks = tasks_path.is_file()423 stats = _task_stats(tasks_path) if has_tasks else None424 specs: list[dict[str, Any]] = []425 if specs_root.is_dir():426 try:427 for child in sorted(specs_root.rglob("*.md")):428 rel = child.relative_to(specs_root).as_posix()429 after = _read_doc(child) or ""430 before = _read_worktree_spec(root, rel)431 status = _spec_status(before, after)432 semantic = _compute_semantic_diff(before, after)433 specs.append({434 "path": rel,435 "content": after,436 "before": before,437 "status": status,438 "diff": _spec_diff(before, after, rel) if status not in ("unchanged", "missing") else "",439 "semantic_diff": semantic,440 })441 except OSError:442 pass443 return {444 "name": change_dir.name,445 "title": _title_from_markdown(proposal_path, change_dir.name),446 "status": _status(has_tasks, stats, archived=archived),447 "archived": archived,448 "taskStats": stats,449 "proposal": _read_doc(proposal_path),450 "design": _read_doc(design_path),451 "tasks": _read_doc(tasks_path),452 "specs": specs,453 }454455456def _idea_detail(root: Path, idea_name: str) -> Optional[dict[str, Any]]:457 ideas_root = root / "openspec" / "ideas"458 candidate = ideas_root / f"{idea_name}.md"459 if not candidate.is_file():460 return None461 return {462 "name": idea_name,463 "title": _title_from_markdown(candidate, idea_name),464 "status": "ideas",465 "content": _read_doc(candidate) or "",466 }467468469def _spec_detail(root: Path, spec_path: str) -> Optional[dict[str, Any]]:470 specs_root = (root / "openspec" / "specs").resolve()471 candidate = (specs_root / spec_path).resolve()472 if specs_root not in candidate.parents and candidate != specs_root:473 return None474 if not candidate.is_file():475 return None476 return {477 "path": spec_path,478 "title": _title_from_markdown(candidate, spec_path),479 "content": _read_doc(candidate) or "",480 }481482483# ---------------------------------------------------------------------------484# Spec browser (current / dirty / ref-diff)485# ---------------------------------------------------------------------------486487def _git_run(root: Path, args: list[str], *, timeout: int = 8) -> subprocess.CompletedProcess[str]:488 return subprocess.run(489 ["git", "-C", str(root), *args],490 capture_output=True,491 text=True,492 timeout=timeout,493 check=False,494 )495496497def _git_output(root: Path, args: list[str], *, timeout: int = 8) -> Optional[str]:498 try:499 proc = _git_run(root, args, timeout=timeout)500 except Exception:501 return None502 if proc.returncode != 0:503 return None504 return proc.stdout505506507def _current_branch(root: Path) -> str:508 return (_git_output(root, ["branch", "--show-current"], timeout=3) or "").strip()509510511def _list_worktree_specs(root: Path) -> list[str]:512 specs_root = root / "openspec" / "specs"513 if not specs_root.is_dir():514 return []515 out: list[str] = []516 try:517 for child in sorted(specs_root.rglob("*.md")):518 out.append(child.relative_to(specs_root).as_posix())519 except OSError:520 return []521 return out522523524def _list_ref_specs(root: Path, ref: str) -> list[str]:525 ref = (ref or "").strip()526 if not ref:527 return []528 # `git ls-tree` returns paths relative to repo root. No shell involved, so529 # arbitrary refs like origin/main, HEAD~3, or full SHAs are safe arguments.530 text = _git_output(root, ["ls-tree", "-r", "--name-only", ref, "--", "openspec/specs"], timeout=8)531 if text is None:532 return []533 prefix = "openspec/specs/"534 return sorted(535 line[len(prefix):]536 for line in text.splitlines()537 if line.startswith(prefix) and line.endswith(".md")538 )539540541def _read_worktree_spec(root: Path, spec_path: str) -> Optional[str]:542 specs_root = (root / "openspec" / "specs").resolve()543 candidate = (specs_root / spec_path).resolve()544 if specs_root not in candidate.parents and candidate != specs_root:545 return None546 if not candidate.is_file():547 return None548 return _read_doc(candidate) or ""549550551def _worktree_spec_mtime(root: Path, spec_path: str) -> Optional[str]:552 """Return ISO-8601 mtime for a worktree spec file, or None if unavailable."""553 specs_root = (root / "openspec" / "specs").resolve()554 candidate = (specs_root / spec_path).resolve()555 if specs_root not in candidate.parents and candidate != specs_root:556 return None557 try:558 return datetime.fromtimestamp(candidate.stat().st_mtime, tz=timezone.utc).isoformat()559 except OSError:560 return None561562563def _spec_git_dates(root: Path) -> dict[str, dict[str, str]]:564 """Return ``{rel_path: {"mtime": iso, "ctime": iso}}`` for each spec.565566 Uses a single ``git log`` call (newest-first). The first time a file567 appears is its most recent commit (mtime); the last time is the oldest568 commit (ctime / creation date). Falls back to an empty dict if git is569 unavailable.570 """571 text = _git_output(572 root,573 ["log", "--no-merges", "--format=%cI", "--name-only", "--", "openspec/specs/"],574 timeout=10,575 )576 if not text:577 return {}578 prefix = "openspec/specs/"579 dates: dict[str, dict[str, str]] = {}580 current_date: Optional[str] = None581 for line in text.splitlines():582 if not line:583 continue584 if line.startswith(prefix):585 rel = line[len(prefix):]586 if not rel.endswith(".md") or not current_date:587 continue588 if rel not in dates:589 dates[rel] = {"mtime": current_date, "ctime": current_date}590 else:591 dates[rel]["ctime"] = current_date592 else:593 current_date = line.strip()594 return dates595596597def _read_ref_spec(root: Path, ref: str, spec_path: str) -> Optional[str]:598 ref = (ref or "").strip()599 if not ref:600 return None601 if spec_path.startswith("/") or ".." in Path(spec_path).parts:602 return None603 return _git_output(root, ["show", f"{ref}:openspec/specs/{spec_path}"], timeout=8)604605606def _spec_title_from_content(content: Optional[str], fallback: str) -> str:607 for line in (content or "").splitlines():608 stripped = line.strip()609 if stripped.startswith("# "):610 return stripped[2:].strip() or fallback611 return fallback612613614def _spec_diff(before: Optional[str], after: Optional[str], path: str) -> str:615 return "\n".join(616 difflib.unified_diff(617 (before or "").splitlines(),618 (after or "").splitlines(),619 fromfile=f"before/{path}",620 tofile=f"after/{path}",621 lineterm="",622 )623 )624625626def _compute_semantic_diff(before: Optional[str], after: Optional[str]) -> dict[str, Any]:627 """Compute a semantic requirement-level delta using the shared spec parser."""628 try:629 spec_parser_mod = _load_spec_parser()630 if spec_parser_mod is None:631 return {}632 return spec_parser_mod.semantic_spec_diff(before, after)633 except Exception:634 return {}635636637def _compute_semantic_summary(before: Optional[str], after: Optional[str]) -> Optional[dict[str, int]]:638 """Compute compact requirement-level counts for spec list display."""639 try:640 spec_parser_mod = _load_spec_parser()641 if spec_parser_mod is None:642 return None643 diff = spec_parser_mod.semantic_spec_diff(before, after)644 return spec_parser_mod.semantic_summary(diff)645 except Exception:646 return None647648649def _load_spec_parser():650 """Import the shared spec_parser module from the plugin root."""651 import importlib.util652 candidate = Path(__file__).resolve().parent.parent / "spec_parser.py"653 if not candidate.is_file():654 return None655 spec = importlib.util.spec_from_file_location("openspec_spec_parser", candidate)656 if spec is None or spec.loader is None:657 return None658 mod = importlib.util.module_from_spec(spec)659 spec.loader.exec_module(mod)660 return mod661662663def _spec_status(before: Optional[str], after: Optional[str]) -> str:664 if before is None and after is None:665 return "missing"666 if before is None:667 return "added"668 if after is None:669 return "deleted"670 if before != after:671 return "modified"672 return "unchanged"673674675def _spec_browser(root: Path, before: str = "", after: str = "", dirty: bool = False) -> dict[str, Any]:676 before_ref = (before or "").strip()677 after_ref = (after or "").strip()678 dirty_mode = bool(dirty)679680 if dirty_mode:681 before_ref = before_ref or "HEAD"682 after_label = "working tree"683 before_paths = _list_ref_specs(root, before_ref)684 after_paths = _list_worktree_specs(root)685 mode = "dirty"686 elif before_ref or after_ref:687 if not before_ref or not after_ref:688 raise HTTPException(status_code=400, detail="both before and after are required for ref diff")689 after_label = after_ref690 before_paths = _list_ref_specs(root, before_ref)691 after_paths = _list_ref_specs(root, after_ref)692 mode = "refs"693 else:694 after_label = "working tree"695 before_paths = []696 after_paths = _list_worktree_specs(root)697 mode = "current"698699 files: list[dict[str, Any]] = []700 # Pre-fetch git commit dates for all specs in one call (current/dirty modes).701 git_dates = _spec_git_dates(root) if mode in ("current", "dirty") else {}702 for rel in sorted(set(before_paths) | set(after_paths)):703 if mode == "current":704 # No comparison — just showing the current worktree state.705 after_content = _read_worktree_spec(root, rel)706 gd = git_dates.get(rel, {})707 files.append({708 "path": rel,709 "token": _change_token(f"spec:{rel}"),710 "title": _spec_title_from_content(after_content, rel),711 "status": "current" if after_content is not None else "missing",712 "changed": False,713 "mtime": gd.get("mtime") or _worktree_spec_mtime(root, rel),714 "ctime": gd.get("ctime"),715 "before": None,716 "after": after_content,717 "diff": "",718 })719 continue720 before_content = _read_ref_spec(root, before_ref, rel) if before_ref else None721 if dirty_mode:722 after_content = _read_worktree_spec(root, rel)723 gd = git_dates.get(rel, {})724 mtime = gd.get("mtime") or _worktree_spec_mtime(root, rel)725 ctime = gd.get("ctime")726 else:727 after_content = _read_ref_spec(root, after_ref, rel)728 mtime = None729 ctime = None730 status = _spec_status(before_content, after_content)731 # In dirty mode, skip unchanged specs — only show what's actually732 # different between HEAD and the worktree.733 if dirty_mode and status in ("unchanged", "missing"):734 continue735 files.append({736 "path": rel,737 "token": _change_token(f"spec:{rel}"),738 "title": _spec_title_from_content(after_content if after_content is not None else before_content, rel),739 "status": status,740 "changed": status not in {"unchanged", "missing"},741 "mtime": mtime,742 "ctime": ctime,743 "before": before_content,744 "after": after_content,745 "diff": _spec_diff(before_content, after_content, rel) if status not in {"unchanged", "missing"} else "",746 "semantic_summary": _compute_semantic_summary(before_content, after_content) if status not in {"unchanged", "missing"} else None,747 "semantic_diff": _compute_semantic_diff(before_content, after_content) if status not in {"unchanged", "missing"} else None,748 })749750 return {751 "mode": mode,752 "branch": _current_branch(root),753 "before": before_ref or None,754 "after": after_label,755 "files": files,756 "changedCount": sum(1 for item in files if item["changed"]),757 }758759760# ---------------------------------------------------------------------------761# Source resolution762# ---------------------------------------------------------------------------763764class _SourceCreate(BaseModel):765 path: str766 name: Optional[str] = None767768769def _resolve_source(source_id: str) -> tuple[dict[str, Any], Path]:770 if _registry is None:771 raise HTTPException(status_code=503, detail="OpenSpec registry not available")772 source = _registry.get_source(source_id)773 if source is None:774 raise HTTPException(status_code=404, detail="Source not found")775 root, error = _source_root(source["path"])776 if root is None:777 raise HTTPException(status_code=400, detail=error or "Source is invalid")778 return source, root779780781# ---------------------------------------------------------------------------782# Legacy config migration (one-time, idempotent)783# ---------------------------------------------------------------------------784785_migration_done = False786787788def _registered_sources() -> list[dict[str, Any]]:789 global _migration_done790 if _registry is None:791 return []792 if not _migration_done:793 _migration_done = True794 try:795 from hermes_cli.config import load_config796 dashboard = (load_config() or {}).get("dashboard") or {}797 legacy = dashboard.get("openspec_sources")798 if isinstance(legacy, list) and legacy:799 _registry.migrate_from_config_sources(legacy)800 except Exception:801 pass802 return _registry.list_sources()803804805# ---------------------------------------------------------------------------806# Routes807# ---------------------------------------------------------------------------808809@router.get("/sources")810def list_sources():811 return {"sources": [_source_payload(s) for s in _registered_sources()]}812813814@router.post("/sources")815def add_source(body: _SourceCreate):816 if _registry is None:817 raise HTTPException(status_code=503, detail="OpenSpec registry not available")818 raw_path = str(body.path or "").strip()819 if not raw_path:820 raise HTTPException(status_code=400, detail="path is required")821 root, error = _repo_root(raw_path)822 if root is None:823 raise HTTPException(status_code=400, detail=error or "Invalid path")824 try:825 source = _registry.add_source(raw_path, str(body.name or "").strip())826 except ValueError:827 raise HTTPException(status_code=409, detail="Source already registered")828 return {"ok": True, "source": _source_payload(source)}829830831@router.delete("/sources/{source_id}")832def remove_source(source_id: str):833 if _registry is None:834 raise HTTPException(status_code=503, detail="OpenSpec registry not available")835 if not _registry.remove_source(source_id):836 raise HTTPException(status_code=404, detail="Source not found")837 return {"ok": True}838839840@router.put("/sources/{source_id}")841def update_source(source_id: str, body: _SourceCreate):842 if _registry is None:843 raise HTTPException(status_code=503, detail="OpenSpec registry not available")844 raw_path = str(body.path or "").strip()845 if not raw_path:846 raise HTTPException(status_code=400, detail="path is required")847 root, error = _repo_root(raw_path)848 if root is None:849 raise HTTPException(status_code=400, detail=error or "Invalid path")850 try:851 source = _registry.update_source(source_id, path=raw_path, name=str(body.name or "").strip())852 except ValueError:853 raise HTTPException(status_code=409, detail="Source already registered")854 if source is None:855 raise HTTPException(status_code=404, detail="Source not found")856 return {"ok": True, "source": _source_payload(source)}857858859@router.post("/sources/{source_id}/init")860def init_source(source_id: str):861 if _registry is None:862 raise HTTPException(status_code=503, detail="OpenSpec registry not available")863 source = _registry.get_source(source_id)864 if source is None:865 raise HTTPException(status_code=404, detail="Source not found")866 root, error = _repo_root(source["path"])867 if root is None:868 raise HTTPException(status_code=400, detail=error or "Invalid path")869 if (root / "openspec").is_dir():870 return {"ok": True, "message": "Already initialized", "source": _source_payload(source)}871 # Try `openspec init <path> --tools none` first.872 exe = _find_openspec_bin()873 if exe:874 try:875 proc = subprocess.run(876 [exe, "init", str(root), "--tools", "none"],877 capture_output=True, text=True, timeout=30, check=False,878 )879 if proc.returncode != 0:880 detail = (proc.stderr or proc.stdout or "").strip()881 raise HTTPException(status_code=500, detail=f"openspec init failed: {detail}")882 except subprocess.TimeoutExpired:883 raise HTTPException(status_code=504, detail="openspec init timed out")884 try:885 _ensure_openspec_layout(root)886 except OSError as exc:887 raise HTTPException(status_code=500, detail=f"Failed to create openspec/ structure: {exc}")888 return {"ok": True, "message": "OpenSpec initialized", "source": _source_payload(source)}889890891@router.get("/sources/{source_id}/changes/{change_name}")892def change_detail(source_id: str, change_name: str):893 _source, root = _resolve_source(source_id)894 detail = _change_detail(root, change_name)895 if detail is None:896 raise HTTPException(status_code=404, detail="Change not found")897 return detail898899900@router.get("/sources/{source_id}/ideas/{idea_name}")901def idea_detail(source_id: str, idea_name: str):902 _source, root = _resolve_source(source_id)903 detail = _idea_detail(root, idea_name)904 if detail is None:905 raise HTTPException(status_code=404, detail="Idea not found")906 return detail907908909@router.get("/sources/{source_id}/spec-browser")910def spec_browser(911 source_id: str,912 before: str = "",913 after: str = "",914 dirty: bool = False,915):916 _source, root = _resolve_source(source_id)917 return _spec_browser(root, before=before, after=after, dirty=dirty)918919920@router.get("/sources/{source_id}/specs")921def spec_detail(source_id: str, path: str = Query(...)):922 _source, root = _resolve_source(source_id)923 detail = _spec_detail(root, path)924 if detail is None:925 raise HTTPException(status_code=404, detail="Spec not found")926 return detail