clone

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

fix: allow adding sources before openspec init, add init endpoint

dami etoile committed b3203d5
- _repo_root: resolve git root without requiring openspec/ dir
- add_source/update_source: use _repo_root so repos can be registered
  before openspec is initialized
- _source_payload: mark valid=False with 'No openspec/ directory found'
  when repo exists but openspec/ is missing, so the UI shows the
  Initialize button
- POST /sources/{id}/init: runs 'openspec init --tools none', falls back
  to creating the minimal directory structure if the CLI isn't installed
commit
b3203d553c64312b59957d1b72cbf20e67eadfc3
parent
ff36a1db819dcddaf8a32cf88493e173459c7938
author
dami etoile <dami.etoile@gmail.com>
1 file changed, +77 -5
Mdashboard/plugin_api.py82++++++++++++++++++++++++++++--
M dashboard/plugin_api.py +77 -5
@@ -30,7 +30,9 @@ from __future__ import annotations
3030 3131 import difflib3232 import importlib.util33+import os3334 import re35+import shutil3436 import subprocess3537 from datetime import datetime, timezone3638 from pathlib import Path
@@ -67,6 +69,19 @@ router = APIRouter()
6769 # Kept here so the plugin has zero core-dependency for filesystem traversal.6870 # ---------------------------------------------------------------------------6971 72+def _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 None83+84+7085 def _resolve_path(raw_path: str) -> Path:7186 """Resolve a user-supplied path string to an absolute Path, rejecting7287 null bytes and unparseable values. Mirrors web_server._fs_path semantics."""
@@ -99,6 +114,24 @@ def _find_git_root(start: Path) -> Optional[Path]:
99114 return None100115 101116 117+def _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).119+120+ 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, None133+134+102135 def _source_root(raw_path: str) -> tuple[Path | None, str | None]:103136 """Resolve a raw path to a repo root containing ``openspec/``.104137
@@ -296,7 +329,7 @@ def _scan(root: Path) -> Optional[dict[str, Any]]:
296329 297330 298331 def _source_payload(source: dict[str, Any]) -> dict[str, Any]:299- root, error = _source_root(source["path"])332+ root, error = _repo_root(source["path"])300333 name = source.get("name") or (root.name if root else Path(source["path"]).expanduser().name)301334 base = {302335 "token": source.get("token") or source.get("id"),
@@ -306,6 +339,9 @@ def _source_payload(source: dict[str, Any]) -> dict[str, Any]:
306339 }307340 if root is None:308341 return {**base, "valid": False, "repoRoot": None, "openspec": None, "error": error}342+ # Repo exists — check if openspec/ is initialized.343+ if not (root / "openspec").is_dir():344+ return {**base, "valid": False, "repoRoot": str(root), "openspec": None, "error": "No openspec/ directory found"}309345 return {310346 **base,311347 "valid": True,
@@ -704,9 +740,9 @@ def add_source(body: _SourceCreate):
704740 raw_path = str(body.path or "").strip()705741 if not raw_path:706742 raise HTTPException(status_code=400, detail="path is required")707- root, error = _source_root(raw_path)743+ root, error = _repo_root(raw_path)708744 if root is None:709- raise HTTPException(status_code=400, detail=error or "No openspec/ directory found")745+ raise HTTPException(status_code=400, detail=error or "Invalid path")710746 try:711747 source = _registry.add_source(raw_path, str(body.name or "").strip())712748 except ValueError:
@@ -730,9 +766,9 @@ def update_source(source_id: str, body: _SourceCreate):
730766 raw_path = str(body.path or "").strip()731767 if not raw_path:732768 raise HTTPException(status_code=400, detail="path is required")733- root, error = _source_root(raw_path)769+ root, error = _repo_root(raw_path)734770 if root is None:735- raise HTTPException(status_code=400, detail=error or "No openspec/ directory found")771+ raise HTTPException(status_code=400, detail=error or "Invalid path")736772 try:737773 source = _registry.update_source(source_id, path=raw_path, name=str(body.name or "").strip())738774 except ValueError:
@@ -742,6 +778,42 @@ def update_source(source_id: str, body: _SourceCreate):
742778 return {"ok": True, "source": _source_payload(source)}743779 744780 781+@router.post("/sources/{source_id}/init")782+def init_source(source_id: str):783+ if _registry is None:784+ raise HTTPException(status_code=503, detail="OpenSpec registry not available")785+ source = _registry.get_source(source_id)786+ if source is None:787+ raise HTTPException(status_code=404, detail="Source not found")788+ root, error = _repo_root(source["path"])789+ if root is None:790+ raise HTTPException(status_code=400, detail=error or "Invalid path")791+ if (root / "openspec").is_dir():792+ return {"ok": True, "message": "Already initialized", "source": _source_payload(source)}793+ # Try `openspec init <path> --tools none` first.794+ exe = _find_openspec_bin()795+ if exe:796+ try:797+ proc = subprocess.run(798+ [exe, "init", str(root), "--tools", "none"],799+ capture_output=True, text=True, timeout=30, check=False,800+ )801+ if proc.returncode != 0:802+ detail = (proc.stderr or proc.stdout or "").strip()803+ raise HTTPException(status_code=500, detail=f"openspec init failed: {detail}")804+ except subprocess.TimeoutExpired:805+ raise HTTPException(status_code=504, detail="openspec init timed out")806+ else:807+ # Fallback: create the minimal directory structure.808+ try:809+ (root / "openspec" / "changes").mkdir(parents=True, exist_ok=True)810+ (root / "openspec" / "specs").mkdir(parents=True, exist_ok=True)811+ (root / "openspec" / "ideas").mkdir(parents=True, exist_ok=True)812+ except OSError as exc:813+ raise HTTPException(status_code=500, detail=f"Failed to create openspec/ structure: {exc}")814+ return {"ok": True, "message": "OpenSpec initialized", "source": _source_payload(source)}815+816+745817 @router.get("/sources/{source_id}/changes/{change_name}")746818 def change_detail(source_id: str, change_name: str):747819 _source, root = _resolve_source(source_id)