diff --git a/CHANGELOG.md b/CHANGELOG.md index fbc6d24..c29a777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ All notable changes to this project will be documented in this file. +## Capacium v0.11.0 — Phase 2: Capacium v2 Redesign (2026-05-24) + +### New Features + +- **`cap export-mcp`** (CAP-008): Export capability manifests as MCP server + descriptors. Generates standardized `serverInfo`, `capabilities/tools`, + and `transport` sections from `capability.yaml`. + +- **`cap export-a2a`** (CAP-008): Export capability manifests as A2A agent + cards. Generates `skills`, `provider`, and `capabilities` sections for + Google A2A protocol compatibility. + +- **`cap adapt`** (CAP-011): Framework adaptation layer with pluggable + registry. Adapts capability manifests to target frameworks (mcp-server, + a2a-agent, claude-desktop) using capability-aware transformation pipelines. + +- **Standards Exporters** (CAP-008): New `capacium.exporters` package with + `MCPExporter` and `A2AExporter`. Abstract `BaseExporter` supports + `export()`, `can_export()`, and `export_json()` methods. 16 tests. + +- **Adaptation Registry** (CAP-011): New `capacium.adaptation` package with + `AdaptationRegistry` (3 built-in targets) and `CapabilityAdapter` for + framework-agnostic capability transformation. 38 tests. + +- **Manifest triggers field** (CAP-003): New `triggers:` section in + `capability.yaml` for event-driven capability activation patterns. + +- **Manifest pricing field** (CAP-004): New `pricing:` section in + `capability.yaml` supporting free/freemium/paid models with tier + definitions and usage limits. + +- **Resource Kind 5-layer schema** (CAP-002): Progressive disclosure schema + for resources — from simple key-value to full conditional evaluation with + `ConditionEvaluator`. + +- **Broad resource support**: Resource kind detection, condition evaluation, + and 5-layer progressive resource schema integrated into CLI. + ## Capacium v1.0.0-dev — Phase 1 (2026-05-11) ### Deprecations diff --git a/README.md b/README.md index 4c559b7..d707a67 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,10 @@ Works fully offline from local paths. The Exchange layer (separate repo) adds re You can install Capacium globally in an isolated environment using `pipx`. ```bash -pipx install git+https://github.com/Capacium/capacium.git@v0.10.25 +pipx install git+https://github.com/Capacium/capacium.git@v0.11.0 # Or with optional signing and YAML support: -pipx install "capacium[yaml,signing] @ git+https://github.com/Capacium/capacium.git@v0.10.25" +pipx install "capacium[yaml,signing] @ git+https://github.com/Capacium/capacium.git@v0.11.0" ``` *(Note: PyPI publishing `pip install capacium` is pending organization approval and currently unavailable).* @@ -35,7 +35,7 @@ If you don't use Python, you can download standalone executables directly from t ### 3. Docker (GHCR) Run Capacium safely in a container with your directories mounted: ```bash -docker run --rm -v ~/.capacium:/root/.capacium -v $(pwd):/workspace ghcr.io/capacium/cap:0.10.25 +docker run --rm -v ~/.capacium:/root/.capacium -v $(pwd):/workspace ghcr.io/capacium/cap:0.11.0 ``` ### 4. macOS / Linux (Homebrew) diff --git a/pyproject.toml b/pyproject.toml index 95ec792..40b7fd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "capacium" -version = "0.10.25" +version = "0.11.0" description = "Capability Packaging System — agent-agnostic packaging for AI agent capabilities" readme = "README.md" authors = [ diff --git a/src/capacium/adaptation/__init__.py b/src/capacium/adaptation/__init__.py new file mode 100644 index 0000000..863a50a --- /dev/null +++ b/src/capacium/adaptation/__init__.py @@ -0,0 +1,4 @@ +from .adapter import CapabilityAdapter +from .registry import AdaptationRegistry + +__all__ = ["CapabilityAdapter", "AdaptationRegistry"] diff --git a/src/capacium/adaptation/adapter.py b/src/capacium/adaptation/adapter.py new file mode 100644 index 0000000..fbccf6a --- /dev/null +++ b/src/capacium/adaptation/adapter.py @@ -0,0 +1,121 @@ +"""Capability Adapter — transforms capabilities between framework formats. + +Uses exporters for format conversion and the adaptation registry +for target-specific configuration. +""" +from typing import Any, Dict, List, Optional + +from ..manifest import Manifest +from ..exporters import MCPExporter, A2AExporter +from .registry import AdaptationRegistry, AdaptationTarget + + +class AdaptationError(Exception): + """Raised when adaptation fails.""" + pass + + +class CapabilityAdapter: + """Adapts capabilities to target framework formats.""" + + def __init__(self): + self._registry = AdaptationRegistry() + self._exporters = { + "mcp-server": MCPExporter(), + "a2a-agent": A2AExporter(), + } + + @property + def registry(self) -> AdaptationRegistry: + return self._registry + + def adapt( + self, + manifest: Manifest, + target: str, + options: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Adapt a manifest to target framework format. + + Args: + manifest: Source capability manifest + target: Target framework name (e.g. "mcp-server", "a2a-agent") + options: Optional adaptation options (transport, etc.) + + Returns: + Adapted output as dict + + Raises: + AdaptationError: If adaptation not possible + """ + target_info = self._registry.get(target) + if target_info is None: + available = ", ".join(self._registry.list_targets()) + raise AdaptationError( + f"Unknown adaptation target '{target}'. Available: {available}" + ) + + # Check if we have an exporter for this target + exporter = self._exporters.get(target) + if exporter is not None: + if not exporter.can_export(manifest): + raise AdaptationError( + f"Cannot adapt manifest kind '{manifest.kind}' to '{target}'" + ) + result = exporter.export(manifest) + else: + # Generic adaptation via target info + result = self._generic_adapt(manifest, target_info) + + # Apply target-specific options + if options: + result = self._apply_options(result, target_info, options) + + return result + + def can_adapt(self, manifest: Manifest, target: str) -> bool: + """Check if a manifest can be adapted to the target.""" + target_info = self._registry.get(target) + if target_info is None: + return False + exporter = self._exporters.get(target) + if exporter is not None: + return exporter.can_export(manifest) + return True # Generic adaptation always possible + + def list_targets(self, manifest: Optional[Manifest] = None) -> List[str]: + """List available adaptation targets, optionally filtered by manifest compatibility.""" + if manifest is None: + return self._registry.list_targets() + return [t for t in self._registry.list_targets() if self.can_adapt(manifest, t)] + + def _generic_adapt(self, manifest: Manifest, target: AdaptationTarget) -> Dict[str, Any]: + """Generic adaptation when no specific exporter exists.""" + result: Dict[str, Any] = { + "name": manifest.name, + "version": manifest.version, + "description": manifest.description, + "kind": manifest.kind, + "adapted_from": "capacium", + "target": target.name, + } + if target.supports_tools and manifest.capabilities: + result["tools"] = [ + {"name": c.get("name", ""), "description": c.get("description", "")} + for c in manifest.capabilities + ] + if manifest.runtimes: + result["runtime"] = manifest.runtimes + return result + + def _apply_options( + self, result: Dict[str, Any], target: AdaptationTarget, options: Dict[str, Any] + ) -> Dict[str, Any]: + """Apply adaptation options to result.""" + if target.requires_transport and "transport" in options: + result["transport"] = options["transport"] + if "command" in options: + result["command"] = options["command"] + if "args" in options: + result["args"] = options["args"] + return result diff --git a/src/capacium/adaptation/registry.py b/src/capacium/adaptation/registry.py new file mode 100644 index 0000000..953b9a3 --- /dev/null +++ b/src/capacium/adaptation/registry.py @@ -0,0 +1,58 @@ +"""Registry of supported adaptation targets. + +Each target describes a framework + what kind of output it needs. +""" +from dataclasses import dataclass +from typing import Dict, List, Optional + + +@dataclass +class AdaptationTarget: + """Describes a target framework for adaptation.""" + name: str # e.g. "mcp-server", "a2a-agent", "claude-desktop" + description: str = "" + output_format: str = "json" # json, yaml, toml + requires_transport: bool = False # needs transport config (e.g. MCP) + supports_tools: bool = True + supports_resources: bool = True + supports_prompts: bool = False + + +class AdaptationRegistry: + """Registry of known adaptation targets.""" + + def __init__(self): + self._targets: Dict[str, AdaptationTarget] = {} + self._register_defaults() + + def _register_defaults(self): + """Register built-in adaptation targets.""" + self.register(AdaptationTarget( + name="mcp-server", + description="Model Context Protocol server descriptor", + requires_transport=True, + supports_prompts=True, + )) + self.register(AdaptationTarget( + name="a2a-agent", + description="Google A2A agent card", + supports_prompts=False, + )) + self.register(AdaptationTarget( + name="claude-desktop", + description="Claude Desktop MCP config entry", + output_format="json", + requires_transport=True, + )) + + def register(self, target: AdaptationTarget) -> None: + self._targets[target.name] = target + + def get(self, name: str) -> Optional[AdaptationTarget]: + return self._targets.get(name) + + def list_targets(self) -> List[str]: + return list(self._targets.keys()) + + def all(self) -> List[AdaptationTarget]: + return list(self._targets.values()) diff --git a/src/capacium/adapters/opencode.py b/src/capacium/adapters/opencode.py index 5444c52..56662bc 100644 --- a/src/capacium/adapters/opencode.py +++ b/src/capacium/adapters/opencode.py @@ -26,20 +26,23 @@ def __init__(self): self.storage = StorageManager() self.symlink_manager = SymlinkManager() self.opencode_skills_dir = Path.home() / ".opencode" / "skills" + self.commands_dir = Path.home() / ".config" / "opencode" / "commands" self.opencode_skills_dir.mkdir(parents=True, exist_ok=True) + self.commands_dir.mkdir(parents=True, exist_ok=True) 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.opencode_skills_dir / _cap_id(cap_name, owner) - success = self.symlink_manager.create_symlink(package_dir, link_path) + skill_success = self.symlink_manager.create_symlink(package_dir, link_path) + command_success = self._create_command_link(cap_name, package_dir) metadata = self._extract_capability_metadata(package_dir) metadata_path = package_dir / ".capacium-meta.json" with open(metadata_path, "w") as f: json.dump(metadata, f, indent=2) - return success + return skill_success and command_success def remove_skill(self, cap_name: str, owner: str = "global") -> bool: link_path = self.opencode_skills_dir / _cap_id(cap_name, owner) @@ -50,12 +53,20 @@ def remove_skill(self, cap_name: str, owner: str = "global") -> bool: shutil.rmtree(link_path) else: link_path.unlink() + command_path = self.commands_dir / f"{cap_name}.md" + if command_path.exists() or command_path.is_symlink(): + if command_path.is_symlink(): + self.symlink_manager.remove_symlink(command_path) + else: + command_path.unlink() return True def capability_exists(self, cap_name: str, owner: str = "global") -> bool: link_path = self.opencode_skills_dir / _cap_id(cap_name, owner) if link_path.exists() and link_path.is_symlink(): return True + if (self.commands_dir / f"{cap_name}.md").exists(): + return True config_path = Path.home() / ".config" / "opencode" / "opencode.json" server_key = McpConfigPatcher.build_server_key(cap_name, owner) @@ -124,6 +135,13 @@ def get_capability_metadata(self, cap_name: str) -> Optional[Dict[str, Any]]: return json.load(f) return None + def _create_command_link(self, cap_name: str, package_dir: Path) -> bool: + command_source = package_dir / "SKILL.md" + if not command_source.exists(): + return True + command_path = self.commands_dir / f"{cap_name}.md" + return self.symlink_manager.create_symlink(command_source, command_path) + def _extract_capability_metadata(self, cap_dir: Path) -> Dict[str, Any]: metadata = { "name": cap_dir.parent.name, diff --git a/src/capacium/cli.py b/src/capacium/cli.py index b343ff7..35590ae 100644 --- a/src/capacium/cli.py +++ b/src/capacium/cli.py @@ -130,7 +130,7 @@ def main(): init_parser.add_argument("--name", help="Capability name (kebab-case)") init_parser.add_argument( "--kind", - help="Capability kind (skill, tool, prompt, mcp-server, template, bundle, workflow, connector-pack)", + help="Capability kind (skill, tool, prompt, mcp-server, template, bundle, workflow, connector-pack, resource)", ) init_parser.add_argument("--version", help="Capability version (semver, e.g. 0.1.0)") init_parser.add_argument("--description", help="Capability description") @@ -144,8 +144,8 @@ def main(): ) init_parser.add_argument( "--template", - choices=["skill", "mcp-server", "bundle"], - help="Scaffold from template: skill | mcp-server | bundle. Creates capability.yaml + SKILL.md + README.md.", + choices=["skill", "mcp-server", "bundle", "resource"], + help="Scaffold from template: skill | mcp-server | bundle | resource. Creates capability.yaml + SKILL.md + README.md.", ) init_parser.add_argument( "--force", @@ -286,6 +286,64 @@ def main(): sign_parser.add_argument("capability", help="Capability specification (owner/name[@version])") sign_parser.add_argument("key_name", help="Name of the signing key") + # CAP-008: Standards export commands + export_mcp_parser = subparsers.add_parser( + "export-mcp", + help="Export capability manifest to MCP server descriptor format", + ) + export_mcp_parser.add_argument( + "target", + help="Path to capability.yaml or directory containing one", + ) + + export_a2a_parser = subparsers.add_parser( + "export-a2a", + help="Export capability manifest to A2A agent card format", + ) + export_a2a_parser.add_argument( + "target", + help="Path to capability.yaml or directory containing one", + ) + + # CAP-011: Framework adaptation + adapt_parser = subparsers.add_parser( + "adapt", + help="Adapt capability to target framework format", + ) + adapt_parser.add_argument( + "target", + nargs="?", + help="Target framework (mcp-server, a2a-agent, claude-desktop)", + ) + adapt_parser.add_argument( + "path", + nargs="?", + default=".", + help="Path to manifest or directory (default: current directory)", + ) + adapt_parser.add_argument( + "--transport", + default=None, + help="Transport type for MCP targets (default: stdio)", + ) + adapt_parser.add_argument( + "--command", + dest="adapt_command", + default=None, + help="Command to run the capability", + ) + adapt_parser.add_argument( + "--args", + dest="adapt_args", + default=None, + help="Arguments for the command (comma-separated)", + ) + adapt_parser.add_argument( + "--list-targets", + action="store_true", + help="List available adaptation targets", + ) + subparsers.add_parser("version", help="Print Capacium version") mcp_parser = subparsers.add_parser("mcp", help="Capacium MCP server for AI agents") @@ -676,6 +734,68 @@ def main(): success = sign_capability(args.capability, args.key_name) sys.exit(0 if success else 1) + elif args.command == "adapt": + if args.list_targets: + from .adaptation import CapabilityAdapter + adapter = CapabilityAdapter() + for t in adapter.registry.all(): + print(f" {t.name:20s} {t.description}") + sys.exit(0) + if not args.target: + print("Error: target is required (use --list-targets to see options)") + sys.exit(1) + from .adaptation import CapabilityAdapter + from .manifest import Manifest + path = Path(args.path) + if path.is_dir(): + manifest = Manifest.detect_from_directory(path) + elif path.is_file(): + manifest = Manifest.load(path) + else: + print(f"Error: {path} not found") + sys.exit(1) + adapter = CapabilityAdapter() + options = {} + if args.transport: + options["transport"] = args.transport + if getattr(args, "adapt_command", None): + options["command"] = args.adapt_command + if getattr(args, "adapt_args", None): + options["args"] = [a.strip() for a in args.adapt_args.split(",")] + try: + result = adapter.adapt(manifest, args.target, options if options else None) + import json + print(json.dumps(result, indent=2)) + sys.exit(0) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + elif args.command in ("export-mcp", "export-a2a"): + from .manifest import Manifest + target = Path(args.target) + if target.is_dir(): + manifest = Manifest.detect_from_directory(target) + elif target.is_file(): + manifest = Manifest.load(target) + else: + print(f"Error: {target} not found") + sys.exit(1) + + if args.command == "export-mcp": + from .exporters import MCPExporter + exporter = MCPExporter() + else: + from .exporters import A2AExporter + exporter = A2AExporter() + + if not exporter.can_export(manifest): + print(f"Error: manifest kind '{manifest.kind}' cannot be exported to {exporter.format_name}") + sys.exit(1) + + print(exporter.export_json(manifest)) + sys.exit(0) + elif args.command == "version": print(f"cap {__version__}") sys.exit(0) diff --git a/src/capacium/commands/_resolve.py b/src/capacium/commands/_resolve.py index 4b77399..f102196 100644 --- a/src/capacium/commands/_resolve.py +++ b/src/capacium/commands/_resolve.py @@ -62,11 +62,14 @@ def _resolve_owner_locally(cap_name: str) -> Optional[str]: return None -def _resolve_owner_via_search(cap_name: str) -> Optional[str]: +def _resolve_owner_via_search( + cap_name: str, + registry_url: Optional[str] = None, +) -> Optional[str]: """Resolve a bare capability name to an owner via Exchange search.""" client = RegistryClient() try: - results = client.search(cap_name, limit=20) + results = client.search(cap_name, registry_url=registry_url, limit=20) except (RegistryClientError, Exception): return None @@ -120,7 +123,7 @@ def resolve_capability_info( canonical = cap_spec.split("@")[0].strip() if "/" not in canonical: # Try to resolve owner first - owner_guess = _resolve_owner_via_search(canonical) + owner_guess = _resolve_owner_via_search(canonical, registry_url=registry_url) if not owner_guess: return None canonical = f"{owner_guess}/{canonical}" diff --git a/src/capacium/commands/init.py b/src/capacium/commands/init.py index f81cfa2..7a50cd6 100644 --- a/src/capacium/commands/init.py +++ b/src/capacium/commands/init.py @@ -6,7 +6,7 @@ from ..utils.config import save_user_config, load_user_config, get_config_dir from ..manifest import Manifest -VALID_TEMPLATES = {"skill", "mcp-server", "bundle"} +VALID_TEMPLATES = {"skill", "mcp-server", "bundle", "resource"} VALID_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$") VALID_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$") @@ -19,6 +19,7 @@ "bundle", "workflow", "connector-pack", + "resource", } @@ -241,7 +242,7 @@ def init_skill() -> bool: manifest.name = _prompt_required("Capability name (kebab-case)", "my-capability") - print("\n Available kinds: skill, bundle, tool, prompt, template, workflow, mcp-server, connector-pack") + print("\n Available kinds: skill, bundle, tool, prompt, template, workflow, mcp-server, connector-pack, resource") default_kind = "skill" manifest.kind = _prompt_with_default("Kind", default_kind).strip() or default_kind @@ -469,6 +470,13 @@ def _write_skill_md(path: Path, name: str, kind: str, version: str, description: "## Installation\n\n" "```bash\ncap install {name}\n```\n" ).format(name=name) + elif kind == "resource": + body = ( + "## Contents\n\n" + "\n\n" + "## Usage\n\n" + "\n" + ) else: body = ( "## Usage\n\n" diff --git a/src/capacium/commands/install.py b/src/capacium/commands/install.py index 743f752..80af4fe 100644 --- a/src/capacium/commands/install.py +++ b/src/capacium/commands/install.py @@ -34,6 +34,7 @@ def install_capability( from_tarball: Optional[str] = None, yes: bool = False, github_token: Optional[str] = None, + registry_url: Optional[str] = None, ) -> bool: # BUG-009: Auto-detect capability name from tarball manifest autodetected_name = None @@ -45,6 +46,17 @@ def install_capability( return False cap_spec = autodetected_name + if source_dir is not None and (not cap_spec or cap_spec.strip() == ""): + source_raw = str(source_dir) + if not (_is_git_remote_url(source_raw) or _GITHUB_SHORT_RE.match(source_raw)): + source_manifest = Manifest.detect_from_directory(source_dir) + if source_manifest.name: + cap_spec = ( + f"{source_manifest.owner}/{source_manifest.name}" + if source_manifest.owner + else source_manifest.name + ) + spec = VersionManager.parse_version_spec(cap_spec) owner = spec["owner"] cap_name = spec["skill"] @@ -54,7 +66,7 @@ def install_capability( # 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: from ._resolve import _resolve_owner_via_search - resolved = _resolve_owner_via_search(cap_name) + resolved = _resolve_owner_via_search(cap_name, registry_url=registry_url) if resolved is not None: owner = resolved # Fallthrough: owner stays as-is ("global") if registry unreachable or no match @@ -176,6 +188,7 @@ def install_capability( version_spec=version_spec, storage=storage, github_token=github_token, + registry_url=registry_url, ) if source_dir is None: # Fallback to current directory @@ -199,6 +212,10 @@ def install_capability( return False source_manifest = Manifest.detect_from_directory(source_dir) + if not cap_name and source_manifest.name: + cap_name = source_manifest.name + owner = source_manifest.owner or owner or "global" + cap_id = f"{owner}/{cap_name}" if not skip_runtime_check: interactive_rt = _is_interactive() and not yes @@ -265,6 +282,9 @@ def install_capability( else: fingerprint = compute_fingerprint(package_dir, exclude_patterns=[".git", "__pycache__", "*.pyc", ".DS_Store", ".capacium-meta.json", ".cap-meta.json", "capability.lock"]) + if force: + _remove_superseded_versions(registry, cap_name, owner, version) + first_fw = resolved_frameworks[0] if resolved_frameworks else "opencode" if not source_url: source_url = source_manifest.repository or _detect_git_remote(source_dir) @@ -282,7 +302,8 @@ def install_capability( source_url=source_url, ) - registry.add_capability(cap) + if not registry.add_capability(cap): + registry.update_capability(cap) if all_frameworks: kind_str = cap.kind.value @@ -352,7 +373,8 @@ def _install_bundle_members( continue _install_single_sub_cap( - sub_name, sub_version, source_path, owner, registry, storage, no_lock + sub_name, sub_version, source_path, owner, registry, storage, no_lock, + force=force, ) sub_cap = registry.get_capability(sub_cap_id, sub_version) @@ -372,6 +394,7 @@ def _install_single_sub_cap( registry: Registry, storage: StorageManager, no_lock: bool, + force: bool = False, ) -> None: package_dir = storage.get_package_dir(sub_name, version, owner=owner) if package_dir.exists(): @@ -416,10 +439,36 @@ def _install_single_sub_cap( source_url=source_url, ) - registry.add_capability(capacity) + if force: + _remove_superseded_versions(registry, sub_name, owner, version) + + if not registry.add_capability(capacity): + registry.update_capability(capacity) StorageManager.write_meta(capacity) +def _remove_superseded_versions( + registry: Registry, + cap_name: str, + owner: str, + keep_version: str, +) -> None: + """Remove registry/package entries for older versions on force installs.""" + cap_id = f"{owner}/{cap_name}" + for existing in list(registry.list_capabilities()): + if ( + existing.owner != owner + or existing.name != cap_name + or existing.version == keep_version + ): + continue + old_id = f"{cap_id}@{existing.version}" + registry.remove_bundle_references(old_id) + registry.remove_capability(cap_id, existing.version) + if existing.install_path and existing.install_path.exists(): + shutil.rmtree(existing.install_path, ignore_errors=True) + + def _resolve_source_path(source_raw: str, bundle_dir: Path) -> Path: p = Path(source_raw) if p.is_absolute(): @@ -662,6 +711,7 @@ def _fetch_from_registry( version_spec: str, storage: StorageManager, github_token: Optional[str] = None, + registry_url: Optional[str] = None, ) -> tuple[Optional[Path], Optional[str]]: """Fetch a capability from the configured Exchange registry. @@ -675,7 +725,7 @@ def _fetch_from_registry( client = RegistryClient() try: - remote = client.get_detail(f"{owner}/{cap_name}") + remote = client.get_detail(f"{owner}/{cap_name}", registry_url=registry_url) except Exception as e: msg = str(e).lower() if "404" in msg or "not found" in msg: @@ -719,6 +769,8 @@ def _fetch_from_registry( return None, None manifest = Manifest.detect_from_directory(repo_dir) + if best_version in ("", "0.0.0", "latest", "stable") and manifest.version: + best_version = manifest.version if not manifest.name or (manifest.name == repo_dir.name and manifest.version == "1.0.0"): registry_meta = { "name": remote.name, @@ -748,15 +800,20 @@ def _clone_registry_repo(repo_url: str, version: str, github_token: Optional[str """ import tempfile - tag = version if version.startswith("v") else f"v{version}" + tag = "" if version in ("", "0.0.0", "latest", "stable") else ( + version if version.startswith("v") else f"v{version}" + ) tmp_dir = Path(tempfile.mkdtemp(prefix="cap-registry-")) clone_url = repo_url if github_token and "github.com" in repo_url: clone_url = repo_url.replace("https://github.com/", f"https://{github_token}@github.com/") - clone_args = ["git", "clone", "--depth=1", "--branch", tag, clone_url, str(tmp_dir / "repo")] + clone_args = ["git", "clone", "--depth=1"] + if tag: + clone_args.extend(["--branch", tag]) + clone_args.extend([clone_url, str(tmp_dir / "repo")]) display_url = repo_url.replace("https://", "https://***@") if github_token and "github.com" in repo_url else repo_url - print(f" Fetching {display_url}@{version}...") + print(f" Fetching {display_url}" + (f"@{version}" if tag else "@HEAD") + "...") try: result = subprocess.run(clone_args, capture_output=True, text=True, timeout=60) if result.returncode != 0: @@ -770,7 +827,7 @@ def _clone_registry_repo(repo_url: str, version: str, github_token: Optional[str capture_output=True, text=True, timeout=10, cwd=str(repo_dir), ) - if tag not in (tag_result.stdout or "").splitlines(): + if tag and tag not in (tag_result.stdout or "").splitlines(): shutil.rmtree(tmp_dir, ignore_errors=True) print(f" Tag {tag} not found in repository.") print(f" Available tags: {', '.join((tag_result.stdout or '').splitlines()[:5])}") diff --git a/src/capacium/commands/publish.py b/src/capacium/commands/publish.py index bd48454..37a068c 100644 --- a/src/capacium/commands/publish.py +++ b/src/capacium/commands/publish.py @@ -54,8 +54,11 @@ def publish_capability( "version": manifest.version, "kind": manifest.kind, "description": manifest.description or "", + "repo_url": manifest.repository or manifest.homepage or "", "frameworks": frameworks, "dependencies": manifest.dependencies or {}, + "replaces": manifest.replaces or [], + "previous_identities": manifest.previous_identities or [], } canonical = f"{owner}/{manifest.name}" @@ -122,12 +125,22 @@ def _display_quality_score( print(" Quality score: pending (score computed within ~5 min)") return - quality_score: float = data.get("quality_score") or 0.0 + quality_score_raw = data.get("quality_score") or 0.0 + try: + quality_score = float(quality_score_raw) + except (TypeError, ValueError): + quality_score = 0.0 trust_state: str = data.get("trust_state", "discovered") has_skill_md: bool = bool(data.get("skill_md_content") or data.get("has_skill_md")) source_url: str = data.get("canonical_source_url") or "" - install_count: int = data.get("install_count") or 0 - github_stars: int = data.get("github_stars") or 0 + try: + install_count = int(data.get("install_count") or 0) + except (TypeError, ValueError): + install_count = 0 + try: + github_stars = int(data.get("github_stars") or 0) + except (TypeError, ValueError): + github_stars = 0 description: str = data.get("short_description") or manifest.description or "" # ── Factor estimates (client-side, server score takes precedence) ────── diff --git a/src/capacium/commands/search.py b/src/capacium/commands/search.py index 1deb8c1..2287161 100644 --- a/src/capacium/commands/search.py +++ b/src/capacium/commands/search.py @@ -324,7 +324,7 @@ def _local_search(query: str, kind: Optional[str], trust: Optional[str], def _remote_format(raw: Dict[str, Any], json_output: bool) -> None: - listings = raw.get("listings", []) + listings = raw.get("listings", raw.get("results", [])) if json_output: total = raw.get("total", len(listings)) sort_val = raw.get("sort", "relevance") @@ -350,7 +350,7 @@ def _remote_format(raw: Dict[str, Any], json_output: bool) -> None: d.setdefault("source_url", d.get("source_url", d.get("repository", ""))) d.setdefault("id", d.get("id", d.get("canonical_name", f"{d['owner']}/{d['name']}"))) result_dicts.append(d) - print(_search_results_json(result_dicts, total, raw.get("query", ""), sort_val)) + print(_search_results_json(result_dicts, total, raw.get("query", raw.get("search", "")), sort_val)) return if not listings: diff --git a/src/capacium/commands/sign.py b/src/capacium/commands/sign.py index 4710e62..ab742ca 100644 --- a/src/capacium/commands/sign.py +++ b/src/capacium/commands/sign.py @@ -39,19 +39,14 @@ def sign_capability(cap_spec: str, key_name: str) -> bool: print(f" Sub-capability {member_id} not found in registry.") return False sub_fingerprints.append(member_cap.fingerprint) - _ = compute_bundle_fingerprint(sub_fingerprints) + fingerprint = compute_bundle_fingerprint(sub_fingerprints) else: - _ = compute_fingerprint( + fingerprint = compute_fingerprint( cap.install_path, exclude_patterns=[".git", "__pycache__", "*.pyc", ".DS_Store", ".capacium-meta.json", ".cap-meta.json", "capability.lock"] ) - # P0-004: Exchange expects signature over "|" - # (same format used by the admin /sign endpoint for consistency) - canonical_name = f"{cap.owner}/{cap.name}" - version = cap.version or "0.0.0" - message = f"{canonical_name}|{version}".encode("utf-8") - sig_bytes = sign(privkey, message) + sig_bytes = sign(privkey, fingerprint.encode("utf-8")) sig_b64 = base64.b64encode(sig_bytes).decode("ascii") # Store locally @@ -67,12 +62,18 @@ def sign_capability(cap_spec: str, key_name: str) -> bool: try: from ..registry_client import RegistryClient + # P0-004: Exchange expects signature over "|" + # (same format used by the admin /sign endpoint for consistency). + canonical_name = f"{cap.owner}/{cap.name}" + version = cap.version or "0.0.0" + exchange_message = f"{canonical_name}|{version}".encode("utf-8") + exchange_sig_b64 = base64.b64encode(sign(privkey, exchange_message)).decode("ascii") client = RegistryClient.from_config() result = client.publisher_sign( owner=cap.owner, name=cap.name, public_key_pem=pub_pem, - signature_b64=sig_b64, + signature_b64=exchange_sig_b64, key_name=key_name, ) trust_state = result.get("trust_state", "unknown") diff --git a/src/capacium/commands/update.py b/src/capacium/commands/update.py index 0c47a26..b719436 100644 --- a/src/capacium/commands/update.py +++ b/src/capacium/commands/update.py @@ -81,9 +81,7 @@ def _check_for_newer_version(cap_id: str, current_version: str, source_url: Opti if _parse_version(latest) > _parse_version(current_version): print(f" Newer version {latest} found via remote tags.") print(f" Installing {cap_id}@{latest}...") - registry = Registry() - registry.remove_capability(cap_id, version=current_version) - return install_capability(f"{cap_id}@{latest}", yes=True) + return install_capability(f"{cap_id}@{latest}", force=True, yes=True) client = RegistryClient() try: @@ -101,9 +99,7 @@ def _check_for_newer_version(cap_id: str, current_version: str, source_url: Opti print(f" Newer version {latest.version} found in registry.") print(f" Installing {cap_id}@{latest.version}...") - registry = Registry() - registry.remove_capability(cap_id, version=current_version) - return install_capability(f"{cap_id}@{latest.version}", yes=True) + return install_capability(f"{cap_id}@{latest.version}", force=True, yes=True) def update_capability( diff --git a/src/capacium/conditions.py b/src/capacium/conditions.py new file mode 100644 index 0000000..3b6769a --- /dev/null +++ b/src/capacium/conditions.py @@ -0,0 +1,265 @@ +"""Condition expression evaluator for capability.yaml conditions. + +Supports expressions like: + - "runtime.python >= 3.10" + - "os == linux" + - "env.OPENAI_API_KEY exists" + - "trust_state >= verified" + - "kind == mcp-server AND runtime.node exists" +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + + +@dataclass +class ConditionResult: + """Result of evaluating a single condition expression.""" + + passed: bool + expression: str + reason: str = "" + + +# Trust-state ordering for comparison operators +_TRUST_ORDER = { + "discovered": 0, + "audited": 1, + "verified": 2, + "signed": 3, +} + +_COMPARISON_OPS = {"==", "!=", ">=", "<=", ">", "<"} +_UNARY_OPS = {"exists", "not-exists"} +_CONNECTORS = {"AND", "OR"} + +# Regex matching: [] +# Handles: "runtime.python >= 3.10", "os == linux", "env.KEY exists" +_EXPR_RE = re.compile( + r"^\s*" + r"(?P[A-Za-z_][A-Za-z0-9_./-]*)" + r"\s+" + r"(?P==|!=|>=|<=|>|<|exists|not-exists)" + r"(?:\s+(?P.+?))?" + r"\s*$" +) + + +class ConditionEvaluator: + """Evaluates condition expressions against a context dict.""" + + def __init__(self, context: Optional[Dict[str, Any]] = None): + """Initialize with evaluation context. + + Context example:: + + { + "runtime": {"python": "3.11", "node": "20.0"}, + "os": "linux", + "env": {"OPENAI_API_KEY": "set", "HOME": "/home/user"}, + "trust_state": "verified", + "kind": "skill", + } + """ + self.context = context or {} + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def evaluate(self, expression: str) -> ConditionResult: + """Evaluate a single condition expression. + + Supported operators: ==, !=, >=, <=, >, <, exists, not-exists + Supported connectors: AND, OR (no nested parens needed for v1) + """ + expression = expression.strip() + if not expression: + return ConditionResult( + passed=False, expression=expression, reason="empty expression" + ) + + # Split on AND / OR connectors + # We tokenise by splitting on ' AND ' or ' OR ', preserving the connector. + parts = re.split(r"\s+(AND|OR)\s+", expression) + + if len(parts) == 1: + return self._evaluate_single(expression) + + # parts is [expr, connector, expr, connector, expr, ...] + results: List[ConditionResult] = [] + connectors: List[str] = [] + + for i, part in enumerate(parts): + if i % 2 == 0: + results.append(self._evaluate_single(part)) + else: + connectors.append(part) + + # Evaluate left-to-right (flat precedence) + combined = results[0].passed + for j, conn in enumerate(connectors): + next_val = results[j + 1].passed + if conn == "AND": + combined = combined and next_val + else: # OR + combined = combined or next_val + + reasons = [r.reason for r in results if r.reason] + return ConditionResult( + passed=combined, + expression=expression, + reason="; ".join(reasons) if not combined else "", + ) + + def evaluate_all(self, expressions: list[str]) -> list[ConditionResult]: + """Evaluate multiple expressions. All must pass (AND logic).""" + return [self.evaluate(expr) for expr in expressions] + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _evaluate_single(self, expression: str) -> ConditionResult: + """Evaluate a single atomic expression (no connectors).""" + match = _EXPR_RE.match(expression) + if not match: + return ConditionResult( + passed=False, + expression=expression, + reason=f"invalid expression syntax: '{expression}'", + ) + + path = match.group("path") + op = match.group("op") + value = match.group("value") + + # Unary operators + if op in _UNARY_OPS: + resolved = self._resolve_value(path) + found = resolved is not None + if op == "exists": + return ConditionResult( + passed=found, + expression=expression, + reason="" if found else f"'{path}' not found in context", + ) + else: # not-exists + return ConditionResult( + passed=not found, + expression=expression, + reason="" if not found else f"'{path}' exists in context", + ) + + # Comparison operators need a right-hand value + if value is None: + return ConditionResult( + passed=False, + expression=expression, + reason=f"operator '{op}' requires a comparison value", + ) + + resolved = self._resolve_value(path) + if resolved is None: + return ConditionResult( + passed=False, + expression=expression, + reason=f"'{path}' not found in context", + ) + + try: + result = self._compare(resolved, op, value) + except (ValueError, TypeError) as exc: + return ConditionResult( + passed=False, + expression=expression, + reason=f"comparison failed: {exc}", + ) + + return ConditionResult( + passed=result, + expression=expression, + reason="" if result else f"'{path}' ({resolved}) {op} {value} is false", + ) + + def _resolve_value(self, path: str) -> Any: + """Resolve a dotted path in the context. + + ``"runtime.python"`` -> ``context["runtime"]["python"]`` + """ + parts = path.split(".") + current: Any = self.context + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + def _compare(self, left: Any, operator: str, right: str) -> bool: + """Compare two values with the given operator. + + Handles version comparison for semver-like strings and trust-state + ordering. + """ + left_str = str(left) + + # Trust-state comparison + if left_str in _TRUST_ORDER and right in _TRUST_ORDER: + left_val = _TRUST_ORDER[left_str] + right_val = _TRUST_ORDER[right] + return _apply_numeric_op(left_val, operator, right_val) + + # Equality / inequality work on plain strings first + if operator == "==": + return left_str == right + if operator == "!=": + return left_str != right + + # Numeric / version comparison for ordering operators + left_num = _to_version_tuple(left_str) + right_num = _to_version_tuple(right) + + if left_num is not None and right_num is not None: + return _apply_numeric_op(left_num, operator, right_num) + + # Fall back to string comparison + return _apply_numeric_op(left_str, operator, right) + + +# ------------------------------------------------------------------ +# Module-level helpers +# ------------------------------------------------------------------ + + +def _to_version_tuple(value: str) -> Optional[tuple[int, ...]]: + """Try to parse a string as a version/numeric tuple. + + ``"3.10"`` -> ``(3, 10)`` + ``"20"`` -> ``(20,)`` + ``"abc"`` -> ``None`` + """ + try: + return tuple(int(p) for p in value.split(".")) + except (ValueError, AttributeError): + return None + + +def _apply_numeric_op(left: Any, operator: str, right: Any) -> bool: + """Apply a comparison operator to two ordered values.""" + if operator == ">=": + return left >= right + if operator == "<=": + return left <= right + if operator == ">": + return left > right + if operator == "<": + return left < right + if operator == "==": + return left == right + if operator == "!=": + return left != right + raise ValueError(f"unsupported operator: {operator}") diff --git a/src/capacium/exporters/__init__.py b/src/capacium/exporters/__init__.py new file mode 100644 index 0000000..c6cf622 --- /dev/null +++ b/src/capacium/exporters/__init__.py @@ -0,0 +1,4 @@ +from .mcp_exporter import MCPExporter +from .a2a_exporter import A2AExporter + +__all__ = ["MCPExporter", "A2AExporter"] diff --git a/src/capacium/exporters/a2a_exporter.py b/src/capacium/exporters/a2a_exporter.py new file mode 100644 index 0000000..637f036 --- /dev/null +++ b/src/capacium/exporters/a2a_exporter.py @@ -0,0 +1,57 @@ +"""Export capability manifest to A2A Agent Card format. + +A2A (Agent-to-Agent, Google) agent card includes: +- name, description, version +- skills: [{id, name, description}] +- capabilities: streaming, pushNotifications +- url, provider +""" + +from typing import Any, Dict + +from .base import BaseExporter +from ..manifest import Manifest + + +class A2AExporter(BaseExporter): + """Export a capability manifest to A2A agent card format.""" + + @property + def format_name(self) -> str: + return "a2a-agent-card" + + def can_export(self, manifest: Manifest) -> bool: + return manifest.kind in ("skill", "mcp-server", "bundle") + + def export(self, manifest: Manifest) -> Dict[str, Any]: + card: Dict[str, Any] = { + "name": manifest.name, + "description": manifest.description, + "version": manifest.version, + "url": manifest.homepage or manifest.repository, + "provider": { + "organization": manifest.owner or manifest.author, + }, + "skills": [], + "capabilities": { + "streaming": False, + "pushNotifications": False, + }, + } + + # Skills from capabilities or self + if manifest.capabilities: + for cap in manifest.capabilities: + card["skills"].append({ + "id": cap.get("name", ""), + "name": cap.get("name", ""), + "description": cap.get("description", manifest.description), + }) + else: + card["skills"].append({ + "id": manifest.name, + "name": manifest.name, + "description": manifest.description, + }) + + return card diff --git a/src/capacium/exporters/base.py b/src/capacium/exporters/base.py new file mode 100644 index 0000000..abf6ec3 --- /dev/null +++ b/src/capacium/exporters/base.py @@ -0,0 +1,31 @@ +"""Base exporter interface.""" + +from abc import ABC, abstractmethod +from typing import Any, Dict + +from ..manifest import Manifest + + +class BaseExporter(ABC): + """Base class for all format exporters.""" + + @property + @abstractmethod + def format_name(self) -> str: + """Human-readable format name.""" + pass + + @abstractmethod + def export(self, manifest: Manifest) -> Dict[str, Any]: + """Convert a Manifest to the target format dict.""" + pass + + @abstractmethod + def can_export(self, manifest: Manifest) -> bool: + """Check if this manifest can be exported to target format.""" + pass + + def export_json(self, manifest: Manifest) -> str: + """Export as JSON string.""" + import json + return json.dumps(self.export(manifest), indent=2) diff --git a/src/capacium/exporters/mcp_exporter.py b/src/capacium/exporters/mcp_exporter.py new file mode 100644 index 0000000..2901b71 --- /dev/null +++ b/src/capacium/exporters/mcp_exporter.py @@ -0,0 +1,52 @@ +"""Export capability manifest to MCP Server format. + +MCP server descriptor includes: +- serverInfo: name, version +- capabilities: tools, resources, prompts +- transport: stdio | sse | streamable-http +""" + +from typing import Any, Dict + +from .base import BaseExporter +from ..manifest import Manifest + + +class MCPExporter(BaseExporter): + """Export a capability manifest to MCP server descriptor format.""" + + @property + def format_name(self) -> str: + return "mcp-server" + + def can_export(self, manifest: Manifest) -> bool: + return manifest.kind in ("skill", "mcp-server", "resource") + + def export(self, manifest: Manifest) -> Dict[str, Any]: + result: Dict[str, Any] = { + "serverInfo": { + "name": manifest.name, + "version": manifest.version, + }, + "capabilities": {}, + "transport": "stdio", + } + + # Add MCP-specific fields if present + if manifest.mcp: + result["transport"] = manifest.mcp.get("transport", "stdio") + if "clients" in manifest.mcp: + result["supportedClients"] = manifest.mcp["clients"] + + # Add tools from capabilities + if manifest.capabilities: + result["capabilities"]["tools"] = [ + {"name": c.get("name", ""), "description": c.get("description", "")} + for c in manifest.capabilities + ] + + # Add runtime info + if manifest.runtimes: + result["runtime"] = manifest.runtimes + + return result diff --git a/src/capacium/framework_detector.py b/src/capacium/framework_detector.py index eee1cb2..4e9a3bb 100644 --- a/src/capacium/framework_detector.py +++ b/src/capacium/framework_detector.py @@ -160,7 +160,7 @@ def resolve_frameworks( return found return [fw] if all_frameworks: - detected = sorted(detect_active_frameworks()) + detected = sorted(set(detect_active_frameworks()) | set(manifest_frameworks or [])) if not detected: return ["opencode"] if _framework_supports_kind("opencode", kind) else [] return _filter_frameworks_by_kind(detected, kind) or ( diff --git a/src/capacium/manifest.py b/src/capacium/manifest.py index 88f6877..50abbab 100644 --- a/src/capacium/manifest.py +++ b/src/capacium/manifest.py @@ -1,7 +1,7 @@ import json from dataclasses import dataclass, field, asdict from pathlib import Path -from typing import Dict, List, Any +from typing import Dict, List, Any, Optional MANIFEST_FILENAME = "capability.yaml" @@ -23,10 +23,20 @@ class Manifest: frameworks: List[str] = field(default_factory=list) dependencies: Dict[str, str] = field(default_factory=dict) runtimes: Dict[str, str] = field(default_factory=dict) + replaces: List[str] = field(default_factory=list) + previous_identities: List[Dict[str, str]] = field(default_factory=list) capabilities: List[Dict[str, str]] = field(default_factory=list) checksums: Dict[str, str] = field(default_factory=dict) mcp: Dict[str, Any] = field(default_factory=dict) entrypoint: str = "" + triggers: List[Dict[str, Any]] = field(default_factory=list) + pricing: Optional[Dict[str, Any]] = None + # Resource-specific (only relevant when kind=resource) + resource_type: Optional[str] = None + resource_format: Optional[str] = None + size_hint: Optional[str] = None + access: Optional[Dict[str, Any]] = None + compatibility: Optional[Dict[str, Any]] = None @property def id(self) -> str: @@ -49,6 +59,56 @@ def validate(self) -> List[str]: else: if "transport" not in self.mcp: errors.append("mcp section: missing required 'transport' field (stdio, sse, or streamable-http)") + if self.kind == "resource": + if not self.description: + errors.append("Resource manifest requires a description") + _VALID_RESOURCE_TYPES = { + "prompt-library", "dataset", "config-template", + "model-weights", "tool-index", "embedding", + } + if self.resource_type and self.resource_type not in _VALID_RESOURCE_TYPES: + errors.append(f"Invalid resource_type: {self.resource_type}") + _VALID_FORMATS = {"yaml", "json", "csv", "parquet", "binary", "directory"} + if self.resource_format and self.resource_format not in _VALID_FORMATS: + errors.append(f"Invalid resource format: {self.resource_format}") + _VALID_SIZES = {"small", "medium", "large"} + if self.size_hint and self.size_hint not in _VALID_SIZES: + errors.append(f"Invalid size_hint: {self.size_hint}") + # Resources don't need entry points or MCP config + # Validate triggers + _VALID_TRIGGER_EVENTS = { + "file-changed", "schedule", "webhook", "manual", "on-install", "on-update", + } + if self.triggers: + for i, trigger in enumerate(self.triggers): + if "event" not in trigger: + errors.append(f"triggers[{i}]: missing required 'event' field") + if "action" not in trigger: + errors.append(f"triggers[{i}]: missing required 'action' field") + event = trigger.get("event") + if event and event not in _VALID_TRIGGER_EVENTS: + errors.append( + f"triggers[{i}]: invalid event '{event}'; " + f"must be one of {sorted(_VALID_TRIGGER_EVENTS)}" + ) + # Validate pricing + _VALID_PRICING_MODELS = {"free", "freemium", "paid", "usage-based", "donation"} + if self.pricing is not None: + if "model" not in self.pricing: + errors.append("pricing: missing required 'model' field") + else: + model = self.pricing["model"] + if model not in _VALID_PRICING_MODELS: + errors.append( + f"pricing: invalid model '{model}'; " + f"must be one of {sorted(_VALID_PRICING_MODELS)}" + ) + if model == "paid": + price = self.pricing.get("price_usd") + if price is None: + errors.append("pricing: 'paid' model requires 'price_usd' field") + elif not isinstance(price, (int, float)) or price <= 0: + errors.append("pricing: 'price_usd' must be a number greater than 0") return errors def get_mcp_metadata(self) -> Dict[str, Any]: diff --git a/src/capacium/models.py b/src/capacium/models.py index 4c4d408..027e3d2 100644 --- a/src/capacium/models.py +++ b/src/capacium/models.py @@ -43,6 +43,7 @@ class Kind(Enum): WORKFLOW = "workflow" MCP_SERVER = "mcp-server" CONNECTOR_PACK = "connector-pack" + RESOURCE = "resource" diff --git a/src/capacium/registry.py b/src/capacium/registry.py index d8332b4..415226f 100644 --- a/src/capacium/registry.py +++ b/src/capacium/registry.py @@ -360,6 +360,15 @@ def remove_bundle_members(self, bundle_id: str) -> None: ) conn.commit() + def remove_bundle_references(self, capability_ref: str) -> None: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "DELETE FROM bundle_members WHERE bundle_id = ? OR member_id = ?", + (capability_ref, capability_ref), + ) + conn.commit() + def get_reference_count(self, member_id: str) -> int: with self._get_connection() as conn: cursor = conn.cursor() diff --git a/src/capacium/registry_client.py b/src/capacium/registry_client.py index 248971a..07abd37 100644 --- a/src/capacium/registry_client.py +++ b/src/capacium/registry_client.py @@ -184,7 +184,11 @@ def search( limit=limit, min_stars=min_stars, ) - listings = raw.get("listings", []) if isinstance(raw, dict) else raw + listings = ( + raw.get("listings", raw.get("results", [])) + if isinstance(raw, dict) + else raw + ) if isinstance(listings, dict): listings = listings.get("listings", []) results = [] @@ -215,7 +219,7 @@ def search_raw( url = self._build_registry_url("/v2/listings", registry_url) params = [] if query: - params.append(f"q={urllib.parse.quote(query)}") + params.append(f"search={urllib.parse.quote(query)}") if kind: params.append(f"kind={urllib.parse.quote(kind)}") if framework: diff --git a/src/capacium/taxonomy.py b/src/capacium/taxonomy.py index dd3faa4..e7958e3 100644 --- a/src/capacium/taxonomy.py +++ b/src/capacium/taxonomy.py @@ -48,6 +48,7 @@ "Vector Databases": "Vector embedding storage and search", "Knowledge Bases": "Structured knowledge management", "Data Processing": "ETL and data transformation", + "Resources": "Data assets, configs, and reference material for capabilities", }, }, "Security & Trust": { @@ -133,6 +134,7 @@ "skill": "AI & Agents/Agent Skills", "tool": "Developer Tools/CLI Plugins", "bundle": "AI & Agents/Agent Workflows", + "resource": "Data & Knowledge/Resources", } _FALLBACK_CATEGORY = "Utilities/File Management" diff --git a/tests/fixtures/resource-full.yaml b/tests/fixtures/resource-full.yaml new file mode 100644 index 0000000..538575e --- /dev/null +++ b/tests/fixtures/resource-full.yaml @@ -0,0 +1,20 @@ +kind: resource +name: test-dataset +version: "2.0.0" +description: "Training dataset for sentiment analysis" +author: "test-org" +license: "MIT" +keywords: ["dataset", "nlp", "sentiment"] +runtimes: {} + +resource_type: dataset +resource_format: parquet +size_hint: medium +access: + method: git-submodule + path: "data/sentiment/" +compatibility: + frameworks: + - claude-code + - cursor + min_version: "1.0.0" diff --git a/tests/fixtures/resource-minimal.yaml b/tests/fixtures/resource-minimal.yaml new file mode 100644 index 0000000..99ea53d --- /dev/null +++ b/tests/fixtures/resource-minimal.yaml @@ -0,0 +1,4 @@ +kind: resource +name: test-prompt-library +version: "1.0.0" +description: "A collection of prompt templates for code review" diff --git a/tests/fixtures/resource-standard.yaml b/tests/fixtures/resource-standard.yaml new file mode 100644 index 0000000..e95acd3 --- /dev/null +++ b/tests/fixtures/resource-standard.yaml @@ -0,0 +1,13 @@ +kind: resource +name: test-config-templates +version: "1.0.0" +description: "Reusable configuration templates for CI/CD pipelines" +author: "test-org" +license: "Apache-2.0" +keywords: ["config", "ci-cd", "templates"] + +resource_type: config-template +resource_format: yaml +access: + method: file + path: "templates/" diff --git a/tests/test_adaptation.py b/tests/test_adaptation.py new file mode 100644 index 0000000..a500696 --- /dev/null +++ b/tests/test_adaptation.py @@ -0,0 +1,380 @@ +"""Tests for CAP-011: Framework adaptation layer.""" + +import pytest + +from capacium.manifest import Manifest +from capacium.adaptation import CapabilityAdapter, AdaptationRegistry +from capacium.adaptation.registry import AdaptationTarget +from capacium.adaptation.adapter import AdaptationError + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _skill_manifest(**overrides): + defaults = dict( + kind="skill", + name="my-skill", + version="1.0.0", + description="A test skill", + author="tester", + capabilities=[ + {"name": "do-thing", "description": "Does something"}, + ], + ) + defaults.update(overrides) + return Manifest(**defaults) + + +def _mcp_manifest(**overrides): + defaults = dict( + kind="mcp-server", + name="my-mcp", + version="2.0.0", + description="An MCP server", + mcp={"transport": "stdio", "clients": ["claude-desktop"]}, + capabilities=[ + {"name": "fetch", "description": "Fetches data"}, + ], + ) + defaults.update(overrides) + return Manifest(**defaults) + + +def _bundle_manifest(**overrides): + defaults = dict( + kind="bundle", + name="my-bundle", + version="3.0.0", + description="A test bundle", + capabilities=[ + {"name": "sub-a", "source": "./a", "description": "Sub A"}, + {"name": "sub-b", "source": "./b", "description": "Sub B"}, + ], + ) + defaults.update(overrides) + return Manifest(**defaults) + + +def _resource_manifest(**overrides): + defaults = dict( + kind="resource", + name="my-resource", + version="0.1.0", + description="A test resource", + resource_type="dataset", + resource_format="csv", + ) + defaults.update(overrides) + return Manifest(**defaults) + + +# =========================================================================== +# AdaptationRegistry tests +# =========================================================================== + +class TestAdaptationRegistry: + def test_defaults_registered(self): + reg = AdaptationRegistry() + names = reg.list_targets() + assert "mcp-server" in names + assert "a2a-agent" in names + assert "claude-desktop" in names + + def test_get_returns_target(self): + reg = AdaptationRegistry() + t = reg.get("mcp-server") + assert t is not None + assert t.name == "mcp-server" + assert t.requires_transport is True + + def test_get_unknown_returns_none(self): + reg = AdaptationRegistry() + assert reg.get("nonexistent") is None + + def test_register_custom_target(self): + reg = AdaptationRegistry() + custom = AdaptationTarget( + name="custom-framework", + description="My custom framework", + output_format="yaml", + ) + reg.register(custom) + assert "custom-framework" in reg.list_targets() + assert reg.get("custom-framework") is custom + + def test_register_overwrites_existing(self): + reg = AdaptationRegistry() + updated = AdaptationTarget( + name="mcp-server", + description="Updated MCP target", + requires_transport=False, + ) + reg.register(updated) + t = reg.get("mcp-server") + assert t.description == "Updated MCP target" + assert t.requires_transport is False + + def test_all_returns_all_targets(self): + reg = AdaptationRegistry() + targets = reg.all() + assert len(targets) == 3 + names = {t.name for t in targets} + assert names == {"mcp-server", "a2a-agent", "claude-desktop"} + + def test_list_targets_order_matches_registration(self): + reg = AdaptationRegistry() + names = reg.list_targets() + # Defaults registered in order: mcp-server, a2a-agent, claude-desktop + assert names == ["mcp-server", "a2a-agent", "claude-desktop"] + + def test_target_dataclass_defaults(self): + t = AdaptationTarget(name="test") + assert t.description == "" + assert t.output_format == "json" + assert t.requires_transport is False + assert t.supports_tools is True + assert t.supports_resources is True + assert t.supports_prompts is False + + +# =========================================================================== +# CapabilityAdapter.adapt tests +# =========================================================================== + +class TestCapabilityAdapterAdapt: + def test_adapt_skill_to_mcp_server(self): + adapter = CapabilityAdapter() + m = _skill_manifest() + result = adapter.adapt(m, "mcp-server") + assert result["serverInfo"]["name"] == "my-skill" + assert result["serverInfo"]["version"] == "1.0.0" + assert "capabilities" in result + + def test_adapt_skill_to_a2a_agent(self): + adapter = CapabilityAdapter() + m = _skill_manifest() + result = adapter.adapt(m, "a2a-agent") + assert result["name"] == "my-skill" + assert result["version"] == "1.0.0" + assert len(result["skills"]) == 1 + assert result["skills"][0]["name"] == "do-thing" + + def test_adapt_mcp_to_mcp_server(self): + adapter = CapabilityAdapter() + m = _mcp_manifest() + result = adapter.adapt(m, "mcp-server") + assert result["serverInfo"]["name"] == "my-mcp" + assert result["transport"] == "stdio" + + def test_adapt_bundle_to_a2a_agent(self): + adapter = CapabilityAdapter() + m = _bundle_manifest() + result = adapter.adapt(m, "a2a-agent") + assert result["name"] == "my-bundle" + assert len(result["skills"]) == 2 + + def test_adapt_to_claude_desktop_uses_generic(self): + """claude-desktop has no dedicated exporter, so uses generic adaptation.""" + adapter = CapabilityAdapter() + m = _skill_manifest() + result = adapter.adapt(m, "claude-desktop") + assert result["name"] == "my-skill" + assert result["adapted_from"] == "capacium" + assert result["target"] == "claude-desktop" + + def test_adapt_resource_to_mcp_server(self): + adapter = CapabilityAdapter() + m = _resource_manifest() + # MCPExporter supports "resource" kind + result = adapter.adapt(m, "mcp-server") + assert result["serverInfo"]["name"] == "my-resource" + + def test_adapt_unknown_target_raises(self): + adapter = CapabilityAdapter() + m = _skill_manifest() + with pytest.raises(AdaptationError, match="Unknown adaptation target"): + adapter.adapt(m, "nonexistent-framework") + + def test_adapt_unsupported_kind_raises(self): + """bundle cannot be exported to mcp-server via the MCPExporter.""" + adapter = CapabilityAdapter() + m = _bundle_manifest() + with pytest.raises(AdaptationError, match="Cannot adapt manifest kind"): + adapter.adapt(m, "mcp-server") + + +# =========================================================================== +# CapabilityAdapter.can_adapt tests +# =========================================================================== + +class TestCapabilityAdapterCanAdapt: + def test_can_adapt_skill_to_mcp(self): + adapter = CapabilityAdapter() + assert adapter.can_adapt(_skill_manifest(), "mcp-server") is True + + def test_can_adapt_skill_to_a2a(self): + adapter = CapabilityAdapter() + assert adapter.can_adapt(_skill_manifest(), "a2a-agent") is True + + def test_can_adapt_bundle_to_mcp_is_false(self): + adapter = CapabilityAdapter() + assert adapter.can_adapt(_bundle_manifest(), "mcp-server") is False + + def test_can_adapt_bundle_to_a2a_is_true(self): + adapter = CapabilityAdapter() + assert adapter.can_adapt(_bundle_manifest(), "a2a-agent") is True + + def test_can_adapt_unknown_target_is_false(self): + adapter = CapabilityAdapter() + assert adapter.can_adapt(_skill_manifest(), "no-such-target") is False + + def test_can_adapt_to_generic_target_always_true(self): + """claude-desktop has no exporter, so generic adaptation always returns True.""" + adapter = CapabilityAdapter() + assert adapter.can_adapt(_resource_manifest(), "claude-desktop") is True + + +# =========================================================================== +# CapabilityAdapter.list_targets tests +# =========================================================================== + +class TestCapabilityAdapterListTargets: + def test_list_all_targets(self): + adapter = CapabilityAdapter() + targets = adapter.list_targets() + assert "mcp-server" in targets + assert "a2a-agent" in targets + assert "claude-desktop" in targets + + def test_list_targets_filtered_by_skill(self): + adapter = CapabilityAdapter() + targets = adapter.list_targets(_skill_manifest()) + # skill is compatible with all three targets + assert "mcp-server" in targets + assert "a2a-agent" in targets + assert "claude-desktop" in targets + + def test_list_targets_filtered_by_bundle(self): + adapter = CapabilityAdapter() + targets = adapter.list_targets(_bundle_manifest()) + # bundle cannot go to mcp-server (MCPExporter rejects it) + assert "mcp-server" not in targets + assert "a2a-agent" in targets + assert "claude-desktop" in targets + + +# =========================================================================== +# Options / _apply_options tests +# =========================================================================== + +class TestAdaptOptions: + def test_transport_option_applied_for_mcp_target(self): + adapter = CapabilityAdapter() + m = _skill_manifest() + result = adapter.adapt(m, "mcp-server", {"transport": "sse"}) + assert result["transport"] == "sse" + + def test_command_option_applied(self): + adapter = CapabilityAdapter() + m = _skill_manifest() + result = adapter.adapt(m, "claude-desktop", {"command": "npx my-server"}) + assert result["command"] == "npx my-server" + + def test_args_option_applied(self): + adapter = CapabilityAdapter() + m = _skill_manifest() + result = adapter.adapt(m, "claude-desktop", {"args": ["--port", "9999"]}) + assert result["args"] == ["--port", "9999"] + + def test_transport_not_applied_when_target_does_not_require_it(self): + adapter = CapabilityAdapter() + m = _skill_manifest() + result = adapter.adapt(m, "a2a-agent", {"transport": "sse"}) + # a2a-agent does NOT require transport, so it should NOT be injected + assert "transport" not in result + + def test_no_options_leaves_result_unchanged(self): + adapter = CapabilityAdapter() + m = _skill_manifest() + with_opts = adapter.adapt(m, "mcp-server", {}) + without_opts = adapter.adapt(m, "mcp-server") + assert with_opts == without_opts + + +# =========================================================================== +# Generic adaptation tests +# =========================================================================== + +class TestGenericAdaptation: + def test_generic_includes_basic_fields(self): + adapter = CapabilityAdapter() + m = _skill_manifest() + result = adapter.adapt(m, "claude-desktop") + assert result["name"] == "my-skill" + assert result["version"] == "1.0.0" + assert result["description"] == "A test skill" + assert result["kind"] == "skill" + assert result["adapted_from"] == "capacium" + assert result["target"] == "claude-desktop" + + def test_generic_includes_tools_when_supported(self): + adapter = CapabilityAdapter() + m = _skill_manifest() + result = adapter.adapt(m, "claude-desktop") + assert "tools" in result + assert result["tools"][0]["name"] == "do-thing" + + def test_generic_includes_runtime(self): + adapter = CapabilityAdapter() + m = _skill_manifest(runtimes={"python": ">=3.10", "uv": ">=0.4"}) + result = adapter.adapt(m, "claude-desktop") + assert result["runtime"] == {"python": ">=3.10", "uv": ">=0.4"} + + def test_generic_no_tools_when_no_capabilities(self): + adapter = CapabilityAdapter() + m = _skill_manifest(capabilities=[]) + result = adapter.adapt(m, "claude-desktop") + assert "tools" not in result + + def test_generic_no_runtime_when_empty(self): + adapter = CapabilityAdapter() + m = _skill_manifest(runtimes={}) + result = adapter.adapt(m, "claude-desktop") + assert "runtime" not in result + + +# =========================================================================== +# Error message quality +# =========================================================================== + +class TestErrorMessages: + def test_unknown_target_lists_available(self): + adapter = CapabilityAdapter() + with pytest.raises(AdaptationError) as exc_info: + adapter.adapt(_skill_manifest(), "bogus") + msg = str(exc_info.value) + assert "mcp-server" in msg + assert "a2a-agent" in msg + assert "claude-desktop" in msg + + def test_unsupported_kind_mentions_kind_and_target(self): + adapter = CapabilityAdapter() + with pytest.raises(AdaptationError) as exc_info: + adapter.adapt(_bundle_manifest(), "mcp-server") + msg = str(exc_info.value) + assert "bundle" in msg + assert "mcp-server" in msg + + +# =========================================================================== +# Registry property access +# =========================================================================== + +class TestRegistryProperty: + def test_registry_accessible(self): + adapter = CapabilityAdapter() + reg = adapter.registry + assert isinstance(reg, AdaptationRegistry) + assert len(reg.list_targets()) >= 3 diff --git a/tests/test_conditions.py b/tests/test_conditions.py new file mode 100644 index 0000000..2acc025 --- /dev/null +++ b/tests/test_conditions.py @@ -0,0 +1,309 @@ +"""Tests for the condition expression evaluator (CAP-005).""" + +import pytest + +from capacium.conditions import ConditionEvaluator, ConditionResult + + +# ------------------------------------------------------------------ +# Shared context fixture +# ------------------------------------------------------------------ + +@pytest.fixture +def ctx(): + return { + "runtime": {"python": "3.11", "node": "20.0"}, + "os": "linux", + "env": {"OPENAI_API_KEY": "set", "HOME": "/home/user"}, + "trust_state": "verified", + "kind": "skill", + } + + +@pytest.fixture +def evaluator(ctx): + return ConditionEvaluator(ctx) + + +# ------------------------------------------------------------------ +# Simple equality +# ------------------------------------------------------------------ + +class TestEquality: + def test_os_equals(self, evaluator): + r = evaluator.evaluate("os == linux") + assert r.passed is True + + def test_os_not_equals(self, evaluator): + r = evaluator.evaluate("os == windows") + assert r.passed is False + + def test_kind_equals(self, evaluator): + r = evaluator.evaluate("kind == skill") + assert r.passed is True + + def test_inequality(self, evaluator): + r = evaluator.evaluate("os != windows") + assert r.passed is True + + def test_inequality_false(self, evaluator): + r = evaluator.evaluate("os != linux") + assert r.passed is False + + +# ------------------------------------------------------------------ +# Version comparison +# ------------------------------------------------------------------ + +class TestVersionComparison: + def test_python_gte_match(self, evaluator): + r = evaluator.evaluate("runtime.python >= 3.10") + assert r.passed is True + + def test_python_gte_exact(self, evaluator): + r = evaluator.evaluate("runtime.python >= 3.11") + assert r.passed is True + + def test_python_gte_fail(self, evaluator): + r = evaluator.evaluate("runtime.python >= 3.12") + assert r.passed is False + + def test_python_lt(self, evaluator): + r = evaluator.evaluate("runtime.python < 4.0") + assert r.passed is True + + def test_python_gt(self, evaluator): + r = evaluator.evaluate("runtime.python > 3.10") + assert r.passed is True + + def test_node_lte(self, evaluator): + r = evaluator.evaluate("runtime.node <= 20.0") + assert r.passed is True + + def test_node_lte_fail(self, evaluator): + r = evaluator.evaluate("runtime.node <= 18.0") + assert r.passed is False + + +# ------------------------------------------------------------------ +# Existence checks +# ------------------------------------------------------------------ + +class TestExistence: + def test_exists_present(self, evaluator): + r = evaluator.evaluate("env.OPENAI_API_KEY exists") + assert r.passed is True + + def test_exists_missing(self, evaluator): + r = evaluator.evaluate("env.SECRET exists") + assert r.passed is False + assert "not found" in r.reason + + def test_not_exists_present(self, evaluator): + r = evaluator.evaluate("env.OPENAI_API_KEY not-exists") + assert r.passed is False + + def test_not_exists_missing(self, evaluator): + r = evaluator.evaluate("env.SECRET not-exists") + assert r.passed is True + + +# ------------------------------------------------------------------ +# Trust state ordering +# ------------------------------------------------------------------ + +class TestTrustState: + def test_trust_gte_verified(self, evaluator): + r = evaluator.evaluate("trust_state >= verified") + assert r.passed is True + + def test_trust_gte_signed(self, evaluator): + r = evaluator.evaluate("trust_state >= signed") + assert r.passed is False + + def test_trust_gt_audited(self, evaluator): + r = evaluator.evaluate("trust_state > audited") + assert r.passed is True + + def test_trust_eq(self, evaluator): + r = evaluator.evaluate("trust_state == verified") + assert r.passed is True + + +# ------------------------------------------------------------------ +# AND connector +# ------------------------------------------------------------------ + +class TestAND: + def test_both_true(self, evaluator): + r = evaluator.evaluate("os == linux AND runtime.python >= 3.10") + assert r.passed is True + + def test_first_false(self, evaluator): + r = evaluator.evaluate("os == windows AND runtime.python >= 3.10") + assert r.passed is False + + def test_second_false(self, evaluator): + r = evaluator.evaluate("os == linux AND runtime.python >= 4.0") + assert r.passed is False + + def test_both_false(self, evaluator): + r = evaluator.evaluate("os == windows AND runtime.python >= 4.0") + assert r.passed is False + + +# ------------------------------------------------------------------ +# OR connector +# ------------------------------------------------------------------ + +class TestOR: + def test_both_true(self, evaluator): + r = evaluator.evaluate("kind == skill OR kind == mcp-server") + assert r.passed is True + + def test_first_true(self, evaluator): + r = evaluator.evaluate("kind == skill OR kind == tool") + assert r.passed is True + + def test_second_true(self, evaluator): + r = evaluator.evaluate("kind == tool OR kind == skill") + assert r.passed is True + + def test_both_false(self, evaluator): + r = evaluator.evaluate("kind == tool OR kind == mcp-server") + assert r.passed is False + + +# ------------------------------------------------------------------ +# Multiple connectors +# ------------------------------------------------------------------ + +class TestMultipleConnectors: + def test_three_and(self, evaluator): + r = evaluator.evaluate("os == linux AND kind == skill AND runtime.python >= 3.10") + assert r.passed is True + + def test_and_or_mixed(self, evaluator): + # Left-to-right: (os==linux AND kind==tool) => False, OR kind==skill => True + r = evaluator.evaluate("os == linux AND kind == tool OR kind == skill") + assert r.passed is True + + +# ------------------------------------------------------------------ +# Nested path resolution +# ------------------------------------------------------------------ + +class TestPathResolution: + def test_nested_path(self, evaluator): + r = evaluator.evaluate("runtime.python >= 3.0") + assert r.passed is True + + def test_deeply_nested(self): + ev = ConditionEvaluator({"a": {"b": {"c": "hello"}}}) + r = ev.evaluate("a.b.c == hello") + assert r.passed is True + + def test_missing_intermediate(self, evaluator): + r = evaluator.evaluate("runtime.ruby >= 3.0") + assert r.passed is False + assert "not found" in r.reason + + def test_top_level_missing(self, evaluator): + r = evaluator.evaluate("arch == x86_64") + assert r.passed is False + assert "not found" in r.reason + + +# ------------------------------------------------------------------ +# Edge cases +# ------------------------------------------------------------------ + +class TestEdgeCases: + def test_empty_expression(self, evaluator): + r = evaluator.evaluate("") + assert r.passed is False + assert "empty expression" in r.reason + + def test_whitespace_only(self, evaluator): + r = evaluator.evaluate(" ") + assert r.passed is False + assert "empty expression" in r.reason + + def test_invalid_operator(self, evaluator): + r = evaluator.evaluate("os ~= linux") + assert r.passed is False + assert "invalid expression syntax" in r.reason + + def test_missing_value_for_comparison(self, evaluator): + r = evaluator.evaluate("os ==") + assert r.passed is False + + def test_no_context(self): + ev = ConditionEvaluator() + r = ev.evaluate("os == linux") + assert r.passed is False + assert "not found" in r.reason + + def test_empty_context(self): + ev = ConditionEvaluator({}) + r = ev.evaluate("env.KEY exists") + assert r.passed is False + + +# ------------------------------------------------------------------ +# evaluate_all +# ------------------------------------------------------------------ + +class TestEvaluateAll: + def test_all_pass(self, evaluator): + results = evaluator.evaluate_all([ + "os == linux", + "kind == skill", + "runtime.python >= 3.10", + ]) + assert all(r.passed for r in results) + assert len(results) == 3 + + def test_one_fails(self, evaluator): + results = evaluator.evaluate_all([ + "os == linux", + "kind == tool", + ]) + assert results[0].passed is True + assert results[1].passed is False + + def test_empty_list(self, evaluator): + results = evaluator.evaluate_all([]) + assert results == [] + + +# ------------------------------------------------------------------ +# ConditionResult dataclass +# ------------------------------------------------------------------ + +class TestConditionResult: + def test_defaults(self): + r = ConditionResult(passed=True, expression="os == linux") + assert r.passed is True + assert r.expression == "os == linux" + assert r.reason == "" + + def test_with_reason(self): + r = ConditionResult(passed=False, expression="x == y", reason="mismatch") + assert r.reason == "mismatch" + + +# ------------------------------------------------------------------ +# Value with hyphens / special chars in comparison +# ------------------------------------------------------------------ + +class TestSpecialValues: + def test_kind_mcp_server(self): + ev = ConditionEvaluator({"kind": "mcp-server"}) + r = ev.evaluate("kind == mcp-server") + assert r.passed is True + + def test_path_with_underscores(self): + ev = ConditionEvaluator({"trust_state": "audited"}) + r = ev.evaluate("trust_state == audited") + assert r.passed is True diff --git a/tests/test_exporters.py b/tests/test_exporters.py new file mode 100644 index 0000000..321b809 --- /dev/null +++ b/tests/test_exporters.py @@ -0,0 +1,207 @@ +"""Tests for CAP-008: Standards exports (MCP and A2A formats).""" + +import json + +from capacium.manifest import Manifest +from capacium.exporters import MCPExporter, A2AExporter + + +class TestMCPExporter: + def test_export_from_skill_manifest(self): + m = Manifest( + kind="skill", + name="my-skill", + version="1.2.0", + description="A handy skill", + capabilities=[ + {"name": "do-thing", "description": "Does something"}, + {"name": "do-other", "description": "Does something else"}, + ], + ) + exporter = MCPExporter() + result = exporter.export(m) + + assert result["serverInfo"]["name"] == "my-skill" + assert result["serverInfo"]["version"] == "1.2.0" + assert result["transport"] == "stdio" + assert len(result["capabilities"]["tools"]) == 2 + assert result["capabilities"]["tools"][0]["name"] == "do-thing" + + def test_export_from_mcp_server_manifest(self): + m = Manifest( + kind="mcp-server", + name="my-mcp", + version="0.5.0", + description="An MCP server", + mcp={"transport": "sse", "clients": ["claude-desktop", "cursor"]}, + capabilities=[{"name": "search", "description": "Search stuff"}], + runtimes={"node": ">=20"}, + ) + exporter = MCPExporter() + result = exporter.export(m) + + assert result["serverInfo"]["name"] == "my-mcp" + assert result["transport"] == "sse" + assert result["supportedClients"] == ["claude-desktop", "cursor"] + assert len(result["capabilities"]["tools"]) == 1 + assert result["runtime"] == {"node": ">=20"} + + def test_export_from_resource_manifest(self): + m = Manifest( + kind="resource", + name="my-dataset", + version="1.0.0", + description="A dataset resource", + ) + exporter = MCPExporter() + assert exporter.can_export(m) is True + result = exporter.export(m) + assert result["serverInfo"]["name"] == "my-dataset" + + def test_can_export_returns_false_for_unsupported_kinds(self): + exporter = MCPExporter() + m = Manifest(kind="bundle", name="b", version="1.0.0") + assert exporter.can_export(m) is False + + m2 = Manifest(kind="workflow", name="w", version="1.0.0") + assert exporter.can_export(m2) is False + + def test_format_name(self): + assert MCPExporter().format_name == "mcp-server" + + def test_no_capabilities_produces_empty_tools(self): + m = Manifest(kind="skill", name="bare", version="0.1.0") + result = MCPExporter().export(m) + assert "tools" not in result["capabilities"] + + def test_mcp_transport_default_when_mcp_section_empty(self): + m = Manifest(kind="mcp-server", name="x", version="1.0.0", mcp={}) + result = MCPExporter().export(m) + assert result["transport"] == "stdio" + + +class TestA2AExporter: + def test_export_from_skill_manifest(self): + m = Manifest( + kind="skill", + name="my-skill", + version="2.0.0", + description="A useful skill", + owner="acme", + homepage="https://example.com", + capabilities=[ + {"name": "analyze", "description": "Analyze data"}, + ], + ) + exporter = A2AExporter() + card = exporter.export(m) + + assert card["name"] == "my-skill" + assert card["version"] == "2.0.0" + assert card["url"] == "https://example.com" + assert card["provider"]["organization"] == "acme" + assert len(card["skills"]) == 1 + assert card["skills"][0]["id"] == "analyze" + assert card["skills"][0]["description"] == "Analyze data" + assert card["capabilities"]["streaming"] is False + assert card["capabilities"]["pushNotifications"] is False + + def test_export_from_bundle_manifest(self): + m = Manifest( + kind="bundle", + name="my-bundle", + version="3.0.0", + description="A bundle", + author="Bob", + repository="https://github.com/bob/bundle", + capabilities=[ + {"name": "cap-a", "source": "./a", "description": "Cap A"}, + {"name": "cap-b", "source": "./b"}, + ], + ) + exporter = A2AExporter() + card = exporter.export(m) + + assert card["name"] == "my-bundle" + assert card["url"] == "https://github.com/bob/bundle" + assert card["provider"]["organization"] == "Bob" + assert len(card["skills"]) == 2 + assert card["skills"][0]["id"] == "cap-a" + assert card["skills"][0]["description"] == "Cap A" + # cap-b has no description, should fall back to manifest description + assert card["skills"][1]["description"] == "A bundle" + + def test_export_without_capabilities_uses_self(self): + m = Manifest( + kind="skill", + name="solo-skill", + version="1.0.0", + description="A standalone skill", + ) + exporter = A2AExporter() + card = exporter.export(m) + + assert len(card["skills"]) == 1 + assert card["skills"][0]["id"] == "solo-skill" + assert card["skills"][0]["name"] == "solo-skill" + assert card["skills"][0]["description"] == "A standalone skill" + + def test_can_export_returns_false_for_unsupported_kinds(self): + exporter = A2AExporter() + m = Manifest(kind="resource", name="r", version="1.0.0") + assert exporter.can_export(m) is False + + m2 = Manifest(kind="workflow", name="w", version="1.0.0") + assert exporter.can_export(m2) is False + + def test_format_name(self): + assert A2AExporter().format_name == "a2a-agent-card" + + def test_url_falls_back_to_repository(self): + m = Manifest( + kind="skill", + name="x", + version="1.0.0", + description="d", + repository="https://github.com/a/b", + ) + card = A2AExporter().export(m) + assert card["url"] == "https://github.com/a/b" + + def test_provider_falls_back_to_author(self): + m = Manifest( + kind="skill", + name="x", + version="1.0.0", + description="d", + author="Alice", + ) + card = A2AExporter().export(m) + assert card["provider"]["organization"] == "Alice" + + +class TestExportJson: + def test_mcp_export_json_produces_valid_json(self): + m = Manifest( + kind="skill", + name="json-test", + version="1.0.0", + description="test", + capabilities=[{"name": "t", "description": "d"}], + ) + output = MCPExporter().export_json(m) + parsed = json.loads(output) + assert parsed["serverInfo"]["name"] == "json-test" + assert isinstance(parsed, dict) + + def test_a2a_export_json_produces_valid_json(self): + m = Manifest( + kind="skill", + name="json-test-a2a", + version="1.0.0", + description="test a2a", + ) + output = A2AExporter().export_json(m) + parsed = json.loads(output) + assert parsed["name"] == "json-test-a2a" + assert isinstance(parsed, dict) diff --git a/tests/test_install.py b/tests/test_install.py index 3a2117c..15bac32 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -181,6 +181,111 @@ def test_install_from_local_path(self, tmp_home, tmp_path, sample_capability_dir ) assert result is True + def test_install_from_local_bundle_detects_manifest_identity(self, tmp_home, tmp_path): + from capacium.commands.install import install_capability + from capacium.registry import Registry + + bundle_dir = tmp_path / "skillweave" + skill_dir = bundle_dir / "skills" / "skillweave-blueprint" + skill_dir.mkdir(parents=True) + (bundle_dir / "capability.yaml").write_text("""\ +kind: bundle +name: skillweave +version: 1.0.2 +description: SkillWeave bundle +frameworks: +- opencode +capabilities: +- name: skillweave-blueprint + source: ./skills/skillweave-blueprint + version: 1.0.2 +""") + (skill_dir / "capability.yaml").write_text("""\ +kind: skill +name: skillweave-blueprint +version: 1.0.2 +description: Blueprint skill +frameworks: +- opencode +""") + (skill_dir / "SKILL.md").write_text("# Blueprint\n") + + result = install_capability( + "", + source_dir=bundle_dir, + no_lock=True, + skip_runtime_check=True, + all_frameworks=True, + force=True, + yes=True, + ) + + assert result is True + registry = Registry() + assert registry.get_capability("global/skillweave", "1.0.2") is not None + assert registry.get_capability("global/", "1.0.2") is None + assert ( + tmp_home + / ".config" + / "opencode" + / "commands" + / "skillweave-blueprint.md" + ).exists() + + def test_force_install_removes_superseded_bundle_member_versions(self, tmp_home, tmp_path): + from capacium.commands.install import install_capability + from capacium.registry import Registry + + def write_bundle(version): + bundle_dir = tmp_path / f"skillweave-{version}" + skill_dir = bundle_dir / "skills" / "skillweave-blueprint" + skill_dir.mkdir(parents=True) + (bundle_dir / "capability.yaml").write_text(f"""\ +kind: bundle +name: skillweave +version: {version} +description: SkillWeave bundle +frameworks: +- opencode +capabilities: +- name: skillweave-blueprint + source: ./skills/skillweave-blueprint + version: {version} +""") + (skill_dir / "capability.yaml").write_text(f"""\ +kind: skill +name: skillweave-blueprint +version: {version} +description: Blueprint skill +frameworks: +- opencode +""") + (skill_dir / "SKILL.md").write_text(f"# Blueprint {version}\n") + return bundle_dir + + assert install_capability( + "", + source_dir=write_bundle("1.0.1"), + no_lock=True, + skip_runtime_check=True, + force=True, + yes=True, + ) + assert install_capability( + "", + source_dir=write_bundle("1.0.2"), + no_lock=True, + skip_runtime_check=True, + force=True, + yes=True, + ) + + registry = Registry() + assert registry.get_capability("global/skillweave", "1.0.1") is None + assert registry.get_capability("global/skillweave-blueprint", "1.0.1") is None + assert registry.get_capability("global/skillweave", "1.0.2") is not None + assert registry.get_capability("global/skillweave-blueprint", "1.0.2") is not None + def test_install_rejects_cwd_without_capability(self, tmp_home, tmp_path, capsys, monkeypatch): empty_dir = tmp_path / "empty" empty_dir.mkdir() @@ -328,6 +433,20 @@ def test_eof_error_returns_all(self, monkeypatch): result = _prompt_framework_selection() assert len(result) == 2 + def test_all_frameworks_includes_manifest_frameworks(self, monkeypatch): + from capacium.framework_detector import resolve_frameworks + + monkeypatch.setattr( + "capacium.framework_detector.detect_active_frameworks", + lambda: {"claude-code"}, + ) + result = resolve_frameworks( + ["opencode"], + all_frameworks=True, + kind="skill", + ) + assert result == ["claude-code", "opencode"] + class TestFrameworkAppend: def test_is_framework_already_returns_true_when_symlink_exists(self, monkeypatch, tmp_path): diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 59f562a..aa5299c 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -77,6 +77,149 @@ def test_without_owner(self): assert parse_cap_id("my-cap") == ("global", "my-cap") +class TestTriggers: + def test_triggers_from_dict(self): + data = { + "name": "trigger-cap", + "version": "1.0.0", + "kind": "skill", + "triggers": [ + {"event": "file-changed", "pattern": "*.py", "action": "run-linter"}, + ], + } + m = Manifest.from_dict(data) + assert len(m.triggers) == 1 + assert m.triggers[0]["event"] == "file-changed" + assert m.triggers[0]["action"] == "run-linter" + + def test_triggers_valid(self): + m = Manifest( + name="t", + triggers=[ + {"event": "schedule", "action": "daily-check"}, + {"event": "on-install", "action": "setup"}, + ], + ) + assert m.validate() == [] + + def test_triggers_missing_event(self): + m = Manifest(name="t", triggers=[{"action": "run"}]) + errors = m.validate() + assert any("missing required 'event'" in e for e in errors) + + def test_triggers_missing_action(self): + m = Manifest(name="t", triggers=[{"event": "manual"}]) + errors = m.validate() + assert any("missing required 'action'" in e for e in errors) + + def test_triggers_invalid_event(self): + m = Manifest(name="t", triggers=[{"event": "invalid-event", "action": "run"}]) + errors = m.validate() + assert any("invalid event 'invalid-event'" in e for e in errors) + + def test_triggers_all_valid_events(self): + valid_events = ["file-changed", "schedule", "webhook", "manual", "on-install", "on-update"] + for event in valid_events: + m = Manifest(name="t", triggers=[{"event": event, "action": "do-stuff"}]) + assert m.validate() == [], f"Event '{event}' should be valid" + + def test_triggers_empty_list_is_valid(self): + m = Manifest(name="t", triggers=[]) + assert m.validate() == [] + + def test_triggers_roundtrip_yaml(self, tmp_path): + m = Manifest( + name="trigger-rt", + version="1.0.0", + triggers=[{"event": "webhook", "action": "notify", "pattern": "/api/*"}], + ) + path = tmp_path / "capability.yaml" + m.save(path) + loaded = Manifest.load(path) + assert loaded.triggers == m.triggers + + +class TestPricing: + def test_pricing_from_dict(self): + data = { + "name": "priced-cap", + "version": "1.0.0", + "kind": "skill", + "pricing": {"model": "free"}, + } + m = Manifest.from_dict(data) + assert m.pricing is not None + assert m.pricing["model"] == "free" + + def test_pricing_none_by_default(self): + m = Manifest(name="t") + assert m.pricing is None + assert m.validate() == [] + + def test_pricing_free_valid(self): + m = Manifest(name="t", pricing={"model": "free"}) + assert m.validate() == [] + + def test_pricing_freemium_valid(self): + m = Manifest( + name="t", + pricing={ + "model": "freemium", + "features_free": ["basic-scan"], + "features_paid": ["deep-analysis"], + }, + ) + assert m.validate() == [] + + def test_pricing_paid_valid(self): + m = Manifest(name="t", pricing={"model": "paid", "price_usd": 9.99}) + assert m.validate() == [] + + def test_pricing_paid_missing_price(self): + m = Manifest(name="t", pricing={"model": "paid"}) + errors = m.validate() + assert any("requires 'price_usd'" in e for e in errors) + + def test_pricing_paid_zero_price(self): + m = Manifest(name="t", pricing={"model": "paid", "price_usd": 0}) + errors = m.validate() + assert any("must be a number greater than 0" in e for e in errors) + + def test_pricing_paid_negative_price(self): + m = Manifest(name="t", pricing={"model": "paid", "price_usd": -5}) + errors = m.validate() + assert any("must be a number greater than 0" in e for e in errors) + + def test_pricing_missing_model(self): + m = Manifest(name="t", pricing={"price_usd": 10}) + errors = m.validate() + assert any("missing required 'model'" in e for e in errors) + + def test_pricing_invalid_model(self): + m = Manifest(name="t", pricing={"model": "subscription"}) + errors = m.validate() + assert any("invalid model 'subscription'" in e for e in errors) + + def test_pricing_all_valid_models(self): + for model in ["free", "freemium", "paid", "usage-based", "donation"]: + pricing = {"model": model} + if model == "paid": + pricing["price_usd"] = 5.0 + m = Manifest(name="t", pricing=pricing) + assert m.validate() == [], f"Model '{model}' should be valid" + + def test_pricing_roundtrip_yaml(self, tmp_path): + m = Manifest( + name="priced-rt", + version="1.0.0", + pricing={"model": "usage-based", "price_usd": 0.01}, + ) + path = tmp_path / "capability.yaml" + m.save(path) + loaded = Manifest.load(path) + assert loaded.pricing == m.pricing + + class TestFormatCapId: def test_format(self): assert format_cap_id("alice", "my-cap") == "alice/my-cap" diff --git a/tests/test_models.py b/tests/test_models.py index 3f47413..ef4efac 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -14,7 +14,7 @@ def test_kind_values(self): def test_all_kinds_covered(self): kinds = set(k.value for k in Kind) - expected = {"skill", "bundle", "tool", "prompt", "template", "workflow", "mcp-server", "connector-pack"} + expected = {"skill", "bundle", "tool", "prompt", "template", "workflow", "mcp-server", "connector-pack", "resource"} assert kinds == expected diff --git a/tests/test_publish.py b/tests/test_publish.py index c060cb6..4dd1b8c 100644 --- a/tests/test_publish.py +++ b/tests/test_publish.py @@ -58,6 +58,45 @@ def test_publish_valid_tarball(self, tmp_path): assert result is True instance.publish.assert_called_once() + def test_publish_includes_identity_migration_fields(self, tmp_path): + cap_yaml = tmp_path / "capability.yaml" + cap_yaml.write_text("""\ +kind: bundle +name: skillweave +version: 1.0.2 +description: SkillWeave bundle +owner: LangeVC +repository: https://github.com/LangeVC/skillweave +replaces: + - typelicious/skillweave +previous_identities: + - owner: typelicious + name: skillweave + repository: https://github.com/typelicious/skillweave +""") + + with mock.patch("capacium.commands.publish.RegistryClient") as mock_client: + instance = mock_client.return_value + instance.publish.return_value = { + "canonical_name": "LangeVC/skillweave", + "kind": "bundle", + "trust_state": "discovered", + "created": True, + } + result = publish_capability(cap_yaml) + assert result is True + + payload = instance.publish.call_args.args[0] + assert payload["repo_url"] == "https://github.com/LangeVC/skillweave" + assert payload["replaces"] == ["typelicious/skillweave"] + assert payload["previous_identities"] == [ + { + "owner": "typelicious", + "name": "skillweave", + "repository": "https://github.com/typelicious/skillweave", + } + ] + def test_publish_conflict_409(self, tmp_path): import tarfile diff --git a/tests/test_resource_kind.py b/tests/test_resource_kind.py new file mode 100644 index 0000000..593c08b --- /dev/null +++ b/tests/test_resource_kind.py @@ -0,0 +1,221 @@ +"""Tests for the resource kind (CAP-001 + CAP-002).""" + +from pathlib import Path + +from capacium.manifest import Manifest +from capacium.models import Kind, Capability +from capacium.commands.init import VALID_KINDS, _validate_kind + + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +class TestResourceManifestFixtures: + def test_load_resource_minimal(self): + m = Manifest.load(FIXTURES_DIR / "resource-minimal.yaml") + assert m.kind == "resource" + assert m.name == "test-prompt-library" + assert m.version == "1.0.0" + assert m.description == "A collection of prompt templates for code review" + + def test_load_resource_full(self): + m = Manifest.load(FIXTURES_DIR / "resource-full.yaml") + assert m.kind == "resource" + assert m.name == "test-dataset" + assert m.version == "2.0.0" + assert m.description == "Training dataset for sentiment analysis" + assert m.author == "test-org" + assert m.license == "MIT" + assert m.keywords == ["dataset", "nlp", "sentiment"] + + def test_resource_kind_accepted(self): + m = Manifest(kind="resource", name="my-resource", version="1.0.0", description="A resource") + assert m.kind == "resource" + errors = m.validate() + assert errors == [] + + def test_resource_no_mcp_required(self): + m = Manifest(kind="resource", name="my-resource", version="1.0.0", description="A resource") + errors = m.validate() + # Should NOT contain any MCP-related errors + assert not any("mcp" in e.lower() for e in errors) + assert not any("transport" in e.lower() for e in errors) + + def test_resource_requires_description(self): + m = Manifest(kind="resource", name="my-resource", version="1.0.0", description="") + errors = m.validate() + assert "Resource manifest requires a description" in errors + + def test_resource_with_description_passes(self): + m = Manifest(kind="resource", name="my-resource", version="1.0.0", description="Has a description") + errors = m.validate() + assert "Resource manifest requires a description" not in errors + + +class TestResourceKindEnum: + def test_resource_in_kind_enum(self): + assert Kind.RESOURCE.value == "resource" + + def test_all_kinds_include_resource(self): + kinds = {k.value for k in Kind} + assert "resource" in kinds + + def test_capability_with_resource_kind(self): + cap = Capability(owner="test", name="my-res", version="1.0.0", kind=Kind.RESOURCE) + assert cap.kind == Kind.RESOURCE + d = cap.to_dict() + assert d["kind"] == "resource" + + def test_capability_from_dict_resource(self): + d = { + "owner": "test", + "name": "my-res", + "version": "1.0.0", + "kind": "resource", + "fingerprint": "", + "install_path": "", + "installed_at": "", + } + cap = Capability.from_dict(d) + assert cap.kind == Kind.RESOURCE + + +class TestResourceInCLIInit: + def test_resource_in_valid_kinds(self): + assert "resource" in VALID_KINDS + + def test_validate_kind_accepts_resource(self): + assert _validate_kind("resource") is None + + def test_validate_kind_rejects_invalid(self): + assert _validate_kind("invalid-kind") is not None + + +class TestResourceSchema: + """CAP-002: Resource kind 5-layer progressive schema.""" + + def test_load_resource_standard(self): + m = Manifest.load(FIXTURES_DIR / "resource-standard.yaml") + assert m.kind == "resource" + assert m.name == "test-config-templates" + assert m.resource_type == "config-template" + assert m.resource_format == "yaml" + assert m.access == {"method": "file", "path": "templates/"} + errors = m.validate() + assert errors == [] + + def test_load_resource_full_layers(self): + m = Manifest.load(FIXTURES_DIR / "resource-full.yaml") + assert m.kind == "resource" + assert m.name == "test-dataset" + assert m.resource_type == "dataset" + assert m.resource_format == "parquet" + assert m.size_hint == "medium" + assert m.access == {"method": "git-submodule", "path": "data/sentiment/"} + assert m.compatibility == { + "frameworks": ["claude-code", "cursor"], + "min_version": "1.0.0", + } + errors = m.validate() + assert errors == [] + + def test_invalid_resource_type_rejected(self): + m = Manifest( + kind="resource", + name="bad-type", + version="1.0.0", + description="Has bad resource_type", + resource_type="magic-beans", + ) + errors = m.validate() + assert any("Invalid resource_type: magic-beans" in e for e in errors) + + def test_invalid_resource_format_rejected(self): + m = Manifest( + kind="resource", + name="bad-format", + version="1.0.0", + description="Has bad format", + resource_format="xlsx", + ) + errors = m.validate() + assert any("Invalid resource format: xlsx" in e for e in errors) + + def test_invalid_size_hint_rejected(self): + m = Manifest( + kind="resource", + name="bad-size", + version="1.0.0", + description="Has bad size hint", + size_hint="enormous", + ) + errors = m.validate() + assert any("Invalid size_hint: enormous" in e for e in errors) + + def test_resource_no_mcp_fields_required(self): + m = Manifest( + kind="resource", + name="no-mcp", + version="1.0.0", + description="Resource without MCP", + resource_type="prompt-library", + resource_format="yaml", + size_hint="small", + ) + errors = m.validate() + assert errors == [] + assert m.mcp == {} + assert m.entrypoint == "" + + def test_valid_resource_types_accepted(self): + for rt in ("prompt-library", "dataset", "config-template", + "model-weights", "tool-index", "embedding"): + m = Manifest( + kind="resource", + name="rt-test", + version="1.0.0", + description="Testing resource type", + resource_type=rt, + ) + errors = m.validate() + assert not any("resource_type" in e for e in errors), f"resource_type {rt} should be valid" + + def test_valid_formats_accepted(self): + for fmt in ("yaml", "json", "csv", "parquet", "binary", "directory"): + m = Manifest( + kind="resource", + name="fmt-test", + version="1.0.0", + description="Testing format", + resource_format=fmt, + ) + errors = m.validate() + assert not any("resource format" in e for e in errors), f"format {fmt} should be valid" + + def test_valid_size_hints_accepted(self): + for sz in ("small", "medium", "large"): + m = Manifest( + kind="resource", + name="sz-test", + version="1.0.0", + description="Testing size hint", + size_hint=sz, + ) + errors = m.validate() + assert not any("size_hint" in e for e in errors), f"size_hint {sz} should be valid" + + def test_resource_fields_none_by_default(self): + m = Manifest(kind="resource", name="defaults", version="1.0.0", description="Defaults") + assert m.resource_type is None + assert m.resource_format is None + assert m.size_hint is None + assert m.access is None + assert m.compatibility is None + + def test_resource_validation_skips_for_non_resource(self): + """Resource-specific validation should not fire for kind=skill.""" + m = Manifest(kind="skill", name="a-skill", version="1.0.0", description="") + errors = m.validate() + assert not any("resource_type" in e for e in errors) + assert not any("resource format" in e for e in errors) + assert not any("size_hint" in e for e in errors) diff --git a/tests/test_update.py b/tests/test_update.py index c71312c..9eface7 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -141,4 +141,4 @@ def test_check_for_newer_version_via_remote(tmp_path): "global/test-cap", "1.0.0", str(remote) ) assert result is True - mock.assert_called_once_with("global/test-cap@2.0.0", yes=True) + mock.assert_called_once_with("global/test-cap@2.0.0", force=True, yes=True)