diff --git a/src/capacium/adapters/base.py b/src/capacium/adapters/base.py index 4761baf..8494cac 100644 --- a/src/capacium/adapters/base.py +++ b/src/capacium/adapters/base.py @@ -27,6 +27,12 @@ class FrameworkAdapter(ABC): def install_capability(self, cap_name: str, version: str, source_dir: Path, owner: str = "global", kind: str = "skill") -> bool: if kind == "mcp-server": return self.install_mcp_server(cap_name, version, source_dir, owner) + from ..models import SKILL_LAYER_KIND_VALUES + if kind not in SKILL_LAYER_KIND_VALUES: + # Kind-placement contract (V6): bundle/connector-pack roots are + # containers — their members get placed individually; the root + # itself produces no client artifacts. + return True return self.install_skill(cap_name, version, source_dir, owner) def remove_capability(self, cap_name: str, owner: str = "global", kind: str = "skill") -> bool: diff --git a/src/capacium/adapters/cursor.py b/src/capacium/adapters/cursor.py index 21b3f92..30762e9 100644 --- a/src/capacium/adapters/cursor.py +++ b/src/capacium/adapters/cursor.py @@ -1,7 +1,9 @@ """Cursor adapter — Skills + MCP. Cursor supports SKILL.md via .cursor/skills/ (project-only) since 2026. -MCP: .cursor/mcp.json (project-local preferred, global fallback). +MCP: .cursor/mcp.json — project-local only with an explicit project root +(V7/STAB-006: never implicit Path.cwd()), global ~/.cursor/mcp.json +otherwise. """ import json import shutil @@ -11,6 +13,7 @@ from ..storage import StorageManager from ..symlink_manager import SymlinkManager from ..manifest import Manifest +from ..utils.project_scope import get_project_root from .base import FrameworkAdapter, _cap_id, ensure_package_dir from .mcp_config_patcher import McpConfigPatcher @@ -22,22 +25,41 @@ class CursorAdapter(FrameworkAdapter): def __init__(self): self.storage = StorageManager() self.symlink_manager = SymlinkManager() - self._skills_dir = Path.cwd() / ".cursor" / "skills" - self.project_mcp_path = Path.cwd() / ".cursor" / "mcp.json" self.global_mcp_path = Path.home() / ".cursor" / "mcp.json" - self._legacy_rules_dir = Path.cwd() / ".cursor" / "rules" self._legacy_global_rules_dir = Path.home() / ".cursor" / "rules" @property - def skills_dir(self) -> Path: - return self._skills_dir + def project_root(self) -> Optional[Path]: + return get_project_root() - def install_skill(self, cap_name: str, version: str, source_dir: Path, owner: str = "global") -> bool: - self.skills_dir.mkdir(parents=True, exist_ok=True) + @property + def skills_dir(self) -> Optional[Path]: + root = self.project_root + return root / ".cursor" / "skills" if root else None + + @property + def project_mcp_path(self) -> Optional[Path]: + root = self.project_root + return root / ".cursor" / "mcp.json" if root else None + + @property + def _legacy_rules_dir(self) -> Optional[Path]: + root = self.project_root + return root / ".cursor" / "rules" if root else None + def install_skill(self, cap_name: str, version: str, source_dir: Path, owner: str = "global") -> bool: package_dir = ensure_package_dir(self.storage, cap_name, version, source_dir, owner=owner) - link_path = self.skills_dir / _cap_id(cap_name, owner) + skills_dir = self.skills_dir + if skills_dir is None: + # Cursor skills are project-scoped; without an explicit project + # root we must not write into the current working directory. + print(f" cursor: skill '{cap_name}' cached only — pass --project " + " to link it into a project's .cursor/skills.") + return True + skills_dir.mkdir(parents=True, exist_ok=True) + + link_path = skills_dir / _cap_id(cap_name, owner) success = self.symlink_manager.create_symlink(package_dir, link_path) metadata_path = package_dir / ".capacium-meta.json" @@ -47,15 +69,19 @@ def install_skill(self, cap_name: str, version: str, source_dir: Path, owner: st return success def remove_skill(self, cap_name: str, owner: str = "global") -> bool: - link_path = self.skills_dir / _cap_id(cap_name, owner) - if link_path.exists(): - if link_path.is_symlink(): - self.symlink_manager.remove_symlink(link_path) - elif link_path.is_dir(): - shutil.rmtree(link_path) - else: - link_path.unlink() + skills_dir = self.skills_dir + if skills_dir is not None: + link_path = skills_dir / _cap_id(cap_name, owner) + if link_path.exists(): + if link_path.is_symlink(): + self.symlink_manager.remove_symlink(link_path) + elif link_path.is_dir(): + shutil.rmtree(link_path) + else: + link_path.unlink() for legacy_dir in (self._legacy_rules_dir, self._legacy_global_rules_dir): + if legacy_dir is None: + continue legacy_path = legacy_dir / f"{cap_name}.mdc" if legacy_path.exists(): try: @@ -65,9 +91,11 @@ def remove_skill(self, cap_name: str, owner: str = "global") -> bool: return True def capability_exists(self, cap_name: str, owner: str = "global") -> bool: - link_path = self.skills_dir / _cap_id(cap_name, owner) - if link_path.exists() and link_path.is_symlink(): - return True + skills_dir = self.skills_dir + if skills_dir is not None: + link_path = skills_dir / _cap_id(cap_name, owner) + if link_path.exists() and link_path.is_symlink(): + return True return McpConfigPatcher.mcp_server_exists_json( self._get_mcp_path(), McpConfigPatcher.build_server_key(cap_name, owner), self.MCP_SECTION_KEY, ) @@ -93,15 +121,19 @@ def remove_mcp_server(self, cap_name: str, owner: str = "global") -> bool: ) def list_capabilities(self) -> List[str]: - if not self.skills_dir.exists(): + skills_dir = self.skills_dir + if skills_dir is None or not skills_dir.exists(): return [] return sorted( - d.name for d in self.skills_dir.iterdir() + d.name for d in skills_dir.iterdir() if d.is_dir() and not d.name.startswith(".") ) def get_capability_metadata(self, cap_name: str) -> Optional[Dict[str, Any]]: - link_path = self.skills_dir / cap_name + skills_dir = self.skills_dir + if skills_dir is None: + return None + link_path = skills_dir / cap_name if link_path.exists() and link_path.is_symlink(): target_dir = link_path.resolve() metadata_path = target_dir / ".capacium-meta.json" @@ -111,6 +143,10 @@ def get_capability_metadata(self, cap_name: str) -> Optional[Dict[str, Any]]: return None def _get_mcp_path(self) -> Path: - if self.project_mcp_path.parent.exists(): - return self.project_mcp_path + """Project config only with an explicit project root — the previous + cwd-probing wrote mcp.json/.bak files into package directories + whenever they happened to contain a .cursor folder (V7).""" + project_path = self.project_mcp_path + if project_path is not None: + return project_path return self.global_mcp_path diff --git a/src/capacium/adapters/mcp_config_patcher.py b/src/capacium/adapters/mcp_config_patcher.py index fb7e14d..88c1106 100644 --- a/src/capacium/adapters/mcp_config_patcher.py +++ b/src/capacium/adapters/mcp_config_patcher.py @@ -9,12 +9,109 @@ import shutil from datetime import datetime from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Tuple + + +class RuntimeUnavailableError(RuntimeError): + """A required host runtime is missing — no client config may be written. + + Carries the human-readable failure report (including platform install + hints) so callers can surface it without re-resolving. + """ + + def __init__(self, report: str, statuses=None): + super().__init__(report) + self.statuses = statuses or [] + + +# Cache for default-resolver probes: (runtime, requirement) -> RuntimeStatus. +# Installs touch up to ~22 client configs; without this every config write +# would re-spawn version subprocesses. +_RUNTIME_STATUS_CACHE: Dict[Tuple[str, str], Any] = {} + +# V11/STAB-007: env keys that look like credentials must never be written as +# literal values into client configs — only as ${VAR} indirections resolvable +# from launchd/envctl at server start. +_SECRET_KEY_RE = re.compile( + r"(TOKEN|SECRET|PASSWORD|PASSWD|COOKIE|CREDENTIAL|API_?KEY|PRIVATE_?KEY|AUTH)", + re.IGNORECASE, +) +_ENV_REF_RE = re.compile(r"^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$") class McpConfigPatcher: """Safely patches MCP server entries into client configuration files.""" + @staticmethod + def clear_runtime_status_cache() -> None: + _RUNTIME_STATUS_CACHE.clear() + + @classmethod + def validate_entry_runtimes( + cls, + entry: Dict[str, Any], + package_root: Path, + resolver=None, + ) -> None: + """Validate runtimes for a built MCP entry BEFORE any config write. + + Merges declared ``manifest.runtimes`` (plus mcp.command inference) + with the runtime implied by the final entry command. Raises + :class:`RuntimeUnavailableError` when any requirement is unmet, so + that no client config entry is ever written for a server that cannot + start on this host (V5 regression class: Go server configured as npx). + """ + if os.environ.get("CAPACIUM_SKIP_RUNTIME_CHECK") == "1": + return + if "command" not in entry: + return # url/sse transports need no local runtime + + from ..runtimes import ( + RuntimeResolver, + format_failure_report, + infer_required_runtimes, + runtime_for_command, + ) + + requirements: Dict[str, str] = {} + try: + from ..manifest import Manifest + + manifest = Manifest.detect_from_directory(package_root) + requirements.update(infer_required_runtimes(manifest)) + except Exception: + pass + command_runtime = runtime_for_command(entry.get("command", "")) + if command_runtime and command_runtime not in requirements: + requirements[command_runtime] = "*" + if not requirements: + return + + use_cache = resolver is None + if resolver is None: + resolver = RuntimeResolver() + + statuses = [] + to_resolve: Dict[str, str] = {} + for name, req in requirements.items(): + cached = _RUNTIME_STATUS_CACHE.get((name, req)) if use_cache else None + if cached is not None: + statuses.append(cached) + else: + to_resolve[name] = req + if to_resolve: + fresh = resolver.resolve(to_resolve) + statuses.extend(fresh) + if use_cache: + for s in fresh: + _RUNTIME_STATUS_CACHE[(s.name, s.requirement)] = s + + failures = [s for s in statuses if not s.ok] + if failures: + raise RuntimeUnavailableError( + format_failure_report(statuses), statuses=statuses + ) + @staticmethod def backup(config_path: Path) -> Optional[Path]: """Create a timestamped backup of the config file before editing.""" @@ -139,6 +236,7 @@ def build_mcp_entry( cap_name: str, source_dir: Path, mcp_meta: Optional[Dict[str, Any]] = None, + resolver=None, ) -> Dict[str, Any]: """Build a standard MCP server entry from a capability's manifest metadata. @@ -151,7 +249,12 @@ def build_mcp_entry( Relative args paths are materialized to absolute paths pointing into the installed package directory (source_dir). + + Raises :class:`RuntimeUnavailableError` when the host lacks a runtime + required by the manifest or the resulting command (set + ``CAPACIUM_SKIP_RUNTIME_CHECK=1`` to bypass). """ + package_root = source_dir source_dir = McpConfigPatcher.resolve_entrypoint_dir(source_dir) meta = mcp_meta or {} transport = meta.get("transport", "stdio") @@ -165,11 +268,29 @@ def build_mcp_entry( # stdio transport (default) command = meta.get("command", "") args = list(meta.get("args", [])) if meta.get("args") else [] - env = meta.get("env", {}) + env = McpConfigPatcher.sanitize_env_block(meta.get("env", {}), cap_name) + + # V10/STAB-004: prefer the binary built from the local package over + # any 'go run' form — especially 'go run ...@latest' network fetches. + is_go_project = (source_dir / "go.mod").exists() or (package_root / "go.mod").exists() + if command == "go" or (not command and is_go_project): + binary = McpConfigPatcher._find_go_binary(source_dir, package_root, cap_name) + if binary is not None: + command = str(binary) + args = [] + elif command == "go" and any("@latest" in str(a) for a in args): + # No binary yet (e.g. pre-build path): run the LOCAL package, + # never the @latest remote module. + args = ["run", "."] if not command: - # Auto-detect: look for common entry points - if (source_dir / "package.json").exists(): + # Auto-detect: look for common entry points. go.mod wins over + # package.json — Go repos often ship docs tooling manifests, and a + # Go server configured as npx can never start (V5 regression). + if is_go_project: + command = "go" + args = ["run", "."] + elif (source_dir / "package.json").exists(): command = "npx" args = ["-y", str(source_dir)] elif (source_dir / "pyproject.toml").exists(): @@ -202,8 +323,51 @@ def build_mcp_entry( cwd_root = McpConfigPatcher._resolve_cwd(source_dir) if cwd_root is not None: entry["cwd"] = cwd_root + McpConfigPatcher.validate_entry_runtimes(entry, package_root, resolver=resolver) return entry + @staticmethod + def sanitize_env_block(env: Dict[str, Any], cap_name: str = "") -> Dict[str, str]: + """Normalize a manifest mcp.env block for client configs (V11). + + - empty/None values become ``${KEY}`` indirections + - secret-looking keys with literal values are REDACTED to ``${KEY}`` + (static guard: no plaintext secret ever lands in a client config) + - explicit ``${VAR}`` references and harmless literals pass through + """ + sanitized: Dict[str, str] = {} + for key, value in (env or {}).items(): + value = "" if value is None else str(value) + if not value: + sanitized[key] = "${" + key + "}" + continue + if _ENV_REF_RE.match(value.strip()): + sanitized[key] = value + continue + if _SECRET_KEY_RE.search(key): + label = f" for {cap_name}" if cap_name else "" + print(f" Warning: env '{key}'{label} carried a literal secret — " + f"redacted to ${{{key}}} (set the value via launchd/envctl).") + sanitized[key] = "${" + key + "}" + continue + sanitized[key] = value + return sanitized + + @staticmethod + def _find_go_binary(source_dir: Path, package_root: Path, cap_name: str) -> Optional[Path]: + """Locate the install-time built Go binary (bin/).""" + for root in (source_dir, package_root): + bin_dir = root / "bin" + for candidate in (bin_dir / cap_name, bin_dir / f"{cap_name}.exe"): + if candidate.is_file(): + return candidate.resolve() + if bin_dir.is_dir(): + executables = [p for p in sorted(bin_dir.iterdir()) + if p.is_file() and os.access(p, os.X_OK)] + if len(executables) == 1: + return executables[0].resolve() + return None + @staticmethod def resolve_entrypoint_dir(source_dir: Path) -> Path: """Return the manifest entrypoint directory when one is declared.""" @@ -270,6 +434,7 @@ def build_opencode_mcp_entry( cap_name: str, source_dir: Path, mcp_meta: Optional[Dict[str, Any]] = None, + resolver=None, ) -> Dict[str, Any]: """Build an OpenCode-native MCP server entry. @@ -287,7 +452,7 @@ def build_opencode_mcp_entry( "enabled": True, } - stdio = McpConfigPatcher.build_mcp_entry(cap_name, source_dir, meta) + stdio = McpConfigPatcher.build_mcp_entry(cap_name, source_dir, meta, resolver=resolver) command = stdio.get("command", "") args = stdio.get("args", []) entry: Dict[str, Any] = { @@ -320,8 +485,14 @@ def inject_json_mcp_server( cap_name: str, source_dir: Path, mcp_meta: Optional[Dict[str, Any]] = None, + resolver=None, ) -> bool: - """Full pipeline: backup → read → inject → write for a JSON config.""" + """Full pipeline: build (runtime-gated) → backup → read → inject → write. + + The entry is built first so a RuntimeUnavailableError aborts before + any filesystem mutation — no backup file, no config write. + """ + entry = cls.build_mcp_entry(cap_name, source_dir, mcp_meta, resolver=resolver) cls.backup(config_path) config = cls.read_json(config_path) servers = config.setdefault(mcp_section_key, {}) @@ -332,7 +503,7 @@ def inject_json_mcp_server( or key.endswith("-" + cap_name) ): del servers[key] - servers[server_key] = cls.build_mcp_entry(cap_name, source_dir, mcp_meta) + servers[server_key] = entry cls.write_json(config_path, config) return True diff --git a/src/capacium/cli.py b/src/capacium/cli.py index c2db306..0bd98da 100644 --- a/src/capacium/cli.py +++ b/src/capacium/cli.py @@ -25,6 +25,11 @@ def main(): install_parser.add_argument("capability", nargs="?", help="Capability specification (owner/name[@version] or name[@version]). Optional when --from-tarball is used.") install_parser.add_argument("--version", help="Specific version to install") install_parser.add_argument("--source", help="Source directory (defaults to current directory)") + install_parser.add_argument( + "--project", + help="Project root for project-scoped clients (cursor): writes go to " + "/.cursor/ instead of being skipped. Never implicit cwd.", + ) install_parser.add_argument("--no-lock", action="store_true", help="Bypass lock file enforcement") install_parser.add_argument( "--skip-runtime-check", @@ -290,6 +295,22 @@ def main(): submit_parser.add_argument("github_url", help="GitHub repository URL (https://github.com/owner/repo)") submit_parser.add_argument("--registry", help="Target registry URL (defaults to configured Exchange)") + hold_parser = subparsers.add_parser("hold", help="Protect a locally patched capability from update overwrites") + hold_parser.add_argument("capability", nargs="?", help="Capability (owner/name); omit with --list") + hold_parser.add_argument("--reason", help="Why the package is held (e.g. pending upstream PR)") + hold_parser.add_argument("--list", action="store_true", help="List all holds") + + unhold_parser = subparsers.add_parser("unhold", help="Release a hold set with 'cap hold'") + unhold_parser.add_argument("capability", help="Capability (owner/name)") + + block_parser = subparsers.add_parser("block", help="Mark a capability as blocked by an upstream defect (honest status)") + block_parser.add_argument("capability", help="Capability (owner/name)") + block_parser.add_argument("--reason", required=True, help="Why the capability cannot work (upstream defect)") + block_parser.add_argument("--issue", help="Tracking link (upstream issue/republish)") + + unblock_parser = subparsers.add_parser("unblock", help="Clear a blocked status set with 'cap block'") + unblock_parser.add_argument("capability", help="Capability (owner/name)") + submit_tarball_parser = subparsers.add_parser("submit-tarball", help="Upload a capability tarball to the Exchange") submit_tarball_parser.add_argument("tarball_path", help="Path to .tar.gz file") @@ -513,6 +534,7 @@ def main(): yes=getattr(args, "yes", False), github_token=getattr(args, "token", None) or os.environ.get("GITHUB_TOKEN"), registry_url=getattr(args, "registry", None), + project=getattr(args, "project", None), ) sys.exit(0 if success else 1) @@ -720,16 +742,36 @@ def main(): config_parser.print_help() sys.exit(1) + elif args.command == "hold": + from .commands.hold import hold_capability, list_holds + if getattr(args, "list", False) or not args.capability: + sys.exit(0 if list_holds() else 1) + ok = hold_capability(args.capability, reason=getattr(args, "reason", None)) + sys.exit(0 if ok else 1) + + elif args.command == "unhold": + from .commands.hold import unhold_capability + sys.exit(0 if unhold_capability(args.capability) else 1) + + elif args.command == "block": + from .commands.block_status import block_capability + ok = block_capability(args.capability, reason=args.reason, + issue=getattr(args, "issue", None)) + sys.exit(0 if ok else 1) + + elif args.command == "unblock": + from .commands.block_status import unblock_capability + sys.exit(0 if unblock_capability(args.capability) else 1) + elif args.command == "submit": - from .registry_client import RegistryClient, RegistryClientError - client = RegistryClient() + from .registry_client import RegistryClientError + from .commands.submit import submit_repository try: - result = client.submit(args.github_url, registry_url=getattr(args, "registry", None)) - print(f"Submitted: {result.get('canonical_name', 'unknown')}") - print(f" Kind: {result.get('kind', 'unknown')}") - print(f" Trust: {result.get('trust_state', 'unknown')}") - print(f" URL: https://capacium.xyz/listings/{result.get('canonical_name', '')}") - sys.exit(0) + ok = submit_repository( + args.github_url, + registry_url=getattr(args, "registry", None), + ) + sys.exit(0 if ok else 1) except RegistryClientError as e: msg = str(e) if "409" in msg: diff --git a/src/capacium/commands/block_status.py b/src/capacium/commands/block_status.py new file mode 100644 index 0000000..5f4658c --- /dev/null +++ b/src/capacium/commands/block_status.py @@ -0,0 +1,73 @@ +"""cap block — honest status for upstream-broken capabilities (UP-002). + +Some capabilities cannot work no matter what Capacium does (missing npm +workspace packages, go.mod replace directives pointing at the author's +machine). Deleting their entries hides the problem; leaving them red blames +the wrong party. 'blocked' marks them per adapter with the upstream reason, +visible in ``cap list --details`` and ``cap doctor`` — distinguished from +'broken' (a Capacium-side defect). +""" + +from typing import Optional + +from ..registry import Registry +from ..versioning import VersionManager +from ._resolve import resolve_cap_id + + +def _resolve(cap_spec: str): + cap_id = resolve_cap_id(cap_spec) + spec = VersionManager.parse_version_spec(cap_id) + bare_id = f"{spec['owner']}/{spec['skill']}" + registry = Registry() + version = None if spec["version"] in ("latest", "stable") else spec["version"] + return registry, bare_id, registry.get_capability(bare_id, version) + + +def block_capability(cap_spec: str, reason: str, issue: Optional[str] = None) -> bool: + """Mark all adapters of a capability as blocked (upstream defect).""" + registry, bare_id, cap = _resolve(cap_spec) + if cap is None: + print(f"Capability {bare_id} not found.") + return False + if not reason: + print("A --reason is required — 'blocked' must explain itself.") + return False + + detail = reason if not issue else f"{reason} (tracking: {issue})" + frameworks = cap.frameworks or ([cap.framework] if cap.framework else []) + for fw in frameworks: + registry.set_adapter_status(bare_id, cap.version, fw, "blocked", detail) + + print(f"Blocked: {bare_id}@{cap.version} ({len(frameworks)} adapter(s))") + print(f" Reason: {detail}") + print(" Shown in 'cap list --details' and 'cap doctor'. Release with 'cap unblock'.") + return True + + +def unblock_capability(cap_spec: str) -> bool: + registry, bare_id, cap = _resolve(cap_spec) + if cap is None: + print(f"Capability {bare_id} not found.") + return False + + statuses = registry.get_adapter_statuses(bare_id, cap.version) + blocked = [fw for fw, s in statuses.items() if s.status == "blocked"] + if not blocked: + print(f"No blocked adapters for {bare_id}.") + return False + for fw in blocked: + registry.set_adapter_status(bare_id, cap.version, fw, "installed", None) + print(f"Unblocked: {bare_id}@{cap.version} ({len(blocked)} adapter(s))") + return True + + +def get_blocked_frameworks(registry: Registry, cap) -> dict: + """Return {framework: reason} for blocked adapters of *cap*.""" + cap_id = f"{cap.owner}/{cap.name}" + statuses = registry.get_adapter_statuses(cap_id, cap.version) + return { + fw: (s.last_error or "blocked upstream") + for fw, s in statuses.items() + if s.status == "blocked" + } diff --git a/src/capacium/commands/doctor.py b/src/capacium/commands/doctor.py index f68cf34..e1dbe6b 100644 --- a/src/capacium/commands/doctor.py +++ b/src/capacium/commands/doctor.py @@ -192,7 +192,7 @@ def _check_dependency_materialization() -> Tuple[str, bool, str]: requirements = Path(install_path) / "requirements.txt" pyproject = Path(install_path) / "pyproject.toml" has_python_dep = uv_lock.exists() or requirements.exists() or pyproject.exists() - if has_python_dep: + if has_python_dep and not _uses_ephemeral_python_env(cap, pyproject): venv = Path(install_path) / ".venv" if not venv.exists(): issues.append(f"{cap.name}: python deps declared but .venv missing") @@ -207,6 +207,23 @@ def _check_dependency_materialization() -> Tuple[str, bool, str]: return ("Dependency materialization", True, "MCP server dependencies look ok") +def _uses_ephemeral_python_env(cap: Capability, pyproject: Path) -> bool: + """True when the server runs via uvx/pipx — those create their own + isolated environments per invocation, so a missing ``.venv`` in the + package directory is expected, not an issue (V5 false-positive class). + """ + manifest = _load_manifest(cap) + command = "" + if manifest is not None and isinstance(getattr(manifest, "mcp", None), dict): + command = (manifest.mcp.get("command") or "").strip() + base = command.split("/")[-1].split()[0] if command else "" + if base in ("uvx", "pipx", "uv"): + return True + # No explicit command + pyproject present → entry auto-detection picks + # uvx, which is equally venv-less. + return not command and pyproject.exists() + + _last_probe_results: dict = {} @@ -227,7 +244,16 @@ def _check_mcp_handshake() -> Tuple[str, bool, str]: return ("MCP handshake", True, "no MCP servers to probe") failures = [] + blocked_caps = [] for cap in capabilities: + # UP-002: blocked (upstream-broken) capabilities are expected not to + # respond — reporting them as probe failures blames the wrong party. + from .block_status import get_blocked_frameworks + blocked = get_blocked_frameworks(registry, cap) + if blocked: + reason = next(iter(blocked.values())) + blocked_caps.append(f"{cap.name}: blocked upstream — {reason}") + continue manifest = _load_manifest(cap) if manifest is None or not manifest.mcp: continue @@ -246,13 +272,16 @@ def _check_mcp_handshake() -> Tuple[str, bool, str]: err = f"command '{command}' not found" failures.append(f"{cap.name}: {err}") + blocked_note = "" + if blocked_caps: + blocked_note = f"; {len(blocked_caps)} blocked (upstream): {'; '.join(blocked_caps[:2])}" if failures: return ( "MCP handshake", False, - f"{len(failures)} probe(s) failed: {'; '.join(failures[:3])}", + f"{len(failures)} probe(s) failed: {'; '.join(failures[:3])}" + blocked_note, ) - return ("MCP handshake", True, "all MCP servers respond to probe") + return ("MCP handshake", True, "all MCP servers respond to probe" + blocked_note) def _check_mcp_stdout_purity() -> Tuple[str, bool, str]: diff --git a/src/capacium/commands/hold.py b/src/capacium/commands/hold.py new file mode 100644 index 0000000..b816233 --- /dev/null +++ b/src/capacium/commands/hold.py @@ -0,0 +1,112 @@ +"""cap hold — protect locally patched packages from update overwrites (V8/UP-001). + +A held capability is never replaced by ``cap update``'s newer-version fetch +(which reinstalls force=True and would wipe local patches). Holds live in +``~/.capacium/holds.json`` together with the fingerprint at hold time, so +later drift remains attributable. +""" + +import json +from datetime import datetime +from pathlib import Path +from typing import Dict, Optional + +from ..fingerprint import compute_fingerprint +from ..registry import Registry +from ..versioning import VersionManager +from ._resolve import resolve_cap_id + +FINGERPRINT_EXCLUDES = [ + ".git", "__pycache__", "*.pyc", ".DS_Store", + ".capacium-meta.json", ".cap-meta.json", "capability.lock", "node_modules", +] + + +def _holds_path() -> Path: + return Path.home() / ".capacium" / "holds.json" + + +def load_holds() -> Dict[str, dict]: + path = _holds_path() + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text()) + return data if isinstance(data, dict) else {} + except (json.JSONDecodeError, OSError): + return {} + + +def _save_holds(holds: Dict[str, dict]) -> None: + path = _holds_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(holds, indent=2) + "\n") + + +def get_hold(cap_id: str) -> Optional[dict]: + return load_holds().get(cap_id) + + +def _resolve_cap(cap_spec: str): + cap_id = resolve_cap_id(cap_spec) + spec = VersionManager.parse_version_spec(cap_id) + bare_id = f"{spec['owner']}/{spec['skill']}" + registry = Registry() + version = None if spec["version"] in ("latest", "stable") else spec["version"] + return bare_id, registry.get_capability(bare_id, version) + + +def hold_capability(cap_spec: str, reason: Optional[str] = None) -> bool: + bare_id, cap = _resolve_cap(cap_spec) + if cap is None: + print(f"Capability {bare_id} not found. Use 'cap install' first.") + return False + + fingerprint = None + drift = None + if cap.install_path and Path(cap.install_path).exists(): + fingerprint = compute_fingerprint( + cap.install_path, exclude_patterns=FINGERPRINT_EXCLUDES + ) + drift = fingerprint != cap.fingerprint + + holds = load_holds() + holds[bare_id] = { + "reason": reason or "locally patched", + "since": datetime.now().isoformat(timespec="seconds"), + "version": cap.version, + "fingerprint_at_hold": fingerprint, + } + _save_holds(holds) + + print(f"Hold set: {bare_id}@{cap.version}") + if drift: + print(" Local modifications detected (fingerprint drift vs. install).") + elif drift is False: + print(" No local drift yet — updates will be skipped regardless.") + print(" 'cap update' will skip this package. Release with 'cap unhold'.") + return True + + +def unhold_capability(cap_spec: str) -> bool: + bare_id, _cap = _resolve_cap(cap_spec) + holds = load_holds() + if bare_id not in holds: + print(f"No hold set for {bare_id}.") + return False + del holds[bare_id] + _save_holds(holds) + print(f"Hold released: {bare_id}") + return True + + +def list_holds() -> bool: + holds = load_holds() + if not holds: + print("No holds set.") + return True + for cap_id, meta in sorted(holds.items()): + since = meta.get("since", "?") + reason = meta.get("reason", "") + print(f" {cap_id}@{meta.get('version', '?')} since {since} — {reason}") + return True diff --git a/src/capacium/commands/install.py b/src/capacium/commands/install.py index 0aef9cc..297bc8b 100644 --- a/src/capacium/commands/install.py +++ b/src/capacium/commands/install.py @@ -1,3 +1,4 @@ +import os import re import shutil import subprocess @@ -18,6 +19,7 @@ prompt_and_resolve_runtimes, ) from ..framework_detector import resolve_frameworks, create_framework_symlinks, detect_active_frameworks +from ..adapters.mcp_config_patcher import RuntimeUnavailableError _GITHUB_SHORT_RE = re.compile(r"^([\w.-]+/[\w.-]+)$") @@ -35,7 +37,19 @@ def install_capability( yes: bool = False, github_token: Optional[str] = None, registry_url: Optional[str] = None, + project: Optional[str] = None, ) -> bool: + if project: + # V7/STAB-006: explicit project root for project-scoped clients + # (cursor). Without it, those adapters never write into cwd. + from ..utils.project_scope import set_project_root + set_project_root(project) + + if skip_runtime_check: + # Propagate the explicit bypass to the adapter-level runtime gate + # (McpConfigPatcher.validate_entry_runtimes) and any child processes. + os.environ["CAPACIUM_SKIP_RUNTIME_CHECK"] = "1" + # BUG-009: Auto-detect capability name from tarball manifest autodetected_name = None if from_tarball is not None and (not cap_spec or cap_spec.strip() == ""): @@ -62,6 +76,17 @@ def install_capability( cap_name = spec["skill"] version_spec = spec["version"] + # V13b/STAB-001: 3-part IDs (owner/repo/skill or owner/repo::skill) + # install only the sub-skill subtree of a multi-skill repository — never + # the full repo copy that broke the owner/name/version layout. + sub_skill_repo = None + if "::" in cap_name: + sub_skill_repo, sub_name = cap_name.split("::", 1) + cap_name = sub_name.strip() + elif "/" in cap_name: + sub_skill_repo, sub_name = cap_name.split("/", 1) + cap_name = sub_name.strip().strip("/").split("/")[-1] + # Resolve bare name (no owner prefix) via Exchange search # Skip when source/tarball/offline is provided — user brings their own if owner in ("", "global", "unknown", "any") and source_dir is None and from_tarball is None and not offline: @@ -176,20 +201,31 @@ def install_capability( return False source_dir, source_url = resolved else: - # No --source flag: try registry fetch + # No --source flag: try registry fetch. For sub-skill installs the + # fetchable unit is the repository, not the member skill. if offline: print(" Offline mode: registry fetch skipped.") print(" Use --source to install from a local path.") return False + fetch_id = f"{owner}/{sub_skill_repo}" if sub_skill_repo else cap_id + fetch_name = sub_skill_repo if sub_skill_repo else cap_name source_dir, source_url = _fetch_from_registry( - cap_id=cap_id, - cap_name=cap_name, + cap_id=fetch_id, + cap_name=fetch_name, owner=owner, version_spec=version_spec, storage=storage, github_token=github_token, registry_url=registry_url, ) + if source_dir is None and sub_skill_repo: + resolved = _resolve_source( + f"{owner}/{sub_skill_repo}", + version_spec=version_spec, + github_token=github_token, + ) + if resolved is not None: + source_dir, source_url = resolved if source_dir is None: # Fallback to current directory cwd = Path.cwd() @@ -201,6 +237,12 @@ def install_capability( print(" Use --source to install from a local path.") return False + if sub_skill_repo: + member_dir = _resolve_sub_skill_dir(source_dir, cap_name) + if member_dir is None: + return False + source_dir = member_dir + if version_spec in ["latest", "stable"]: version = VersionManager.detect_version(source_dir) else: @@ -265,13 +307,56 @@ def install_capability( for e in errors: print(f"Warning: {e}") + # V11/STAB-007: warn when declared env vars never appear in the server + # source — the korotovsky class (manifest: SLACK_BOT_TOKEN, server reads + # SLACK_MCP_XOXP_TOKEN) yields silently non-functional servers. + if manifest.kind == "mcp-server": + _warn_unknown_env_vars(package_dir, manifest) + + # V13a/STAB-001 static guard: a kind=skill package without a root + # SKILL.md is undiscoverable in skill clients. If it actually contains + # nested member skills it is a mis-modeled multi-skill repo — refuse + # instead of creating a dead root link. + if (manifest.kind or "skill") == "skill" and not (package_dir / "SKILL.md").exists(): + from ..manifest import infer_multi_skill_members + nested = infer_multi_skill_members(package_dir) + if nested: + print(f"Error: {cap_id} is a multi-skill repository " + f"({len(nested)} member skills) declared as kind=skill.") + print(" A root link would be invisible to skill clients.") + print(" Model it as kind=bundle, or install a member directly:") + for m in nested[:8]: + print(f" cap install {cap_id}/{m['name']}") + return False + print(f"Warning: {cap_id} has no SKILL.md — skill clients may not discover it.") + # Install runtime dependencies before writing client configuration. A failed - # dependency install must not leave broken MCP entries behind. - if manifest.kind == "mcp-server" and _has_package_json(package_dir): + # dependency install must not leave broken MCP entries behind. Go projects + # are exempt even when a stray package.json (docs tooling) is present — + # treating them as node packages is the V5 misclassification. + if ( + manifest.kind == "mcp-server" + and _has_package_json(package_dir) + and not _is_go_project(package_dir, manifest) + ): if not _install_npm_dependencies(package_dir, cap_name): print(f"Error: npm install failed for {cap_name}. Installation aborted.") return False + # V10/STAB-004: Go MCP servers get built from the local package so the + # client config references a binary, never a network-fetching 'go run'. + if manifest.kind == "mcp-server" and _is_go_project(package_dir, manifest): + build_result = _build_go_binary(package_dir, cap_name, yes=yes) + if build_result == "failed": + print(f"Error: could not build Go binary for {cap_name}. Installation aborted.") + return False + if build_result == "no-toolchain" and not ( + skip_runtime_check + or os.environ.get("CAPACIUM_SKIP_RUNTIME_CHECK") == "1" + ): + print(f"Error: go toolchain required to build {cap_name}. Installation aborted.") + return False + attempted_frameworks: set[str] = set() successful_frameworks: List[str] = [] for fw in resolved_frameworks: @@ -284,13 +369,20 @@ def install_capability( except ValueError: print(f"Warning: Unknown framework adapter '{fw}'. Skipping.") continue - success = adapter.install_capability( - cap_name, - version, - package_dir, - owner=owner, - kind=manifest.kind or "skill", - ) + try: + success = adapter.install_capability( + cap_name, + version, + package_dir, + owner=owner, + kind=manifest.kind or "skill", + ) + except RuntimeUnavailableError as exc: + # Host-global condition: no client config was written and no other + # framework can succeed either. Abort with the install hint. + print(f"Error: cannot install {cap_id}@{version} — required runtime unavailable.") + print(str(exc)) + return False if success: successful_frameworks.append(fw) else: @@ -304,7 +396,8 @@ def install_capability( _fingerprint_excludes = [".git", "__pycache__", "*.pyc", ".DS_Store", ".capacium-meta.json", ".cap-meta.json", "capability.lock", "node_modules"] if manifest.kind == "bundle": sub_fingerprints = _install_bundle_members( - manifest, owner, package_dir, registry, storage, no_lock, force=force + manifest, owner, package_dir, registry, storage, no_lock, force=force, + all_frameworks=all_frameworks, ) fingerprint = compute_bundle_fingerprint(sub_fingerprints) else: @@ -367,6 +460,7 @@ def _install_bundle_members( storage: StorageManager, no_lock: bool, force: bool = False, + all_frameworks: bool = False, ) -> List[str]: sub_fingerprints = [] bundle_id = f"{owner}/{manifest.name}@{manifest.version}" @@ -403,7 +497,7 @@ def _install_bundle_members( _install_single_sub_cap( sub_name, sub_version, source_path, owner, registry, storage, no_lock, - force=force, + force=force, all_frameworks=all_frameworks, ) sub_cap = registry.get_capability(sub_cap_id, sub_version) @@ -424,6 +518,7 @@ def _install_single_sub_cap( storage: StorageManager, no_lock: bool, force: bool = False, + all_frameworks: bool = False, ) -> None: package_dir = storage.get_package_dir(sub_name, version, owner=owner) if package_dir.exists(): @@ -433,6 +528,7 @@ def _install_single_sub_cap( sub_manifest = Manifest.detect_from_directory(package_dir) sub_frameworks = resolve_frameworks( sub_manifest.get_target_frameworks(), + all_frameworks=all_frameworks, kind=sub_manifest.kind or "skill", ) for fw in sub_frameworks: @@ -450,7 +546,8 @@ def _install_single_sub_cap( if sub_manifest.kind == "bundle": sub_sub_fingerprints = _install_bundle_members( - sub_manifest, owner, source_path, registry, storage, no_lock + sub_manifest, owner, source_path, registry, storage, no_lock, + all_frameworks=all_frameworks, ) fingerprint = compute_bundle_fingerprint(sub_sub_fingerprints) else: @@ -509,6 +606,34 @@ def _resolve_source_path(source_raw: str, bundle_dir: Path) -> Path: return (bundle_dir / p).resolve() +def _resolve_sub_skill_dir(repo_dir: Path, sub_skill: str) -> Optional[Path]: + """Locate a member skill directory inside a multi-skill repository. + + Used for 3-part IDs (V13b): only the member subtree gets installed. + """ + from ..manifest import infer_multi_skill_members + + members = infer_multi_skill_members(repo_dir) + if not members: + manifest = Manifest.detect_from_directory(repo_dir) + if manifest.kind == "bundle": + members = [ + m for m in manifest.capabilities + if isinstance(m, dict) and "name" in m and "source" in m + ] + for member in members: + if member["name"] == sub_skill: + member_dir = _resolve_source_path(member["source"], repo_dir) + if member_dir.is_dir(): + return member_dir + print(f" Sub-skill '{sub_skill}' not found in repository.") + if members: + print(f" Available skills: {', '.join(m['name'] for m in members)}") + else: + print(" The repository does not look like a multi-skill repo.") + return None + + def _is_git_remote_url(value: str) -> bool: return value.startswith("https://") or value.startswith("git@") or value.startswith("http://") @@ -658,6 +783,16 @@ def _vk(v): if not version or version in ("", "latest", "stable"): version = "1.0.0" + # V13/STAB-001: multi-skill repositories become bundles with member + # skills instead of a single undiscoverable root skill. + members = [] + if kind in ("skill", "bundle"): + from ..manifest import infer_multi_skill_members + members = infer_multi_skill_members(repo_dir) + if members: + kind = "bundle" + description = f"Multi-skill bundle {name} ({len(members)} skills)" + yaml_data = { "kind": kind, "name": name, @@ -666,6 +801,8 @@ def _vk(v): "owner": owner, "repository": repo_url, } + if members: + yaml_data["capabilities"] = members if tags_list: yaml_data["tags"] = tags_list @@ -1300,6 +1437,117 @@ def _has_package_json(package_dir: Path) -> bool: return (runtime_dir / "package.json").exists() +def _is_go_project(package_dir: Path, manifest: Manifest) -> bool: + """True when the package is Go-based (go.mod or declared go runtime). + + A declared node runtime overrides — mixed projects that genuinely ship a + node MCP server alongside Go sources still get their npm deps installed. + """ + runtimes = getattr(manifest, "runtimes", None) or {} + if "node" in runtimes: + return False + if "go" in runtimes: + return True + return (package_dir / "go.mod").exists() + + +_ENV_SCAN_SUFFIXES = {".go", ".py", ".js", ".ts", ".mjs", ".cjs", ".rs", ".sh", + ".md", ".json", ".yaml", ".yml", ".toml", ".env.example"} + + +def _warn_unknown_env_vars(package_dir: Path, manifest: Manifest) -> None: + """Warn for manifest mcp.env vars that never occur in the package source. + + Heuristic guard for the korotovsky class: a declared env var the server + does not read means the credential mapping is wrong and the server will + start unauthenticated or not at all. + """ + mcp = getattr(manifest, "mcp", None) or {} + declared = list((mcp.get("env") or {}).keys()) + if not declared: + return + + unknown = set(declared) + scanned = 0 + for path in package_dir.rglob("*"): + if not unknown or scanned > 2000: + break + if not path.is_file() or path.suffix not in _ENV_SCAN_SUFFIXES: + continue + if any(part in ("node_modules", ".git", "vendor", "dist") + for part in path.parts): + continue + if path.name == "capability.yaml": + continue + scanned += 1 + try: + content = path.read_text(errors="replace") + except OSError: + continue + unknown = {var for var in unknown if var not in content} + + for var in sorted(unknown): + print(f" Warning: declared env var '{var}' does not appear anywhere " + f"in the package source — the server may not know it. " + f"Check the upstream docs for the correct variable name.") + + +def _go_build_target(package_dir: Path, cap_name: str) -> Optional[str]: + """Resolve the go build target by convention: cmd/, first cmd/* + with a main.go, or the package root when it holds a main.go.""" + cmd_dir = package_dir / "cmd" + if (cmd_dir / cap_name / "main.go").exists(): + return f"./cmd/{cap_name}" + if cmd_dir.is_dir(): + for sub in sorted(cmd_dir.iterdir()): + if (sub / "main.go").exists(): + return f"./cmd/{sub.name}" + if (package_dir / "main.go").exists(): + return "." + return None + + +def _build_go_binary(package_dir: Path, cap_name: str, yes: bool = False) -> str: + """V10/STAB-004: build the Go MCP server from the LOCAL package so client + configs reference a binary instead of 'go run ...@latest' network fetches. + + Returns "built", "no-target", "no-toolchain" or "failed". Never invokes + brew under CAPACIUM_SANDBOX; runtime provisioning is the install + preflight's job — this step only builds. + """ + target = _go_build_target(package_dir, cap_name) + if target is None: + print(f" Warning: no go build target found for {cap_name} " + "(no main.go, no cmd/*/main.go) — skipping binary build.") + return "no-target" + + if not shutil.which("go"): + # The runtime gate normally aborts earlier; defensive double-check. + print(" Warning: go toolchain not available — server binary not built.") + return "no-toolchain" + + bin_dir = package_dir / "bin" + bin_dir.mkdir(exist_ok=True) + binary = bin_dir / cap_name + print(f" Building Go binary from local package ({target})...") + try: + result = subprocess.run( + ["go", "build", "-o", str(binary), target], + cwd=package_dir, + capture_output=True, + text=True, + timeout=300, + ) + except (subprocess.TimeoutExpired, FileNotFoundError) as exc: + print(f" go build failed: {exc}") + return "failed" + if result.returncode != 0: + print(f" go build failed:\n{result.stderr.strip()[:800]}") + return "failed" + print(f" Built {binary.relative_to(package_dir)}") + return "built" if binary.exists() else "failed" + + def _install_npm_dependencies(package_dir: Path, cap_name: str) -> bool: """Run npm install/ci in the package directory. Returns True on success.""" import shutil as sht diff --git a/src/capacium/commands/list_capabilities.py b/src/capacium/commands/list_capabilities.py index 1ce19db..ad96760 100644 --- a/src/capacium/commands/list_capabilities.py +++ b/src/capacium/commands/list_capabilities.py @@ -130,6 +130,6 @@ def _print_adapter_statuses(cap, registry) -> None: else: symbol = STATUS_SYMBOLS.get(s.status, "?") detail = s.status - if s.last_error and s.status == "error": + if s.last_error and s.status in ("error", "blocked"): detail += f": {s.last_error}" print(f" {fw:<18} {symbol} {detail}") diff --git a/src/capacium/commands/remove.py b/src/capacium/commands/remove.py index 1ec9e98..494007a 100644 --- a/src/capacium/commands/remove.py +++ b/src/capacium/commands/remove.py @@ -1,4 +1,7 @@ import shutil +from pathlib import Path +from typing import List + from ..storage import StorageManager from ..registry import Registry from ..versioning import VersionManager @@ -6,6 +9,129 @@ from ._resolve import resolve_cap_id +class _RemovalSnapshot: + """Rollback journal for transactional removes (V14/STAB-002). + + Records the pre-remove state of every write surface (client config + files, skills-dir links, package trees, registry rows) before any + mutation. ``restore()`` puts everything back; ``commit()`` purges the + parked package trees once all steps succeeded. + """ + + def __init__(self, registry: Registry): + self._registry = registry + self._files: dict = {} # Path -> bytes | None (absent) + self._links: dict = {} # Path -> link target str | None (absent) + self._moves: List[tuple] = [] # (original Path, parked Path) + self._rows: List = [] # Capability objects removed from registry + self._members: List[tuple] = [] # (bundle_id, [member_id, ...]) + + # ── recording ────────────────────────────────────────────────────── + def record_file(self, path: Path) -> None: + path = Path(path) + if path in self._files: + return + try: + self._files[path] = path.read_bytes() if path.is_file() else None + except OSError: + self._files[path] = None + + def record_link(self, path: Path) -> None: + path = Path(path) + if path in self._links: + return + import os + if path.is_symlink(): + self._links[path] = os.readlink(path) + else: + self._links[path] = None + + def record_registry_row(self, cap) -> None: + self._rows.append(cap) + + def record_bundle_members(self, bundle_id: str, member_ids: List[str]) -> None: + self._members.append((bundle_id, list(member_ids))) + + def park_tree(self, path: Path) -> None: + """Move a package tree aside instead of deleting it outright.""" + path = Path(path) + if not path.exists(): + return + parked = path.with_name(path.name + ".removing") + idx = 0 + while parked.exists(): + idx += 1 + parked = path.with_name(f"{path.name}.removing{idx}") + path.rename(parked) + self._moves.append((path, parked)) + + # ── outcome ──────────────────────────────────────────────────────── + def restore(self) -> None: + for original, parked in reversed(self._moves): + if parked.exists() and not original.exists(): + parked.rename(original) + for path, content in self._files.items(): + try: + if content is None: + if path.is_file(): + path.unlink() + else: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + except OSError: + pass + for path, target in self._links.items(): + try: + if target is None: + if path.is_symlink(): + path.unlink() + elif not path.is_symlink() and not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + path.symlink_to(target) + except OSError: + pass + for cap in self._rows: + try: + if not self._registry.add_capability(cap): + self._registry.update_capability(cap) + except Exception: + pass + for bundle_id, member_ids in self._members: + for member_id in member_ids: + try: + self._registry.add_bundle_member(bundle_id, member_id) + except Exception: + pass + + def commit(self) -> None: + for _original, parked in self._moves: + shutil.rmtree(parked, ignore_errors=True) + + +def _snapshot_cap_surfaces(snapshot: _RemovalSnapshot, cap_name: str, + frameworks: List[str]) -> None: + """Record client config files and skills-dir links a remove may touch.""" + config_backed = set(frameworks) | {"claude-desktop", "codex", "gemini-cli", + "antigravity", "opencode"} + for fw_name in config_backed: + try: + adapter = get_adapter(fw_name) + except Exception: + continue + config_path = getattr(adapter, "config_path", None) + if config_path is not None: + snapshot.record_file(Path(config_path)) + + for parent_dir in _known_skill_paths(): + snapshot.record_link(parent_dir / cap_name) + snapshot.record_link(parent_dir / f"{cap_name}.md") + if parent_dir.is_dir(): + for item in parent_dir.iterdir(): + if item.is_dir() and not item.is_symlink(): + snapshot.record_link(item / cap_name) + snapshot.record_link(item / f"{cap_name}.md") + + def remove_capability(cap_spec: str, force: bool = False) -> bool: cap_id = resolve_cap_id(cap_spec) spec = VersionManager.parse_version_spec(cap_id) @@ -32,38 +158,50 @@ def remove_capability(cap_spec: str, force: bool = False) -> bool: print(f"Capability {bare_id}@{version} not found.") return False - _remove_sub_capabilities(cap, registry, force) + # Transactional remove (V14/STAB-002): snapshot every write surface + # first, run all adapter steps, park the package tree, and only then + # touch the registry. Any failure restores the full pre-remove state. + snapshot = _RemovalSnapshot(registry) + _snapshot_cap_surfaces(snapshot, cap_name, list(cap.frameworks or [])) - frameworks = cap.frameworks if cap.frameworks else [cap.framework or "opencode"] - for fw_name in frameworks: - try: - adapter = get_adapter(fw_name) - except ValueError: - continue - adapter.remove_capability( - cap_name, owner=owner, kind=cap.kind.value if cap.kind else "skill" - ) - - removed = registry.remove_capability(bare_id, version) - if not removed: - print(f"Capability {bare_id}@{version} not found in registry.") - return False + try: + _remove_sub_capabilities(cap, registry, force, snapshot=snapshot) - package_dir = storage.get_package_dir(cap_name, version, owner=owner) - if package_dir.exists(): - shutil.rmtree(package_dir) + frameworks = cap.frameworks if cap.frameworks else [cap.framework or "opencode"] + for fw_name in frameworks: + try: + adapter = get_adapter(fw_name) + except ValueError: + continue + adapter.remove_capability( + cap_name, owner=owner, kind=cap.kind.value if cap.kind else "skill" + ) - if force: - _purge_all_adapter_symlinks(cap_name) + package_dir = storage.get_package_dir(cap_name, version, owner=owner) + snapshot.park_tree(package_dir) + + # Registry change only after all adapter + filesystem steps succeeded. + snapshot.record_registry_row(cap) + removed = registry.remove_capability(bare_id, version) + if not removed: + raise RuntimeError(f"registry row for {bare_id}@{version} vanished mid-remove") + + if force: + _purge_all_adapter_symlinks(cap_name) + except Exception as exc: + snapshot.restore() + print(f"Remove of {bare_id}@{version} failed: {exc}") + print(" All changes rolled back — state restored.") + return False + snapshot.commit() print(f"Removed {bare_id}@{version}") return True -def _purge_all_adapter_symlinks(cap_name: str) -> None: - from pathlib import Path - - common_skill_paths = [ +def _known_skill_paths() -> List[Path]: + """Skills/command directories any adapter may have written links into.""" + return [ Path.home() / ".opencode" / "skills", Path.home() / ".config" / "opencode" / "commands", Path.home() / ".opencode" / "mcp", @@ -85,9 +223,12 @@ def _purge_all_adapter_symlinks(cap_name: str) -> None: Path.home() / ".agents" / "skills", Path.home() / ".agents" / "commands", ] + + +def _purge_all_adapter_symlinks(cap_name: str) -> None: cache_root = Path.home() / ".capacium" / "packages" - for parent_dir in common_skill_paths: + for parent_dir in _known_skill_paths(): link = parent_dir / cap_name if link.is_symlink(): target = Path(link).resolve() @@ -136,7 +277,12 @@ def _purge_all_adapter_symlinks(cap_name: str) -> None: adapter.remove_capability(cap_name, kind="mcp-server") -def _remove_sub_capabilities(cap, registry: Registry, force: bool = False) -> None: +def _remove_sub_capabilities( + cap, + registry: Registry, + force: bool = False, + snapshot: _RemovalSnapshot = None, +) -> None: bundle_id = f"{cap.owner}/{cap.name}@{cap.version}" member_ids = registry.get_bundle_members(bundle_id) @@ -158,13 +304,19 @@ def _remove_sub_capabilities(cap, registry: Registry, force: bool = False) -> No if member_cap is None and not force: continue - if member_cap is not None: - _remove_sub_capabilities(member_cap, registry, force) - owner_name = member_cap_id.split("/", 1) m_owner = owner_name[0] if len(owner_name) > 1 else "global" m_name = owner_name[-1] + if snapshot is not None: + _snapshot_cap_surfaces( + snapshot, m_name, + list(member_cap.frameworks or []) if member_cap else [], + ) + + if member_cap is not None: + _remove_sub_capabilities(member_cap, registry, force, snapshot=snapshot) + frameworks = member_cap.frameworks if (member_cap and member_cap.frameworks) else [member_cap.framework if member_cap else "opencode"] for fw_name in frameworks: try: @@ -178,22 +330,30 @@ def _remove_sub_capabilities(cap, registry: Registry, force: bool = False) -> No ) if member_cap is not None: + if snapshot is not None: + snapshot.record_registry_row(member_cap) registry.remove_capability(member_cap_id, member_version) storage = StorageManager() if member_version: pkg_dir = storage.get_package_dir(m_name, member_version, owner=m_owner) - if pkg_dir.exists(): + if snapshot is not None: + snapshot.park_tree(pkg_dir) + elif pkg_dir.exists(): shutil.rmtree(pkg_dir) if force and member_cap is None: alt_pkg_dir = storage.get_package_dir( m_name, member_version or "latest", owner="global" ) - if alt_pkg_dir.exists(): + if snapshot is not None: + snapshot.park_tree(alt_pkg_dir) + elif alt_pkg_dir.exists(): shutil.rmtree(alt_pkg_dir) _purge_all_adapter_symlinks(m_name) print(f" Removed sub-capability {member_id}") + if snapshot is not None: + snapshot.record_bundle_members(bundle_id, member_ids) registry.remove_bundle_members(bundle_id) diff --git a/src/capacium/commands/submit.py b/src/capacium/commands/submit.py new file mode 100644 index 0000000..858bb60 --- /dev/null +++ b/src/capacium/commands/submit.py @@ -0,0 +1,110 @@ +"""cap submit — submit a GitHub repository for indexing on the Exchange. + +The Exchange /v2/submit endpoint is queue-based (202 + job_id); the result +must be polled via GET /v2/submit/{job_id}. V12 regression: the CLI assumed +a synchronous schema and printed 'unknown' for every field although the +submit landed server-side. Unknown response schemas now show the raw +response with a warning instead of fabricated 'unknown' values. +""" + +import json +import time +from typing import Any, Dict, Optional + +LISTINGS_BASE_URL = "https://capacium.xyz/listings" + + +def _print_listing_summary(canonical_name: str, kind: Optional[str], + trust: Optional[str]) -> None: + print(f"Submitted: {canonical_name}") + if kind: + print(f" Kind: {kind}") + if trust: + print(f" Trust: {trust}") + print(f" URL: {LISTINGS_BASE_URL}/{canonical_name}") + + +def _print_raw_with_warning(response: Any) -> None: + print("Warning: unrecognized response schema from the Exchange — raw response:") + try: + print(json.dumps(response, indent=2, default=str)) + except (TypeError, ValueError): + print(repr(response)) + + +def submit_repository( + github_url: str, + registry_url: Optional[str] = None, + client=None, + wait_timeout: float = 90.0, + poll_interval: float = 2.0, +) -> bool: + """Submit *github_url* to the Exchange and report the real outcome. + + Returns True when the submission was accepted (even if still + processing), False when the Exchange reports the job as failed. + """ + if client is None: + from ..registry_client import RegistryClient + client = RegistryClient() + + response = client.submit(github_url, registry_url=registry_url) + + if not isinstance(response, dict): + _print_raw_with_warning(response) + return True + + # Legacy synchronous schema (pre-queue Exchange versions) + if "canonical_name" in response and "job_id" not in response: + _print_listing_summary( + response["canonical_name"], + response.get("kind"), + response.get("trust_state"), + ) + return True + + # Queue schema: 202 + job_id, poll for the result + job_id = response.get("job_id") + if not job_id: + _print_raw_with_warning(response) + return True + + canonical_hint = response.get("canonical_hint", github_url) + print(f"Accepted: {canonical_hint} (job {job_id})") + + deadline = time.monotonic() + wait_timeout + job: Dict[str, Any] = dict(response) + while time.monotonic() < deadline: + try: + job = client.submit_status(job_id, registry_url=registry_url) + except Exception as exc: + print(f" Warning: could not poll job status: {exc}") + break + status = (job or {}).get("status") + if status in ("completed", "failed"): + break + time.sleep(poll_interval) + + status = (job or {}).get("status") + if status == "failed": + print(f"Submit failed: {job.get('error') or 'unspecified error'}") + return False + + if status == "completed": + canonical_name = job.get("canonical_name") or canonical_hint + kind = trust = None + try: + detail = client.get_detail(canonical_name, registry_url=registry_url) + if isinstance(detail, dict): + kind = detail.get("kind") + trust = detail.get("trust_state") + except Exception: + pass # listing detail is best-effort decoration + _print_listing_summary(canonical_name, kind, trust) + if job.get("message"): + print(f" {job['message']}") + return True + + print(f"Still processing — check later with job id {job_id}") + print(f" (GET /v2/submit/{job_id} on the Exchange)") + return True diff --git a/src/capacium/commands/update.py b/src/capacium/commands/update.py index 8667afd..b7d2ce5 100644 --- a/src/capacium/commands/update.py +++ b/src/capacium/commands/update.py @@ -17,6 +17,7 @@ _preflight_runtimes, install_capability, ) +from .hold import get_hold from ._resolve import resolve_cap_id @@ -129,13 +130,22 @@ def update_capability( if not skip_runtime_check and not _preflight_runtimes(manifest): return False + cap_label = f"{cap.owner}/{cap.name}@{cap.version}" + + # V8/UP-001: held packages (locally patched) are never updated. + hold = get_hold(f"{cap.owner}/{cap.name}") + if hold is not None and not force: + print(f"{cap_label} is held: {hold.get('reason') or 'locally patched'}") + print(" Skipping update. Release with 'cap unhold' or use --force.") + return True + current_fingerprint = compute_fingerprint( cap.install_path, exclude_patterns=FINGERPRINT_EXCLUDES, ) - cap_label = f"{cap.owner}/{cap.name}@{cap.version}" + fingerprint_drift = current_fingerprint != cap.fingerprint - if current_fingerprint == cap.fingerprint and not force: + if not fingerprint_drift and not force: print(f"{cap_label} content is already up to date; reconciling adapters...") else: print(f"Updating {cap_label} from {cap.install_path}...") @@ -156,7 +166,13 @@ def update_capability( print(f"Updated {cap_label}") if version_spec in ("latest", "stable"): - _check_for_newer_version(cap_id, cap.version, cap.source_url) + if fingerprint_drift and not force: + # V8: local modifications would be wiped by a force-reinstall. + print(" Local modifications detected (fingerprint drift).") + print(" Skipping newer-version fetch — keep the patch with 'cap hold'") + print(" or overwrite explicitly with 'cap update --force'.") + else: + _check_for_newer_version(cap_id, cap.version, cap.source_url) return True diff --git a/src/capacium/framework_detector.py b/src/capacium/framework_detector.py index b4e4b57..de2a966 100644 --- a/src/capacium/framework_detector.py +++ b/src/capacium/framework_detector.py @@ -9,11 +9,15 @@ def framework_skills_dirs() -> Dict[str, Path]: Must stay a function: import-time resolution freezes the real home and silently ignores sandbox HOME overrides (V3, 2026-06-11). """ - return { + # V7/STAB-006: never resolve project-scope clients against Path.cwd() — + # cursor appears only with an explicit project root; opencode discovers + # globally under the user home. + from .utils.project_scope import get_project_root + + dirs = { "claude-code": Path.home() / ".claude" / "skills", - "cursor": Path.cwd() / ".cursor" / "skills", "gemini-cli": Path.home() / ".gemini" / "skills", - "opencode": Path.cwd() / ".opencode" / "skills", + "opencode": Path.home() / ".opencode" / "skills", "openclaw": Path.home() / ".openclaw" / "skills", "continue-dev": Path.home() / ".continue" / "skills", "antigravity": Path.home() / ".gemini" / "config" / "skills", @@ -23,6 +27,10 @@ def framework_skills_dirs() -> Dict[str, Path]: "copilot": Path.home() / ".config" / "github-copilot" / "skills", "qwen": Path.home() / ".qwen" / "skills", } + project_root = get_project_root() + if project_root is not None: + dirs["cursor"] = project_root / ".cursor" / "skills" + return dirs # Backward-compatible snapshot (import-time HOME). Prefer the function above. @@ -219,10 +227,22 @@ def create_framework_symlinks( trust_state: str = "untrusted", ) -> List[str]: created: List[str] = [] + from .models import SKILL_LAYER_KIND_VALUES + if kind not in SKILL_LAYER_KIND_VALUES: + # Kind-placement contract (V6): mcp-server/bundle/connector-pack never + # appear in skills directories (Antigravity regression: MCP servers + # were fanned out as skills via --all-frameworks). + return created from .symlink_manager import SymlinkManager sm = SymlinkManager() for fw in frameworks: skills_dir = FRAMEWORK_SKILLS_DIRS.get(fw) + if skills_dir is None and fw == "cursor": + # Project-scope client: links only with an explicit project root. + from .utils.project_scope import get_project_root + project_root = get_project_root() + if project_root is not None: + skills_dir = project_root / ".cursor" / "skills" if skills_dir is None: continue skills_dir.mkdir(parents=True, exist_ok=True) diff --git a/src/capacium/manifest.py b/src/capacium/manifest.py index dfc2133..87e3808 100644 --- a/src/capacium/manifest.py +++ b/src/capacium/manifest.py @@ -244,6 +244,21 @@ def detect_from_directory(cls, directory: Path) -> "Manifest": from .versioning import VersionManager version = VersionManager.detect_version(directory) + + # V13/STAB-001: multi-skill repositories (skills/*/SKILL.md, plugin + # layouts) are bundles with member skills — modeling them as a single + # root skill produced undiscoverable SKILL.md-less root links. + members = infer_multi_skill_members(directory) + if members: + return cls( + kind="bundle", + owner="unknown", + name=directory.name, + version=version, + description=f"Multi-skill bundle {directory.name}", + capabilities=members, + ) + return cls( owner="unknown", name=directory.name, @@ -252,6 +267,59 @@ def detect_from_directory(cls, directory: Path) -> "Manifest": ) +_MEMBER_IGNORE_DIRS = { + "node_modules", ".git", "__pycache__", ".venv", "venv", + "dist", "build", "tests", "test", "docs", +} + + +def infer_multi_skill_members(directory: Path) -> List[Dict[str, str]]: + """Detect multi-skill repository structures (V13/STAB-001). + + Recognized layouts (member = directory containing a SKILL.md): + + 1. ``skills//SKILL.md`` at the repository root + 2. ``/skills//SKILL.md`` one level deep + (plugin layout, e.g. ``repo-plugin/skills/...``) + 3. two or more sibling ``/SKILL.md`` directories at the root + + Returns ``[{"name": , "source": }, ...]`` sorted + by name, or an empty list when the directory is not multi-skill shaped. + A root-level SKILL.md means the repo IS a single skill — no inference. + """ + directory = Path(directory) + if not directory.is_dir() or (directory / "SKILL.md").exists(): + return [] + + def _collect(pattern: str) -> List[Path]: + hits = [] + for skill_md in sorted(directory.glob(pattern)): + member_dir = skill_md.parent + if any(part in _MEMBER_IGNORE_DIRS or part.startswith(".") + for part in member_dir.relative_to(directory).parts): + continue + hits.append(member_dir) + return hits + + members = _collect("skills/*/SKILL.md") + _collect("*/skills/*/SKILL.md") + if not members: + siblings = _collect("*/SKILL.md") + if len(siblings) >= 2: + members = siblings + + seen = set() + result: List[Dict[str, str]] = [] + for member_dir in members: + if member_dir.name in seen: + continue + seen.add(member_dir.name) + result.append({ + "name": member_dir.name, + "source": "./" + member_dir.relative_to(directory).as_posix(), + }) + return sorted(result, key=lambda m: m["name"]) + + def parse_cap_id(cap_id: str) -> tuple[str, str]: if "/" in cap_id: owner, name = cap_id.split("/", 1) diff --git a/src/capacium/models.py b/src/capacium/models.py index fcda132..fb66ec6 100644 --- a/src/capacium/models.py +++ b/src/capacium/models.py @@ -46,6 +46,22 @@ class Kind(Enum): RESOURCE = "resource" +# Kind-placement contract (V6): only these kinds may materialize as links in +# client skills directories. mcp-server lives in client MCP configs; bundle +# and connector-pack roots are containers whose members are placed +# individually. +SKILL_LAYER_KINDS = frozenset({ + Kind.SKILL, + Kind.PROMPT, + Kind.TEMPLATE, + Kind.WORKFLOW, + Kind.TOOL, + Kind.RESOURCE, +}) + +SKILL_LAYER_KIND_VALUES = frozenset(k.value for k in SKILL_LAYER_KINDS) + + @dataclass class Capability: diff --git a/src/capacium/registry_client.py b/src/capacium/registry_client.py index 07abd37..c21e505 100644 --- a/src/capacium/registry_client.py +++ b/src/capacium/registry_client.py @@ -374,6 +374,17 @@ def submit( data = json.dumps(payload).encode("utf-8") return self._request(url, method="POST", data=data) + def submit_status( + self, + job_id: str, + registry_url: Optional[str] = None, + ) -> Dict[str, Any]: + """Poll a queued submission (/v2/submit returns 202 + job_id).""" + url = self._build_registry_url( + f"/v2/submit/{urllib.parse.quote(job_id, safe='')}", registry_url + ) + return self._request(url) + def publisher_sign( self, owner: str, diff --git a/src/capacium/utils/project_scope.py b/src/capacium/utils/project_scope.py new file mode 100644 index 0000000..f0f01d8 --- /dev/null +++ b/src/capacium/utils/project_scope.py @@ -0,0 +1,25 @@ +"""Explicit project scope for project-local adapters (V7/STAB-006). + +Project-scope clients (cursor; opencode historically) used to write into +``Path.cwd()`` implicitly — littering package directories and foreign repos +with ``.cursor/`` files and skill links. Project-local writes now happen +only when an explicit project root was provided (``cap install --project`` +or ``CAPACIUM_PROJECT_ROOT``). +""" + +import os +from pathlib import Path +from typing import Optional + +ENV_VAR = "CAPACIUM_PROJECT_ROOT" + + +def set_project_root(path) -> Path: + resolved = Path(path).expanduser().resolve() + os.environ[ENV_VAR] = str(resolved) + return resolved + + +def get_project_root() -> Optional[Path]: + raw = os.environ.get(ENV_VAR, "").strip() + return Path(raw) if raw else None diff --git a/tests/conftest.py b/tests/conftest.py index 8d874d6..5dbe6c4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,16 @@ from pathlib import Path +@pytest.fixture(autouse=True) +def _skip_runtime_gate(monkeypatch): + """Keep the suite host-independent: the adapter-level runtime gate + (STAB-003) would otherwise make fixture installs depend on which + runtimes the CI runner happens to ship. Gate-specific tests in + test_runtime_gate.py re-enable it explicitly. + """ + monkeypatch.setenv("CAPACIUM_SKIP_RUNTIME_CHECK", "1") + + @pytest.fixture def tmp_home(monkeypatch): with tempfile.TemporaryDirectory() as td: diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 9eba964..cc639b2 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -1,4 +1,7 @@ from pathlib import Path + +import pytest + from capacium.adapters.claude_code import ClaudeCodeAdapter from capacium.adapters.claude_desktop import _path_in_sandbox_denied from capacium.adapters.gemini_cli import GeminiCLIAdapter @@ -277,14 +280,35 @@ def test_skills_dir_created(self, tmp_home, sample_capability_dir): class TestCursorAdapterSkills: + """V7/STAB-006: cursor skills are project-scoped — links require an + explicit project root, never implicit Path.cwd().""" - def test_install_capability(self, tmp_home, sample_capability_dir): + @pytest.fixture + def project_root(self, tmp_path, monkeypatch): + project = tmp_path / "the-project" + project.mkdir() + monkeypatch.setenv("CAPACIUM_PROJECT_ROOT", str(project)) + return project + + def test_install_capability(self, tmp_home, project_root, sample_capability_dir): adapter = CursorAdapter() result = adapter.install_capability("test-cap", "1.0.0", sample_capability_dir) assert result is True assert adapter.capability_exists("test-cap") + assert (project_root / ".cursor" / "skills" / "test-cap").is_symlink() - def test_remove_capability(self, tmp_home, sample_capability_dir): + def test_install_without_project_creates_no_cwd_files( + self, tmp_home, sample_capability_dir, monkeypatch, tmp_path + ): + monkeypatch.delenv("CAPACIUM_PROJECT_ROOT", raising=False) + workdir = tmp_path / "random-cwd" + workdir.mkdir() + monkeypatch.chdir(workdir) + adapter = CursorAdapter() + assert adapter.install_capability("test-cap", "1.0.0", sample_capability_dir) is True + assert not (workdir / ".cursor").exists() + + def test_remove_capability(self, tmp_home, project_root, sample_capability_dir): adapter = CursorAdapter() adapter.install_capability("test-cap", "1.0.0", sample_capability_dir) assert adapter.capability_exists("test-cap") @@ -302,8 +326,10 @@ def test_capability_exists_false_for_missing(self, tmp_home): adapter = CursorAdapter() assert not adapter.capability_exists("nonexistent") - def test_skills_dir_created(self, tmp_home, sample_capability_dir): - skills_dir = Path.cwd() / ".cursor" / "skills" + def test_skills_dir_created(self, tmp_home, project_root, sample_capability_dir): + # V7/STAB-006: the skills dir lives under the explicit project root, + # never the implicit cwd. + skills_dir = project_root / ".cursor" / "skills" adapter = CursorAdapter() adapter.install_capability("test-cap", "1.0.0", sample_capability_dir) assert skills_dir.exists() diff --git a/tests/test_block_status.py b/tests/test_block_status.py new file mode 100644 index 0000000..c33c727 --- /dev/null +++ b/tests/test_block_status.py @@ -0,0 +1,94 @@ +"""UP-002: blocked-but-honest status for upstream-broken capabilities. + +elementeer (@elementeer/shared missing on npm) and karldane (go.mod replace +pointing at the author's machine) can never work — they get per-adapter +status 'blocked' with the upstream reason, visible in cap list --details +and distinguished from 'broken' in doctor. +""" +import pytest + +from capacium.commands.block_status import ( + block_capability, + get_blocked_frameworks, + unblock_capability, +) +from capacium.commands.doctor import _check_mcp_handshake +from capacium.commands.list_capabilities import list_capabilities +from capacium.models import Capability, Kind +from capacium.registry import Registry + + +@pytest.fixture +def upstream_broken_cap(tmp_home): + pkg = tmp_home / ".capacium" / "packages" / "karldane" / "slack-mcp" / "1.0.0" + pkg.mkdir(parents=True) + (pkg / "capability.yaml").write_text( + "kind: mcp-server\nname: slack-mcp\nversion: 1.0.0\ndescription: t\n" + "mcp:\n transport: stdio\n command: /nonexistent/slack-mcp\n" + ) + registry = Registry() + cap = Capability( + owner="karldane", name="slack-mcp", version="1.0.0", + kind=Kind.MCP_SERVER, install_path=pkg, fingerprint="f" * 64, + framework="claude-code", frameworks=["claude-code", "opencode"], + ) + assert registry.add_capability(cap) + return {"registry": registry, "cap": cap} + + +REASON = "go.mod replace points at the author's machine — never buildable" + + +class TestBlockLifecycle: + def test_block_sets_status_on_all_adapters(self, tmp_home, upstream_broken_cap): + assert block_capability( + "karldane/slack-mcp", reason=REASON, + issue="https://github.com/karldane/slack-mcp/issues/1", + ) is True + blocked = get_blocked_frameworks( + upstream_broken_cap["registry"], upstream_broken_cap["cap"] + ) + assert set(blocked) == {"claude-code", "opencode"} + assert REASON in blocked["claude-code"] + assert "issues/1" in blocked["claude-code"] + + def test_block_requires_reason(self, tmp_home, upstream_broken_cap): + assert block_capability("karldane/slack-mcp", reason="") is False + + def test_unblock_clears(self, tmp_home, upstream_broken_cap): + block_capability("karldane/slack-mcp", reason=REASON) + assert unblock_capability("karldane/slack-mcp") is True + assert get_blocked_frameworks( + upstream_broken_cap["registry"], upstream_broken_cap["cap"] + ) == {} + assert unblock_capability("karldane/slack-mcp") is False + + +class TestVisibility: + def test_list_details_shows_blocked_with_reason( + self, tmp_home, upstream_broken_cap, capsys + ): + block_capability("karldane/slack-mcp", reason=REASON) + capsys.readouterr() + list_capabilities(details=True) + out = capsys.readouterr().out + assert "blocked" in out + assert "never buildable" in out + + def test_doctor_distinguishes_blocked_from_broken( + self, tmp_home, upstream_broken_cap, capsys + ): + """A blocked cap must not appear as a failed probe — and must not be + probed at all (it cannot respond, that is the point).""" + block_capability("karldane/slack-mcp", reason=REASON) + name, passed, detail = _check_mcp_handshake() + assert passed is True, f"blocked cap reported as broken: {detail}" + assert "blocked (upstream)" in detail + assert "slack-mcp" in detail + + def test_doctor_unblocked_broken_cap_still_fails( + self, tmp_home, upstream_broken_cap + ): + name, passed, detail = _check_mcp_handshake() + assert passed is False + assert "slack-mcp" in detail diff --git a/tests/test_bundle_inference.py b/tests/test_bundle_inference.py new file mode 100644 index 0000000..f9d7f30 --- /dev/null +++ b/tests/test_bundle_inference.py @@ -0,0 +1,184 @@ +"""STAB-001 (V13): multi-skill repo modeling — bundle inference, member +links, sub-skill subtree installs, and the SKILL.md root-link guard. + +Regression (lum1104/understand-anything, 2026-06-11): + V13a: repo root auto-modeled as kind=skill without SKILL.md → root link + invisible in every client + V13b: 3-part ID install copied the WHOLE repo again, broke the + owner/name/version layout and created no links +""" +from pathlib import Path + +import pytest + +from capacium.commands.install import install_capability +from capacium.manifest import Manifest, infer_multi_skill_members +from capacium.registry import Registry + + +def _make_multi_skill_repo(root: Path, layout: str = "skills") -> Path: + """Create a multi-skill repo. layout: 'skills' | 'plugin' | 'siblings'.""" + repo = root / "multi-repo" + if layout == "skills": + base = repo / "skills" + elif layout == "plugin": + base = repo / "multi-repo-plugin" / "skills" + else: + base = repo + for name in ("understand", "understand-chat", "understand-diff"): + d = base / name + d.mkdir(parents=True) + (d / "SKILL.md").write_text(f"# {name}\n\nDoes {name} things.\n") + (repo / "README.md").write_text("# multi repo\n") + return repo + + +class TestInference: + def test_skills_layout_detected(self, tmp_path): + repo = _make_multi_skill_repo(tmp_path, "skills") + members = infer_multi_skill_members(repo) + assert [m["name"] for m in members] == [ + "understand", "understand-chat", "understand-diff", + ] + assert members[0]["source"] == "./skills/understand" + + def test_plugin_layout_detected(self, tmp_path): + repo = _make_multi_skill_repo(tmp_path, "plugin") + members = infer_multi_skill_members(repo) + assert len(members) == 3 + assert members[0]["source"].startswith("./multi-repo-plugin/skills/") + + def test_sibling_layout_needs_two(self, tmp_path): + repo = _make_multi_skill_repo(tmp_path, "siblings") + assert len(infer_multi_skill_members(repo)) == 3 + + single = tmp_path / "single" + (single / "only-one").mkdir(parents=True) + (single / "only-one" / "SKILL.md").write_text("# x\n") + assert infer_multi_skill_members(single) == [] + + def test_root_skill_md_means_single_skill(self, tmp_path): + repo = _make_multi_skill_repo(tmp_path, "skills") + (repo / "SKILL.md").write_text("# the repo itself is a skill\n") + assert infer_multi_skill_members(repo) == [] + + def test_ignore_dirs_are_skipped(self, tmp_path): + repo = tmp_path / "noisy" + (repo / "node_modules" / "dep" / "skills" / "x").mkdir(parents=True) + (repo / "node_modules" / "dep" / "skills" / "x" / "SKILL.md").write_text("#\n") + (repo / "tests" / "SKILL.md").parent.mkdir(parents=True) + (repo / "tests" / "SKILL.md").write_text("#\n") + assert infer_multi_skill_members(repo) == [] + + def test_detect_from_directory_falls_back_to_bundle(self, tmp_path): + repo = _make_multi_skill_repo(tmp_path, "skills") + manifest = Manifest.detect_from_directory(repo) + assert manifest.kind == "bundle" + assert len(manifest.capabilities) == 3 + assert manifest.validate() == [] + + +class TestAutoManifest: + def test_auto_manifest_models_bundle(self, tmp_path, monkeypatch): + from capacium.commands import install as install_mod + monkeypatch.setattr(install_mod, "_fetch_remote_tags", lambda url: []) + repo = _make_multi_skill_repo(tmp_path, "skills") + install_mod._auto_generate_manifest( + repo, "https://github.com/acme/multi-repo" + ) + manifest = Manifest.load(repo / "capability.yaml") + assert manifest.kind == "bundle" + assert len(manifest.capabilities) == 3 + assert manifest.owner == "acme" + + +@pytest.fixture +def claude_home(tmp_home, monkeypatch): + (tmp_home / ".claude" / "skills").mkdir(parents=True) + monkeypatch.chdir(tmp_home) + return tmp_home + + +class TestEndToEndInstall: + def test_multi_skill_install_links_each_member(self, claude_home, tmp_path): + repo = _make_multi_skill_repo(tmp_path, "skills") + ok = install_capability("acme/multi-repo", source_dir=repo, + no_lock=True, yes=True) + assert ok is True + + skills_dir = claude_home / ".claude" / "skills" + entries = {p.name for p in skills_dir.iterdir()} + # N direct, discoverable member links ... + for member in ("understand", "understand-chat", "understand-diff"): + assert member in entries, f"member {member} not linked" + assert (skills_dir / member / "SKILL.md").exists() + # ... and no SKILL.md-less root link + assert "multi-repo" not in entries + + registry = Registry() + assert registry.get_capability("acme/multi-repo") is not None # bundle row + assert registry.get_capability("acme/understand-chat") is not None + + def test_sub_skill_id_installs_only_subtree(self, claude_home, tmp_path): + repo = _make_multi_skill_repo(tmp_path, "skills") + (repo / "skills" / "understand" / "big-blob.bin").write_bytes(b"x" * 50_000) + + ok = install_capability("acme/multi-repo/understand-chat", + source_dir=repo, no_lock=True, yes=True) + assert ok is True + + pkg_root = claude_home / ".capacium" / "packages" / "acme" / "understand-chat" + versions = list(pkg_root.iterdir()) + assert len(versions) == 1, "owner/name/version layout must hold" + pkg = versions[0] + assert (pkg / "SKILL.md").exists() + # subtree only: no sibling members, no full-repo copy + assert not (pkg / "skills").exists() + assert not (pkg / "big-blob.bin").exists() + size = sum(f.stat().st_size for f in pkg.rglob("*") if f.is_file()) + assert size < 10_000, "sub-skill package must be a fraction of the repo" + + registry = Registry() + cap = registry.get_capability("acme/understand-chat") + assert cap is not None + assert (claude_home / ".claude" / "skills" / "understand-chat").exists() + + def test_sub_skill_id_unknown_member_fails_with_listing( + self, claude_home, tmp_path, capsys + ): + repo = _make_multi_skill_repo(tmp_path, "skills") + ok = install_capability("acme/multi-repo/nope", source_dir=repo, + no_lock=True, yes=True) + out = capsys.readouterr().out + assert ok is False + assert "understand-chat" in out # available members listed + + +class TestRootLinkGuard: + def test_mismodeled_multi_skill_repo_is_refused(self, claude_home, tmp_path, capsys): + repo = _make_multi_skill_repo(tmp_path, "skills") + (repo / "capability.yaml").write_text( + "kind: skill\nname: multi-repo\nversion: 1.0.0\n" + "description: wrongly modeled\n" + ) + ok = install_capability("acme/multi-repo", source_dir=repo, + no_lock=True, yes=True) + out = capsys.readouterr().out + assert ok is False + assert "multi-skill" in out + assert not (claude_home / ".claude" / "skills" / "multi-repo").exists() + + def test_plain_skill_without_skill_md_warns_but_installs( + self, claude_home, tmp_path, capsys + ): + repo = tmp_path / "plain" + repo.mkdir() + (repo / "capability.yaml").write_text( + "kind: skill\nname: plain\nversion: 1.0.0\ndescription: ok\n" + ) + (repo / "main.py").write_text("print('x')\n") + ok = install_capability("acme/plain", source_dir=repo, + no_lock=True, yes=True) + out = capsys.readouterr().out + assert ok is True + assert "SKILL.md" in out # warning shown diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 7f1d251..5b219b3 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -132,6 +132,53 @@ def test_dependency_materialization_missing_node_modules(self, tmp_home, monkeyp assert passed is False assert "node_modules missing" in detail + def test_dependency_materialization_uvx_needs_no_venv(self, tmp_home, monkeypatch): + """V5 regression: uvx-based packages create ephemeral envs — a missing + .venv must not be reported as an issue (false positive class).""" + caps = [ + Capability( + owner="acme", name="uvx-mcp", version="1.0.0", + kind=Kind.MCP_SERVER, install_path=tmp_home / "uvx-mcp", + ), + ] + install = caps[0].install_path + install.mkdir(parents=True) + (install / "pyproject.toml").write_text("[project]\nname = 'uvx-mcp'\n") + (install / "capability.yaml").write_text( + "kind: mcp-server\nname: uvx-mcp\nversion: 1.0.0\n" + "description: uvx fixture\n" + "mcp:\n command: uvx\n args: ['uvx-mcp']\n" + ) + monkeypatch.setattr( + "capacium.commands.doctor.Registry.list_capabilities", + MagicMock(return_value=caps), + ) + name, passed, detail = _check_dependency_materialization() + assert passed is True, detail + + def test_dependency_materialization_python_cmd_still_needs_venv(self, tmp_home, monkeypatch): + caps = [ + Capability( + owner="acme", name="py-mcp", version="1.0.0", + kind=Kind.MCP_SERVER, install_path=tmp_home / "py-mcp", + ), + ] + install = caps[0].install_path + install.mkdir(parents=True) + (install / "requirements.txt").write_text("requests\n") + (install / "capability.yaml").write_text( + "kind: mcp-server\nname: py-mcp\nversion: 1.0.0\n" + "description: python fixture\n" + "mcp:\n command: python\n args: ['server.py']\n" + ) + monkeypatch.setattr( + "capacium.commands.doctor.Registry.list_capabilities", + MagicMock(return_value=caps), + ) + name, passed, detail = _check_dependency_materialization() + assert passed is False + assert ".venv missing" in detail + def test_dependency_materialization_ok(self, tmp_home, monkeypatch): caps = [ Capability( diff --git a/tests/test_env_materialization.py b/tests/test_env_materialization.py new file mode 100644 index 0000000..76645df --- /dev/null +++ b/tests/test_env_materialization.py @@ -0,0 +1,136 @@ +"""STAB-007 (V11+): manifest env materialization + secret indirection. + +- declared mcp.env blocks land in client configs (clients like Gemini do + not pass the shell env through — regression: PERPLEXITY_COOKIES) +- secret-looking keys never carry literal values in written configs + (static guard: ${VAR} indirection via launchd/envctl) +- declared env vars unknown to the server source produce a warning + (korotovsky: SLACK_BOT_TOKEN vs SLACK_MCP_XOXP_TOKEN) +""" +import json + +from capacium.adapters.mcp_config_patcher import McpConfigPatcher +from capacium.commands.install import _warn_unknown_env_vars +from capacium.manifest import Manifest + + +class TestSanitizeEnvBlock: + def test_reference_passes_through(self): + env = McpConfigPatcher.sanitize_env_block( + {"PERPLEXITY_COOKIES": "${PERPLEXITY_COOKIES}"} + ) + assert env == {"PERPLEXITY_COOKIES": "${PERPLEXITY_COOKIES}"} + + def test_empty_value_becomes_reference(self): + env = McpConfigPatcher.sanitize_env_block({"SLACK_MCP_XOXP_TOKEN": ""}) + assert env == {"SLACK_MCP_XOXP_TOKEN": "${SLACK_MCP_XOXP_TOKEN}"} + + def test_literal_secret_is_redacted(self, capsys): + env = McpConfigPatcher.sanitize_env_block( + {"SLACK_MCP_XOXP_TOKEN": "xoxp-1234-real-secret"}, "slack-mcp" + ) + assert env == {"SLACK_MCP_XOXP_TOKEN": "${SLACK_MCP_XOXP_TOKEN}"} + out = capsys.readouterr().out + assert "redacted" in out + assert "xoxp-1234-real-secret" not in out + + def test_harmless_literal_kept(self): + env = McpConfigPatcher.sanitize_env_block( + {"SLACK_MCP_LOG_LEVEL": "debug", "PORT": "3001"} + ) + assert env == {"SLACK_MCP_LOG_LEVEL": "debug", "PORT": "3001"} + + +class TestEnvLandsInConfigs: + def _pkg(self, tmp_path): + pkg = tmp_path / "envful" + pkg.mkdir() + (pkg / "capability.yaml").write_text( + "kind: mcp-server\nname: envful\nversion: 1.0.0\ndescription: t\n" + "mcp:\n transport: stdio\n command: python3\n args: ['srv.py']\n" + " env:\n PERPLEXITY_COOKIES: '${PERPLEXITY_COOKIES}'\n" + ) + (pkg / "srv.py").write_text("import os; os.environ['PERPLEXITY_COOKIES']\n") + return pkg + + def test_json_config_contains_env_block(self, tmp_path): + pkg = self._pkg(tmp_path) + manifest = Manifest.detect_from_directory(pkg) + config_path = tmp_path / "client.json" + McpConfigPatcher.inject_json_mcp_server( + config_path=config_path, + server_key="envful", + mcp_section_key="mcpServers", + cap_name="envful", + source_dir=pkg, + mcp_meta=manifest.get_mcp_metadata(), + ) + entry = json.loads(config_path.read_text())["mcpServers"]["envful"] + assert entry["env"] == {"PERPLEXITY_COOKIES": "${PERPLEXITY_COOKIES}"} + + def test_opencode_entry_contains_env_block(self, tmp_path): + pkg = self._pkg(tmp_path) + manifest = Manifest.detect_from_directory(pkg) + entry = McpConfigPatcher.build_opencode_mcp_entry( + "envful", pkg, manifest.get_mcp_metadata() + ) + assert entry["env"] == {"PERPLEXITY_COOKIES": "${PERPLEXITY_COOKIES}"} + + def test_no_plaintext_secret_in_written_config(self, tmp_path): + pkg = tmp_path / "leaky" + pkg.mkdir() + (pkg / "capability.yaml").write_text( + "kind: mcp-server\nname: leaky\nversion: 1.0.0\ndescription: t\n" + "mcp:\n transport: stdio\n command: python3\n args: ['s.py']\n" + " env:\n MY_API_KEY: 'sk-live-supersecret'\n" + ) + manifest = Manifest.detect_from_directory(pkg) + config_path = tmp_path / "client.json" + McpConfigPatcher.inject_json_mcp_server( + config_path=config_path, + server_key="leaky", + mcp_section_key="mcpServers", + cap_name="leaky", + source_dir=pkg, + mcp_meta=manifest.get_mcp_metadata(), + ) + raw = config_path.read_text() + assert "sk-live-supersecret" not in raw + entry = json.loads(raw)["mcpServers"]["leaky"] + assert entry["env"]["MY_API_KEY"] == "${MY_API_KEY}" + + +class TestUnknownEnvVarWarning: + def _manifest(self, env_keys): + env_yaml = "".join(f" {k}: '${{{k}}}'\n" for k in env_keys) + return ( + "kind: mcp-server\nname: slack-mcp\nversion: 1.0.0\ndescription: t\n" + "mcp:\n transport: stdio\n command: go\n args: ['run', '.']\n" + f" env:\n{env_yaml}" + ) + + def test_korotovsky_class_mismatch_warns(self, tmp_path, capsys): + pkg = tmp_path / "slack-mcp" + pkg.mkdir() + (pkg / "capability.yaml").write_text(self._manifest(["SLACK_BOT_TOKEN"])) + (pkg / "main.go").write_text( + 'package main\nimport "os"\n' + 'func main() { os.Getenv("SLACK_MCP_XOXP_TOKEN") }\n' + ) + manifest = Manifest.detect_from_directory(pkg) + _warn_unknown_env_vars(pkg, manifest) + out = capsys.readouterr().out + assert "SLACK_BOT_TOKEN" in out + assert "does not appear" in out + + def test_known_env_var_no_warning(self, tmp_path, capsys): + pkg = tmp_path / "slack-mcp" + pkg.mkdir() + (pkg / "capability.yaml").write_text(self._manifest(["SLACK_MCP_XOXP_TOKEN"])) + (pkg / "main.go").write_text( + 'package main\nimport "os"\n' + 'func main() { os.Getenv("SLACK_MCP_XOXP_TOKEN") }\n' + ) + manifest = Manifest.detect_from_directory(pkg) + _warn_unknown_env_vars(pkg, manifest) + assert "does not appear" not in capsys.readouterr().out diff --git a/tests/test_go_build.py b/tests/test_go_build.py new file mode 100644 index 0000000..df29b9f --- /dev/null +++ b/tests/test_go_build.py @@ -0,0 +1,136 @@ +"""STAB-004 (V10): Go build-from-local-package pipeline. + +Install builds runtimes.go MCP servers from the local package (go build, +entrypoint by cmd/ convention) and client configs reference the binary — +never a network-fetching 'go run ...@latest' (korotovsky parity). +""" +import shutil +import subprocess +from pathlib import Path + +import pytest + +from capacium.adapters.mcp_config_patcher import McpConfigPatcher +from capacium.commands.install import _build_go_binary, _go_build_target + + +def _go_package(root: Path, name: str = "slack-mcp", layout: str = "cmd") -> Path: + pkg = root / name + pkg.mkdir(parents=True) + (pkg / "go.mod").write_text(f"module example.com/{name}\n\ngo 1.21\n") + main_go = "package main\n\nfunc main() {}\n" + if layout == "cmd": + d = pkg / "cmd" / name + d.mkdir(parents=True) + (d / "main.go").write_text(main_go) + elif layout == "cmd-other": + d = pkg / "cmd" / "server" + d.mkdir(parents=True) + (d / "main.go").write_text(main_go) + elif layout == "root": + (pkg / "main.go").write_text(main_go) + (pkg / "capability.yaml").write_text( + f"kind: mcp-server\nname: {name}\nversion: 1.0.0\ndescription: t\n" + "runtimes:\n go: '>=1.21'\nmcp:\n transport: stdio\n" + ) + return pkg + + +class TestBuildTarget: + def test_cmd_name_convention(self, tmp_path): + pkg = _go_package(tmp_path, layout="cmd") + assert _go_build_target(pkg, "slack-mcp") == "./cmd/slack-mcp" + + def test_first_cmd_dir_fallback(self, tmp_path): + pkg = _go_package(tmp_path, layout="cmd-other") + assert _go_build_target(pkg, "slack-mcp") == "./cmd/server" + + def test_root_main_go(self, tmp_path): + pkg = _go_package(tmp_path, layout="root") + assert _go_build_target(pkg, "slack-mcp") == "." + + def test_no_target(self, tmp_path): + pkg = _go_package(tmp_path, layout="none") + assert _go_build_target(pkg, "slack-mcp") is None + + +class TestBuildStep: + def test_build_invokes_go_build_into_bin(self, tmp_path, monkeypatch): + pkg = _go_package(tmp_path, layout="cmd") + calls = [] + + def fake_run(cmd, **kwargs): + calls.append((cmd, kwargs.get("cwd"))) + Path(cmd[cmd.index("-o") + 1]).write_text("binary") + return subprocess.CompletedProcess(cmd, 0, "", "") + + monkeypatch.setattr("capacium.commands.install.shutil.which", + lambda n: "/usr/local/bin/go") + monkeypatch.setattr("capacium.commands.install.subprocess.run", fake_run) + + assert _build_go_binary(pkg, "slack-mcp") == "built" + assert (pkg / "bin" / "slack-mcp").exists() + cmd, cwd = calls[0] + assert cmd[:3] == ["go", "build", "-o"] + assert cmd[-1] == "./cmd/slack-mcp" + assert cwd == pkg + + def test_build_failure_reported(self, tmp_path, monkeypatch): + pkg = _go_package(tmp_path, layout="cmd") + monkeypatch.setattr("capacium.commands.install.shutil.which", + lambda n: "/usr/local/bin/go") + monkeypatch.setattr( + "capacium.commands.install.subprocess.run", + lambda cmd, **kw: subprocess.CompletedProcess(cmd, 1, "", "boom"), + ) + assert _build_go_binary(pkg, "slack-mcp") == "failed" + + def test_missing_toolchain_is_soft(self, tmp_path, monkeypatch): + pkg = _go_package(tmp_path, layout="cmd") + monkeypatch.setattr("capacium.commands.install.shutil.which", lambda n: None) + assert _build_go_binary(pkg, "slack-mcp") == "no-toolchain" + + +class TestEntryUsesBinary: + def test_entry_references_built_binary(self, tmp_path): + pkg = _go_package(tmp_path, layout="cmd") + binary = pkg / "bin" / "slack-mcp" + binary.parent.mkdir() + binary.write_text("bin") + binary.chmod(0o755) + + entry = McpConfigPatcher.build_mcp_entry("slack-mcp", pkg, None) + assert entry["command"] == str(binary.resolve()) + assert "args" not in entry or entry["args"] == [] + + def test_go_run_latest_rewritten_to_binary(self, tmp_path): + pkg = _go_package(tmp_path, layout="cmd") + binary = pkg / "bin" / "slack-mcp" + binary.parent.mkdir() + binary.write_text("bin") + binary.chmod(0o755) + + meta = {"command": "go", + "args": ["run", "github.com/korotovsky/slack-mcp-server@latest"]} + entry = McpConfigPatcher.build_mcp_entry("slack-mcp", pkg, meta) + assert entry["command"] == str(binary.resolve()) + assert all("@latest" not in str(a) for a in entry.get("args", [])) + + def test_go_run_latest_without_binary_runs_local_package(self, tmp_path): + pkg = _go_package(tmp_path, layout="cmd") + meta = {"command": "go", + "args": ["run", "github.com/korotovsky/slack-mcp-server@latest"]} + entry = McpConfigPatcher.build_mcp_entry("slack-mcp", pkg, meta) + assert entry["command"] == "go" + assert all("@latest" not in str(a) for a in entry.get("args", [])) + + +@pytest.mark.skipif(shutil.which("go") is None, reason="go toolchain not on host") +class TestRealGoBuild: + def test_end_to_end_build_and_entry(self, tmp_path): + pkg = _go_package(tmp_path, layout="cmd") + assert _build_go_binary(pkg, "slack-mcp") == "built" + binary = pkg / "bin" / "slack-mcp" + assert binary.exists() + entry = McpConfigPatcher.build_mcp_entry("slack-mcp", pkg, None) + assert entry["command"] == str(binary.resolve()) diff --git a/tests/test_hold.py b/tests/test_hold.py new file mode 100644 index 0000000..1de03af --- /dev/null +++ b/tests/test_hold.py @@ -0,0 +1,140 @@ +"""UP-001 (V8): cap hold — update guard for locally patched packages. + +- cap hold prevents cap update from touching the package +- fingerprint drift (local patch) without hold suppresses the + newer-version force-reinstall and points to 'cap hold' +- unhold releases the guard +""" +import json + +import pytest + +from capacium.commands.hold import ( + get_hold, + hold_capability, + load_holds, + unhold_capability, +) +from capacium.commands.update import update_capability +from capacium.fingerprint import compute_fingerprint +from capacium.models import Capability, Kind +from capacium.registry import Registry + + +@pytest.fixture +def installed_cap(tmp_home): + package_dir = tmp_home / ".capacium" / "packages" / "helallao" / "px" / "1.0.0" + package_dir.mkdir(parents=True) + (package_dir / "capability.yaml").write_text( + "name: px\nversion: 1.0.0\nkind: skill\ndescription: t\n" + ) + (package_dir / "SKILL.md").write_text("# px\n") + fingerprint = compute_fingerprint( + package_dir, + exclude_patterns=[".git", "__pycache__", "*.pyc", ".DS_Store", + ".capacium-meta.json", ".cap-meta.json", + "capability.lock"], + ) + registry = Registry() + cap = Capability( + owner="helallao", name="px", version="1.0.0", kind=Kind.SKILL, + install_path=package_dir, fingerprint=fingerprint, + framework="claude-code", frameworks=["claude-code"], + source_url="https://github.com/helallao/px", + ) + assert registry.add_capability(cap) + return {"registry": registry, "cap": cap, "package_dir": package_dir} + + +class TestHoldLifecycle: + def test_hold_set_and_persisted(self, tmp_home, installed_cap): + assert hold_capability("helallao/px", reason="local patch") is True + hold = get_hold("helallao/px") + assert hold is not None + assert hold["reason"] == "local patch" + holds_file = tmp_home / ".capacium" / "holds.json" + assert holds_file.is_file() + assert "helallao/px" in json.loads(holds_file.read_text()) + + def test_hold_records_drift(self, tmp_home, installed_cap, capsys): + (installed_cap["package_dir"] / "SKILL.md").write_text("# patched\n") + assert hold_capability("helallao/px") is True + out = capsys.readouterr().out + assert "drift" in out.lower() or "modifications" in out.lower() + + def test_hold_unknown_cap_fails(self, tmp_home): + assert hold_capability("nobody/nothing") is False + + def test_unhold_releases(self, tmp_home, installed_cap): + hold_capability("helallao/px") + assert unhold_capability("helallao/px") is True + assert get_hold("helallao/px") is None + assert unhold_capability("helallao/px") is False + + def test_load_holds_tolerates_garbage(self, tmp_home): + holds_file = tmp_home / ".capacium" / "holds.json" + holds_file.parent.mkdir(parents=True, exist_ok=True) + holds_file.write_text("{not json") + assert load_holds() == {} + + +class TestUpdateGuard: + def test_update_skips_held_package_with_notice( + self, tmp_home, installed_cap, capsys, monkeypatch + ): + called = [] + monkeypatch.setattr( + "capacium.commands.update._check_for_newer_version", + lambda *a, **kw: called.append(a), + ) + hold_capability("helallao/px", reason="pending upstream PR") + capsys.readouterr() + + ok = update_capability("helallao/px") + out = capsys.readouterr().out + assert ok is True + assert "held" in out + assert "pending upstream PR" in out + assert called == [] # no fetch, no reconcile mutation + + def test_update_with_drift_suppresses_newer_version_fetch( + self, tmp_home, installed_cap, capsys, monkeypatch + ): + called = [] + monkeypatch.setattr( + "capacium.commands.update._check_for_newer_version", + lambda *a, **kw: called.append(a), + ) + (installed_cap["package_dir"] / "SKILL.md").write_text("# patched locally\n") + + ok = update_capability("helallao/px") + out = capsys.readouterr().out + assert ok is True + assert called == [], "drifted package must not be force-reinstalled" + assert "cap hold" in out + + def test_update_without_drift_checks_newer_version( + self, tmp_home, installed_cap, capsys, monkeypatch + ): + called = [] + monkeypatch.setattr( + "capacium.commands.update._check_for_newer_version", + lambda *a, **kw: called.append(a) or False, + ) + ok = update_capability("helallao/px") + assert ok is True + assert len(called) == 1 + + def test_unhold_restores_update_path( + self, tmp_home, installed_cap, capsys, monkeypatch + ): + called = [] + monkeypatch.setattr( + "capacium.commands.update._check_for_newer_version", + lambda *a, **kw: called.append(a) or False, + ) + hold_capability("helallao/px") + unhold_capability("helallao/px") + ok = update_capability("helallao/px") + assert ok is True + assert len(called) == 1 diff --git a/tests/test_integration_phase0.py b/tests/test_integration_phase0.py index 5f20d1b..6670bc6 100644 --- a/tests/test_integration_phase0.py +++ b/tests/test_integration_phase0.py @@ -289,7 +289,7 @@ def exchange_client(self): from capacium_mcp.server import ExchangeClient except ImportError: pytest.skip("capacium_mcp not installed") - return ExchangeClient(base_url="https://api.capacium.xyz") + return ExchangeClient(exchange_url="https://api.capacium.xyz") def test_search_uses_v2_search(self, exchange_client): _captured = {} diff --git a/tests/test_kind_placement.py b/tests/test_kind_placement.py new file mode 100644 index 0000000..6856f48 --- /dev/null +++ b/tests/test_kind_placement.py @@ -0,0 +1,123 @@ +"""STAB-005 (V6): kind-placement contract for filesystem adapters. + +Skills-dir links may only be created for skill-layer kinds +({skill, prompt, template, workflow, tool, resource}). mcp-server installs +and bundle roots must never produce skills-dir links (Antigravity +regression: mempalace/perplexity/slack appeared as skills). +""" +from pathlib import Path + +import pytest + +from capacium.adapters.base import FrameworkAdapter +from capacium.framework_detector import create_framework_symlinks +from capacium.models import SKILL_LAYER_KIND_VALUES + + +class RecordingAdapter(FrameworkAdapter): + """Records which install path the kind dispatch takes.""" + + def __init__(self): + self.calls = [] + + def install_skill(self, cap_name, version, source_dir, owner="global"): + self.calls.append("install_skill") + return True + + def remove_skill(self, cap_name, owner="global"): + self.calls.append("remove_skill") + return True + + def install_mcp_server(self, cap_name, version, source_dir, owner="global"): + self.calls.append("install_mcp_server") + return True + + def remove_mcp_server(self, cap_name, owner="global"): + self.calls.append("remove_mcp_server") + return True + + def capability_exists(self, cap_name): + return False + + +class TestContractSet: + def test_skill_layer_kinds_exact(self): + assert SKILL_LAYER_KIND_VALUES == { + "skill", "prompt", "template", "workflow", "tool", "resource", + } + + +class TestDispatchGate: + @pytest.mark.parametrize("kind", sorted(SKILL_LAYER_KIND_VALUES)) + def test_skill_layer_kinds_reach_install_skill(self, tmp_path, kind): + adapter = RecordingAdapter() + assert adapter.install_capability("c", "1.0.0", tmp_path, kind=kind) is True + assert adapter.calls == ["install_skill"] + + def test_mcp_server_dispatches_to_mcp_install(self, tmp_path): + adapter = RecordingAdapter() + adapter.install_capability("c", "1.0.0", tmp_path, kind="mcp-server") + assert adapter.calls == ["install_mcp_server"] + + @pytest.mark.parametrize("kind", ["bundle", "connector-pack"]) + def test_non_skill_layer_kinds_create_no_links(self, tmp_path, kind): + adapter = RecordingAdapter() + assert adapter.install_capability("c", "1.0.0", tmp_path, kind=kind) is True + assert adapter.calls == [] # neither skill links nor mcp config + + def test_remove_still_cleans_legacy_links(self, tmp_path): + # pre-contract installs may have leaked links — removal stays tolerant + adapter = RecordingAdapter() + adapter.remove_capability("c", kind="bundle") + assert adapter.calls == ["remove_skill"] + + +class TestOmniSymlinkGate: + @pytest.fixture + def skills_dirs(self, tmp_path, monkeypatch): + dirs = { + "claude-code": tmp_path / ".claude" / "skills", + "antigravity": tmp_path / ".gemini" / "antigravity" / "skills", + } + monkeypatch.setattr( + "capacium.framework_detector.FRAMEWORK_SKILLS_DIRS", dirs + ) + return dirs + + def _create(self, package_dir, kind, frameworks): + return create_framework_symlinks( + package_dir=package_dir, + cap_name="some-cap", + owner="acme", + version="1.0.0", + kind=kind, + fingerprint="f" * 64, + frameworks=frameworks, + ) + + def test_mcp_server_kind_creates_zero_links(self, tmp_path, skills_dirs): + package_dir = tmp_path / "pkg" + package_dir.mkdir() + created = self._create(package_dir, "mcp-server", list(skills_dirs)) + assert created == [] + for d in skills_dirs.values(): + assert not (d / "some-cap").exists() + + def test_bundle_kind_creates_zero_links(self, tmp_path, skills_dirs): + package_dir = tmp_path / "pkg" + package_dir.mkdir() + created = self._create(package_dir, "bundle", list(skills_dirs)) + assert created == [] + for d in skills_dirs.values(): + assert not (d / "some-cap").exists() + + def test_skill_kind_still_creates_links(self, tmp_path, skills_dirs): + package_dir = tmp_path / "pkg" + package_dir.mkdir() + (package_dir / "SKILL.md").write_text("# some-cap\n") + created = self._create(package_dir, "skill", list(skills_dirs)) + assert sorted(created) == sorted(skills_dirs) + for d in skills_dirs.values(): + link = d / "some-cap" + assert link.exists() + assert Path(link).is_symlink() or link.is_dir() diff --git a/tests/test_mcp_adapters.py b/tests/test_mcp_adapters.py index 0392564..8dcf1ac 100644 --- a/tests/test_mcp_adapters.py +++ b/tests/test_mcp_adapters.py @@ -464,12 +464,12 @@ class TestCursorAdapterMcp: `mcpServers` JSON map. """ - def test_install_writes_global_mcp_when_no_project_dir(self, tmp_path): + def test_install_writes_global_mcp_when_no_project_dir(self, tmp_path, monkeypatch): from capacium.adapters.cursor import CursorAdapter + # V7: without an explicit project root the global config is used. + monkeypatch.delenv("CAPACIUM_PROJECT_ROOT", raising=False) adapter = CursorAdapter() - # Force a clean state — neither project nor global cursor dirs exist yet. - adapter.project_mcp_path = tmp_path / "no-cwd-project" / ".cursor" / "mcp.json" adapter.global_mcp_path = tmp_path / "global" / ".cursor" / "mcp.json" source = tmp_path / "source" @@ -488,11 +488,11 @@ def test_install_writes_global_mcp_when_no_project_dir(self, tmp_path): assert "cursor-test" in data["mcpServers"] assert data["mcpServers"]["cursor-test"]["command"] == "uvx" - def test_remove_clears_global_mcp(self, tmp_path): + def test_remove_clears_global_mcp(self, tmp_path, monkeypatch): from capacium.adapters.cursor import CursorAdapter + monkeypatch.delenv("CAPACIUM_PROJECT_ROOT", raising=False) adapter = CursorAdapter() - adapter.project_mcp_path = tmp_path / "ne" / ".cursor" / "mcp.json" adapter.global_mcp_path = tmp_path / "global" / ".cursor" / "mcp.json" adapter.global_mcp_path.parent.mkdir(parents=True, exist_ok=True) adapter.global_mcp_path.write_text( @@ -503,13 +503,11 @@ def test_remove_clears_global_mcp(self, tmp_path): data = json.loads(adapter.global_mcp_path.read_text()) assert "x" not in data.get("mcpServers", {}) - def test_capability_exists_checks_both_rules_and_mcp(self, tmp_path): + def test_capability_exists_checks_both_rules_and_mcp(self, tmp_path, monkeypatch): from capacium.adapters.cursor import CursorAdapter + monkeypatch.delenv("CAPACIUM_PROJECT_ROOT", raising=False) adapter = CursorAdapter() - adapter.project_rules_dir = tmp_path / "no-rules" - adapter.global_rules_dir = tmp_path / "global-rules" - adapter.project_mcp_path = tmp_path / "ne" / ".cursor" / "mcp.json" adapter.global_mcp_path = tmp_path / "global" / ".cursor" / "mcp.json" # Neither registered yet diff --git a/tests/test_project_scope.py b/tests/test_project_scope.py new file mode 100644 index 0000000..c974768 --- /dev/null +++ b/tests/test_project_scope.py @@ -0,0 +1,114 @@ +"""STAB-006 (V7): explicit cwd scope for project-local adapters. + +Cursor (project-scoped client) writes only with an explicit project root +(--project / CAPACIUM_PROJECT_ROOT) — never implicitly into Path.cwd(). +Regressions: mcp.bak files in package directories (cwd/.cursor probing), +skill links inside foreign repos (cwd-based skills-dir map). +""" +import json +from pathlib import Path + +import pytest + +from capacium.adapters.cursor import CursorAdapter +from capacium.framework_detector import framework_skills_dirs +from capacium.utils.project_scope import get_project_root, set_project_root + + +@pytest.fixture(autouse=True) +def _no_project_root(monkeypatch): + monkeypatch.delenv("CAPACIUM_PROJECT_ROOT", raising=False) + + +def _mcp_package(tmp_home, with_cursor_dir=False): + pkg = tmp_home / ".capacium" / "packages" / "acme" / "scoped-mcp" / "1.0.0" + pkg.mkdir(parents=True) + (pkg / "capability.yaml").write_text( + "kind: mcp-server\nname: scoped-mcp\nversion: 1.0.0\ndescription: t\n" + "mcp:\n command: python3\n args: ['srv.py']\n" + ) + if with_cursor_dir: + (pkg / ".cursor").mkdir() + return pkg + + +class TestCursorWithoutProject: + def test_mcp_install_goes_global_not_cwd(self, tmp_home, monkeypatch, tmp_path): + workdir = tmp_path / "some-random-cwd" + workdir.mkdir() + monkeypatch.chdir(workdir) + pkg = _mcp_package(tmp_home) + + adapter = CursorAdapter() + assert adapter.install_mcp_server("scoped-mcp", "1.0.0", pkg) is True + + assert not (workdir / ".cursor").exists(), "no implicit cwd writes" + global_config = tmp_home / ".cursor" / "mcp.json" + assert global_config.exists() + assert "scoped-mcp" in json.loads(global_config.read_text())["mcpServers"] + + def test_no_mcp_bak_in_package_dirs(self, tmp_home, monkeypatch): + """Regression: cwd/.cursor probing wrote configs+baks into package + directories whenever cwd happened to be one.""" + pkg = _mcp_package(tmp_home, with_cursor_dir=True) + monkeypatch.chdir(pkg) + + adapter = CursorAdapter() + assert adapter.install_mcp_server("scoped-mcp", "1.0.0", pkg) is True + + assert not (pkg / ".cursor" / "mcp.json").exists() + assert list(pkg.rglob("*.bak")) == [] + assert (tmp_home / ".cursor" / "mcp.json").exists() + + def test_skill_install_skips_link_with_notice(self, tmp_home, monkeypatch, + tmp_path, capsys): + workdir = tmp_path / "elsewhere" + workdir.mkdir() + monkeypatch.chdir(workdir) + src = tmp_home / ".capacium" / "packages" / "acme" / "sk" / "1.0.0" + src.mkdir(parents=True) + (src / "SKILL.md").write_text("# sk\n") + + adapter = CursorAdapter() + assert adapter.install_skill("sk", "1.0.0", src) is True + out = capsys.readouterr().out + assert "--project" in out + assert not (workdir / ".cursor").exists() + + +class TestCursorWithProject: + def test_writes_into_explicit_project(self, tmp_home, tmp_path, monkeypatch): + project = tmp_path / "my-project" + project.mkdir() + monkeypatch.setenv("CAPACIUM_PROJECT_ROOT", str(project)) + pkg = _mcp_package(tmp_home) + + adapter = CursorAdapter() + assert adapter.install_mcp_server("scoped-mcp", "1.0.0", pkg) is True + config = json.loads((project / ".cursor" / "mcp.json").read_text()) + assert "scoped-mcp" in config["mcpServers"] + + src = tmp_home / ".capacium" / "packages" / "acme" / "sk" / "1.0.0" + src.mkdir(parents=True) + (src / "SKILL.md").write_text("# sk\n") + assert adapter.install_skill("sk", "1.0.0", src) is True + assert (project / ".cursor" / "skills" / "sk").is_symlink() + + def test_set_project_root_helper(self, tmp_path): + root = set_project_root(tmp_path) + assert get_project_root() == root + + +class TestSkillsDirsMap: + def test_cursor_absent_without_project_root(self, tmp_home): + dirs = framework_skills_dirs() + assert "cursor" not in dirs + + def test_cursor_present_with_project_root(self, tmp_home, tmp_path, monkeypatch): + monkeypatch.setenv("CAPACIUM_PROJECT_ROOT", str(tmp_path)) + dirs = framework_skills_dirs() + assert dirs["cursor"] == Path(tmp_path) / ".cursor" / "skills" + + def test_opencode_is_global_not_cwd(self, tmp_home): + dirs = framework_skills_dirs() + assert dirs["opencode"] == tmp_home / ".opencode" / "skills" diff --git a/tests/test_remove_transactional.py b/tests/test_remove_transactional.py new file mode 100644 index 0000000..957478f --- /dev/null +++ b/tests/test_remove_transactional.py @@ -0,0 +1,147 @@ +"""STAB-002 (V14): transactional cap remove with rollback journal. + +Remove collects all steps (adapter configs, links, package, registry) as a +plan and executes with a rollback journal: + - error injection at adapter step N → state fully restored + - registry row is gone exactly iff all adapter steps succeeded + - missing client dirs (e.g. no ~/.opencode) are a skip, never a crash +""" +import json + +import pytest + +from capacium.commands.remove import remove_capability +from capacium.models import Capability, Kind +from capacium.registry import Registry + + +@pytest.fixture +def installed_skill(tmp_home): + """A registered skill with package dir + skills-dir links + registry row.""" + package_dir = tmp_home / ".capacium" / "packages" / "acme" / "tx-skill" / "1.0.0" + package_dir.mkdir(parents=True) + (package_dir / "capability.yaml").write_text( + "name: tx-skill\nversion: 1.0.0\nkind: skill\ndescription: t\n" + ) + (package_dir / "SKILL.md").write_text("# tx-skill\n") + + links = [] + for skills_dir in (tmp_home / ".claude" / "skills", + tmp_home / ".opencode" / "skills"): + skills_dir.mkdir(parents=True) + link = skills_dir / "tx-skill" + link.symlink_to(package_dir) + links.append(link) + + registry = Registry() + cap = Capability( + owner="acme", name="tx-skill", version="1.0.0", kind=Kind.SKILL, + install_path=package_dir, fingerprint="f" * 64, + framework="claude-code", frameworks=["claude-code", "opencode"], + ) + assert registry.add_capability(cap) + return { + "registry": registry, + "cap": cap, + "package_dir": package_dir, + "links": links, + } + + +@pytest.fixture +def installed_mcp(tmp_home): + """A registered mcp-server with a claude-desktop config entry.""" + package_dir = tmp_home / ".capacium" / "packages" / "acme" / "tx-mcp" / "1.0.0" + package_dir.mkdir(parents=True) + (package_dir / "capability.yaml").write_text( + "name: tx-mcp\nversion: 1.0.0\nkind: mcp-server\ndescription: t\n" + "mcp:\n command: python3\n args: ['srv.py']\n" + ) + # Resolve the platform-correct config path the adapter actually uses + # (macOS: ~/Library/Application Support/Claude; Linux: ~/.config/Claude). + from capacium.adapters.claude_desktop import ClaudeDesktopAdapter + config_path = ClaudeDesktopAdapter._resolve_config_path() + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps( + {"mcpServers": {"tx-mcp": {"command": "python3", "args": ["srv.py"]}, + "other-server": {"command": "x"}}} + )) + + registry = Registry() + cap = Capability( + owner="acme", name="tx-mcp", version="1.0.0", kind=Kind.MCP_SERVER, + install_path=package_dir, fingerprint="f" * 64, + framework="claude-desktop", frameworks=["claude-desktop"], + ) + assert registry.add_capability(cap) + return { + "registry": registry, + "cap": cap, + "package_dir": package_dir, + "config_path": config_path, + } + + +class TestHappyPath: + def test_remove_clears_row_links_package(self, tmp_home, installed_skill): + assert remove_capability("acme/tx-skill", force=False) is True + assert installed_skill["registry"].get_capability("acme/tx-skill") is None + assert not installed_skill["package_dir"].exists() + for link in installed_skill["links"]: + assert not link.exists() and not link.is_symlink() + + def test_remove_without_opencode_dir_no_crash(self, tmp_home, installed_skill): + import shutil + shutil.rmtree(tmp_home / ".opencode") + assert remove_capability("acme/tx-skill", force=False) is True + assert installed_skill["registry"].get_capability("acme/tx-skill") is None + + +class TestErrorInjection: + def test_adapter_failure_restores_full_state( + self, tmp_home, installed_skill, monkeypatch + ): + """Injected failure at the second adapter step → everything restored.""" + from capacium.adapters.opencode import OpenCodeAdapter + + def boom(self, *a, **kw): + raise RuntimeError("injected adapter failure") + + monkeypatch.setattr(OpenCodeAdapter, "remove_capability", boom) + + assert remove_capability("acme/tx-skill", force=False) is False + + # registry row untouched + assert installed_skill["registry"].get_capability("acme/tx-skill") is not None + # package dir untouched + assert installed_skill["package_dir"].exists() + # links restored — including the one the first adapter already removed + for link in installed_skill["links"]: + assert link.is_symlink(), f"link not restored: {link}" + assert link.resolve() == installed_skill["package_dir"].resolve() + + def test_mcp_config_restored_on_failure( + self, tmp_home, installed_mcp, monkeypatch + ): + """Config entry already removed must be restored byte-identically.""" + before = installed_mcp["config_path"].read_text() + + import capacium.registry as registry_mod + + def boom(self, *a, **kw): + raise RuntimeError("injected registry failure") + + monkeypatch.setattr(registry_mod.Registry, "remove_capability", boom) + + assert remove_capability("acme/tx-mcp", force=False) is False + + assert installed_mcp["config_path"].read_text() == before + assert installed_mcp["package_dir"].exists() + + def test_registry_row_gone_iff_adapters_succeeded(self, tmp_home, installed_mcp): + """No injection: row removed, config entry removed, other entries kept.""" + assert remove_capability("acme/tx-mcp", force=False) is True + assert installed_mcp["registry"].get_capability("acme/tx-mcp") is None + config = json.loads(installed_mcp["config_path"].read_text()) + assert "tx-mcp" not in config.get("mcpServers", {}) + assert "other-server" in config.get("mcpServers", {}) diff --git a/tests/test_runtime_gate.py b/tests/test_runtime_gate.py new file mode 100644 index 0000000..313f9d4 --- /dev/null +++ b/tests/test_runtime_gate.py @@ -0,0 +1,177 @@ +"""STAB-003 (V5): runtime validation before MCP config writes. + +The config builder must validate manifest runtimes against the +RuntimeResolver BEFORE any client config write. An unmet runtime aborts the +install with an install hint and writes no entry. Go projects must never be +auto-detected as npx (2026-06-10 regression: korotovsky/karldane class). +""" +import json + +import pytest + +from capacium.adapters.mcp_config_patcher import ( + McpConfigPatcher, + RuntimeUnavailableError, +) +from capacium.runtimes import RUNTIMES, RuntimeStatus + + +class FakeResolver: + """Host-independent resolver: `available` maps runtime name -> version.""" + + def __init__(self, available=None): + self.available = available or {} + + def resolve(self, requirements): + statuses = [] + for name, req in requirements.items(): + rt = RUNTIMES.get(name) + version = self.available.get(name) + found = version is not None + statuses.append( + RuntimeStatus( + name=name, + requirement=req or "*", + runtime=rt, + found=found, + version=version, + satisfied=found, + install_hint=rt.install_hint_for("darwin") if rt else None, + ) + ) + return statuses + + +@pytest.fixture(autouse=True) +def _enforce_gate(monkeypatch): + """The suite-wide conftest disables the gate; these tests need it live.""" + monkeypatch.delenv("CAPACIUM_SKIP_RUNTIME_CHECK", raising=False) + McpConfigPatcher.clear_runtime_status_cache() + + +def _go_project(tmp_path, with_package_json=False, declare_runtime=True): + pkg = tmp_path / "go-server" + pkg.mkdir() + (pkg / "go.mod").write_text("module example.com/go-server\n\ngo 1.22\n") + (pkg / "main.go").write_text("package main\nfunc main() {}\n") + if with_package_json: + # Some Go repos ship a package.json for docs tooling — it must not win. + (pkg / "package.json").write_text('{"name": "docs-tooling"}') + runtimes = "runtimes:\n go: '>=1.21'\n" if declare_runtime else "" + (pkg / "capability.yaml").write_text( + "kind: mcp-server\nname: go-server\nversion: 1.0.0\n" + "description: go fixture\n" + runtimes + ) + return pkg + + +class TestGoNeverNpx: + def test_autodetect_prefers_go_over_package_json(self, tmp_path): + pkg = _go_project(tmp_path, with_package_json=True) + entry = McpConfigPatcher.build_mcp_entry( + "go-server", pkg, None, resolver=FakeResolver({"go": "1.22.1"}) + ) + assert entry["command"] == "go" + assert entry["command"] not in ("npx", "node") + + def test_declared_go_runtime_never_npx(self, tmp_path): + pkg = _go_project(tmp_path, with_package_json=True) + entry = McpConfigPatcher.build_mcp_entry( + "go-server", pkg, None, resolver=FakeResolver({"go": "1.22.1"}) + ) + assert entry["command"] == "go" + + +class TestRuntimeGateBlocksWrites: + def test_missing_go_runtime_aborts_before_config_write(self, tmp_path): + pkg = _go_project(tmp_path) + config_path = tmp_path / "client" / "config.json" + with pytest.raises(RuntimeUnavailableError) as exc_info: + McpConfigPatcher.inject_json_mcp_server( + config_path=config_path, + server_key="go-server", + mcp_section_key="mcpServers", + cap_name="go-server", + source_dir=pkg, + mcp_meta=None, + resolver=FakeResolver({}), # go missing + ) + # Zero bytes written outside: no config file, no backup + assert not config_path.exists() + assert not config_path.parent.exists() + # The error carries the platform install hint (brew on darwin) + assert "go" in str(exc_info.value) + assert "brew install go" in str(exc_info.value) + + def test_missing_runtime_leaves_existing_config_untouched(self, tmp_path): + pkg = _go_project(tmp_path) + config_path = tmp_path / "config.json" + before = json.dumps({"mcpServers": {"other": {"command": "x"}}}) + config_path.write_text(before) + with pytest.raises(RuntimeUnavailableError): + McpConfigPatcher.inject_json_mcp_server( + config_path=config_path, + server_key="go-server", + mcp_section_key="mcpServers", + cap_name="go-server", + source_dir=pkg, + mcp_meta=None, + resolver=FakeResolver({}), + ) + assert config_path.read_text() == before + # no backup files created either + assert list(config_path.parent.glob("*.bak")) == [] + + def test_satisfied_runtime_writes_entry(self, tmp_path): + pkg = _go_project(tmp_path) + config_path = tmp_path / "config.json" + ok = McpConfigPatcher.inject_json_mcp_server( + config_path=config_path, + server_key="go-server", + mcp_section_key="mcpServers", + cap_name="go-server", + source_dir=pkg, + mcp_meta=None, + resolver=FakeResolver({"go": "1.22.1"}), + ) + assert ok is True + config = json.loads(config_path.read_text()) + assert "go-server" in config["mcpServers"] + assert config["mcpServers"]["go-server"]["command"] == "go" + + def test_mcp_meta_command_runtime_is_validated(self, tmp_path): + pkg = tmp_path / "py-server" + pkg.mkdir() + with pytest.raises(RuntimeUnavailableError): + McpConfigPatcher.build_mcp_entry( + "py-server", + pkg, + {"command": "uvx", "args": ["py-server"]}, + resolver=FakeResolver({}), # uv missing + ) + + def test_url_transport_skips_runtime_gate(self, tmp_path): + pkg = tmp_path / "remote" + pkg.mkdir() + entry = McpConfigPatcher.build_mcp_entry( + "remote", + pkg, + {"transport": "sse", "url": "http://localhost:9999/x"}, + resolver=FakeResolver({}), + ) + assert entry["url"] == "http://localhost:9999/x" + + def test_env_var_skips_gate(self, tmp_path, monkeypatch): + monkeypatch.setenv("CAPACIUM_SKIP_RUNTIME_CHECK", "1") + pkg = _go_project(tmp_path) + entry = McpConfigPatcher.build_mcp_entry( + "go-server", pkg, None, resolver=FakeResolver({}) + ) + assert entry["command"] == "go" + + def test_opencode_entry_is_gated(self, tmp_path): + pkg = _go_project(tmp_path) + with pytest.raises(RuntimeUnavailableError): + McpConfigPatcher.build_opencode_mcp_entry( + "go-server", pkg, None, resolver=FakeResolver({}) + ) diff --git a/tests/test_submit.py b/tests/test_submit.py new file mode 100644 index 0000000..3f02210 --- /dev/null +++ b/tests/test_submit.py @@ -0,0 +1,140 @@ +"""STAB-008 (V12): cap submit response parsing. + +The Exchange /v2/submit endpoint is queue-based: it returns 202 with +{job_id, github_url, canonical_hint, status} and the result must be polled +via GET /v2/submit/{job_id}. The old CLI expected a synchronous schema +(canonical_name/kind/trust_state) and printed 'unknown' for everything +(lum1104 case, 2026-06-11). +""" + + +from capacium.commands.submit import submit_repository + + +# Recorded real-world shapes (lum1104/understand-anything, 2026-06-11): +QUEUE_ACCEPT_RESPONSE = { + "job_id": "5a1d6c9e-1111-2222-3333-444455556666", + "github_url": "https://github.com/Lum1104/Understand-Anything", + "canonical_hint": "Lum1104/Understand-Anything", + "status": "pending", +} + +JOB_COMPLETED = { + "github_url": "https://github.com/Lum1104/Understand-Anything", + "status": "completed", + "created_at": "2026-06-11T14:02:11.000000", + "canonical_name": "lum1104/understand-anything", + "error": None, +} + +LISTING_DETAIL = { + "canonical_name": "lum1104/understand-anything", + "kind": "skill", + "trust_state": "pending_review", + "license": "MIT", +} + + +class FakeClient: + def __init__(self, submit_response, job_states=None, detail=None, + detail_error=None): + self._submit_response = submit_response + self._job_states = list(job_states or []) + self._detail = detail + self._detail_error = detail_error + self.submit_calls = [] + self.status_calls = [] + + def submit(self, github_url, registry_url=None): + self.submit_calls.append(github_url) + return self._submit_response + + def submit_status(self, job_id, registry_url=None): + self.status_calls.append(job_id) + if self._job_states: + return self._job_states.pop(0) + return {"status": "processing"} + + def get_detail(self, name, registry_url=None): + if self._detail_error: + raise self._detail_error + return self._detail or {} + + +class TestQueueResponse: + def test_lum1104_recorded_response_shows_real_values(self, capsys): + client = FakeClient( + QUEUE_ACCEPT_RESPONSE, + job_states=[{"status": "processing"}, JOB_COMPLETED], + detail=LISTING_DETAIL, + ) + ok = submit_repository( + "https://github.com/Lum1104/Understand-Anything", + client=client, poll_interval=0, + ) + out = capsys.readouterr().out + assert ok is True + assert "lum1104/understand-anything" in out + assert "skill" in out + assert "pending_review" in out + assert "https://capacium.xyz/listings/lum1104/understand-anything" in out + assert "unknown" not in out + + def test_failed_job_shows_error(self, capsys): + client = FakeClient( + QUEUE_ACCEPT_RESPONSE, + job_states=[{ + "status": "failed", + "error": "No capability.yaml or SKILL.md with YAML frontmatter found", + }], + ) + ok = submit_repository("https://github.com/x/y", client=client, + poll_interval=0) + out = capsys.readouterr().out + assert ok is False + assert "No capability.yaml" in out + + def test_timeout_reports_job_id_for_later(self, capsys): + client = FakeClient(QUEUE_ACCEPT_RESPONSE, job_states=[]) + ok = submit_repository("https://github.com/x/y", client=client, + poll_interval=0, wait_timeout=0.01) + out = capsys.readouterr().out + assert ok is True + assert QUEUE_ACCEPT_RESPONSE["job_id"] in out + + def test_detail_lookup_failure_degrades_gracefully(self, capsys): + client = FakeClient( + QUEUE_ACCEPT_RESPONSE, + job_states=[JOB_COMPLETED], + detail_error=RuntimeError("listing endpoint down"), + ) + ok = submit_repository("https://github.com/x/y", client=client, + poll_interval=0) + out = capsys.readouterr().out + assert ok is True + assert "lum1104/understand-anything" in out + + +class TestLegacyAndDriftSchemas: + def test_legacy_sync_schema_still_parses(self, capsys): + client = FakeClient({ + "canonical_name": "acme/old-cap", + "kind": "tool", + "trust_state": "verified", + }) + ok = submit_repository("https://github.com/acme/old-cap", client=client) + out = capsys.readouterr().out + assert ok is True + assert "acme/old-cap" in out + assert "tool" in out + assert "verified" in out + + def test_unknown_schema_shows_raw_response_with_warning(self, capsys): + drifted = {"result": {"something": "entirely-different"}, "v": 3} + client = FakeClient(drifted) + ok = submit_repository("https://github.com/x/y", client=client) + out = capsys.readouterr().out + assert ok is True + assert "Warning" in out or "warning" in out + assert "entirely-different" in out # raw response visible + assert "unknown" not in out.replace("unknown response schema", "")