diff --git a/.gitignore b/.gitignore index d1d5d93..c5edf4c 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,9 @@ htmlcov/ # Ruff .ruff_cache/ + +# Tool config dirs (generated by apc sync) +.cursor/ +.claude/ +.gemini/ +.codeium/ diff --git a/pyproject.toml b/pyproject.toml index 97e850b..19dcc4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,10 +30,9 @@ build-backend = "setuptools.build_meta" package-dir = {"" = "src"} py-modules = [ "cache", "collect", "config", "export_import", - "frontmatter_parser", "llm_client", "llm_config", - "main", "marketplace", "mcp", "memory", - "secrets_manager", "share", "skill", "status", - "sync_helpers", "ui", +"frontmatter_parser", "install", "llm_client", "llm_config", + "main", "mcp", "memory", "secrets_manager", + "skill", "skills", "status", "sync_helpers", "ui", ] packages = ["extractors", "appliers"] diff --git a/src/appliers/cursor.py b/src/appliers/cursor.py index af9d6a2..2383074 100644 --- a/src/appliers/cursor.py +++ b/src/appliers/cursor.py @@ -9,10 +9,6 @@ from appliers.manifest import ToolManifest from frontmatter_parser import render_frontmatter -CURSOR_DIR = Path.home() / ".cursor" -CURSOR_RULES_DIR = Path(".cursor") / "rules" -CURSOR_MCP_JSON = CURSOR_DIR / "mcp.json" - CURSOR_MEMORY_SCHEMA = """ Cursor uses Project Rules in .cursor/rules/ to provide persistent context to its AI. Rules are markdown files (.md or .mdc). Files with .mdc extension support YAML frontmatter. @@ -65,17 +61,30 @@ """ +def _cursor_dir() -> Path: + return Path.home() / ".cursor" + + +def _cursor_rules_dir() -> Path: + return Path.home() / ".cursor" / "rules" + + +def _cursor_mcp_json() -> Path: + return Path.home() / ".cursor" / "mcp.json" + + class CursorApplier(BaseApplier): - SKILL_DIR = CURSOR_RULES_DIR TOOL_NAME = "cursor" MEMORY_SCHEMA = CURSOR_MEMORY_SCHEMA + @property + def SKILL_DIR(self) -> Path: # type: ignore[override] + return _cursor_rules_dir() + def link_skills(self, skills: List[Dict], source_dir: Path, manifest: ToolManifest) -> int: """Cursor uses flat .mdc files, so symlink SKILL.md as .mdc.""" - if self.SKILL_DIR is None: - return 0 - - self.SKILL_DIR.mkdir(parents=True, exist_ok=True) + rules_dir = _cursor_rules_dir() + rules_dir.mkdir(parents=True, exist_ok=True) count = 0 for skill in skills: @@ -84,7 +93,7 @@ def link_skills(self, skills: List[Dict], source_dir: Path, manifest: ToolManife if not source.exists(): continue - link_path = self.SKILL_DIR / f"{name}.mdc" + link_path = rules_dir / f"{name}.mdc" if link_path.is_symlink() or link_path.exists(): link_path.unlink() @@ -100,7 +109,8 @@ def link_skills(self, skills: List[Dict], source_dir: Path, manifest: ToolManife return count def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int: - CURSOR_RULES_DIR.mkdir(parents=True, exist_ok=True) + rules_dir = _cursor_rules_dir() + rules_dir.mkdir(parents=True, exist_ok=True) count = 0 for skill in skills: name = skill.get("name", "unnamed") @@ -111,7 +121,7 @@ def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int: metadata["tags"] = skill["tags"] content = render_frontmatter(metadata, skill.get("body", "")) - path = CURSOR_RULES_DIR / f"{name}.mdc" + path = rules_dir / f"{name}.mdc" path.write_text(content, encoding="utf-8") manifest.record_skill(name, file_path=str(path.resolve()), content=content) count += 1 @@ -124,9 +134,10 @@ def apply_mcp_servers( manifest: ToolManifest, override: bool = False, ) -> int: - if CURSOR_MCP_JSON.exists(): + mcp_json = _cursor_mcp_json() + if mcp_json.exists(): try: - data = json.loads(CURSOR_MCP_JSON.read_text(encoding="utf-8")) + data = json.loads(mcp_json.read_text(encoding="utf-8")) except json.JSONDecodeError: data = {} else: @@ -136,8 +147,6 @@ def apply_mcp_servers( mcp_servers = {} else: mcp_servers = data.get("mcpServers", {}) - - # Prune orphaned MCP servers if not manifest.is_first_sync: current_names = {s.get("name", "unnamed") for s in servers} for orphan in set(manifest.managed_mcp_names()) - current_names: @@ -147,7 +156,6 @@ def apply_mcp_servers( count = 0 for server in servers: name = server.get("name", "unnamed") - env = server.get("env", {}).copy() for key, value in env.items(): if isinstance(value, str) and value.startswith("${") and value.endswith("}"): @@ -166,15 +174,15 @@ def apply_mcp_servers( count += 1 data["mcpServers"] = mcp_servers - CURSOR_MCP_JSON.parent.mkdir(parents=True, exist_ok=True) - CURSOR_MCP_JSON.write_text(json.dumps(data, indent=2), encoding="utf-8") + mcp_json.parent.mkdir(parents=True, exist_ok=True) + mcp_json.write_text(json.dumps(data, indent=2), encoding="utf-8") return count def _read_existing_memory_files(self) -> Dict[str, str]: - """Return {file_path: content} for Cursor's rule files.""" result = {} - if CURSOR_RULES_DIR.exists(): - for path in CURSOR_RULES_DIR.rglob("*.md*"): + rules_dir = _cursor_rules_dir() + if rules_dir.exists(): + for path in rules_dir.rglob("*.md*"): if path.is_file(): try: result[str(path)] = path.read_text(encoding="utf-8") diff --git a/src/appliers/openclaw.py b/src/appliers/openclaw.py index 2d85760..a252c4b 100644 --- a/src/appliers/openclaw.py +++ b/src/appliers/openclaw.py @@ -59,7 +59,11 @@ def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int: return count def apply_mcp_servers( - self, servers: List[Dict], secrets: Dict[str, str], manifest: ToolManifest + self, + servers: List[Dict], + secrets: Dict[str, str], + manifest: ToolManifest, + override: bool = False, ) -> int: # OpenClaw does not support MCP servers — it uses its own skill/tool system return 0 diff --git a/src/export_import.py b/src/export_import.py index eb56427..6741bef 100644 --- a/src/export_import.py +++ b/src/export_import.py @@ -28,8 +28,8 @@ save_skills, ) from config import get_config_dir -from marketplace import get_skills_dir from secrets_manager import retrieve_secret, store_secrets_batch +from skills import get_skills_dir from ui import error, header, info, success, warning SCHEMA_VERSION = 1 diff --git a/src/install.py b/src/install.py new file mode 100644 index 0000000..49fe984 --- /dev/null +++ b/src/install.py @@ -0,0 +1,197 @@ +"""apc install command — install skills from a GitHub repository. + +Handles the `apc install owner/repo` command and all its options. +""" + +from typing import List + +import click + +from appliers import get_applier +from cache import load_skills, merge_skills, save_skills +from extractors import detect_installed_tools +from skills import fetch_skill_from_repo, list_skills_in_repo, save_skill_file + +_AGENTS = ["claude-code", "cursor", "gemini-cli", "github-copilot", "openclaw", "windsurf"] + + +def _resolve_targets(target_args: tuple, yes: bool) -> List[str]: + """Resolve target targets from -a flags, '*', or interactive selection.""" + if not target_args: + detected = detect_installed_tools() + if not detected: + click.echo("No AI tools detected on this machine.", err=True) + return [] + if yes: + return detected + click.echo("\nDetected tools:") + for i, t in enumerate(detected, 1): + click.echo(f" {i}. {t}") + raw = click.prompt("Install to (e.g. 1,3 or 'all')", default="all") + if raw.strip().lower() == "all": + return detected + indices = [] + for part in raw.split(","): + part = part.strip() + if "-" in part: + a, b = part.split("-", 1) + indices.extend(range(int(a) - 1, int(b))) + elif part.isdigit(): + indices.append(int(part) - 1) + return [detected[i] for i in indices if 0 <= i < len(detected)] + + targets = list(target_args) + if "*" in targets: + return detect_installed_tools() + return targets + + +def _apply_skill_to_targets(skill: dict, target_list: list) -> int: + """Write a skill directly to each target's skill directory. Returns applied count.""" + + count = 0 + for target_name in target_list: + try: + applier = get_applier(target_name) + manifest = applier.get_manifest() + applied = applier.apply_skills([skill], manifest) + manifest.save() + count += applied + except Exception as e: + click.echo(f" ! {target_name}: {e}", err=True) + return count + + +@click.command() +@click.argument("repo") +@click.option( + "--skill", "-s", "skills", multiple=True, help="Skill name(s) to install. Use '*' for all." +) +@click.option("--all", "install_all", is_flag=True, help="Install all skills from the repo.") +@click.option( + "--target", + "-t", + "targets", + multiple=True, + help="Target tool(s) to install to. Use '*' for all detected.", +) +@click.option("--branch", default="main", show_default=True, help="Git branch to fetch from.") +@click.option( + "--list", + "list_only", + is_flag=True, + help="List available skills in the repo without installing.", +) +@click.option("-y", "--yes", is_flag=True, help="Non-interactive: skip all confirmation prompts.") +def install(repo, skills, install_all, targets, branch, list_only, yes): + """Install skills from a GitHub repository. + + \b + Examples: + apc install owner/repo --list + apc install owner/repo --skill frontend-design + apc install owner/repo --skill frontend-design --skill skill-creator + apc install owner/repo --skill '*' + apc install owner/repo --all + apc install owner/repo --skill frontend-design -t claude-code -t cursor + apc install owner/repo --all -t claude-code -y + """ + # Validate: repo must look like owner/repo + if "/" not in repo or repo.startswith("http"): + raise click.UsageError( + "REPO must be a GitHub repository name in owner/repo format" + " (e.g. vercel-labs/target-skills)" + ) + + # --list: just show available skills and exit + if list_only: + click.echo(f"Fetching skill list from {repo}...") + available = list_skills_in_repo(repo, branch) + if not available: + click.echo(f"No skills found in {repo} (branch: {branch}).", err=True) + return + click.echo(f"\nAvailable skills in {repo}:\n") + for name in available: + click.echo(f" • {name}") + click.echo(f"\n{len(available)} skill(s) found.") + return + + # Resolve which skills to install + if install_all or ("*" in skills): + click.echo(f"Fetching skill list from {repo}...") + skill_names = list_skills_in_repo(repo, branch) + if not skill_names: + click.echo(f"No skills found in {repo}.", err=True) + return + elif skills: + skill_names = list(skills) + else: + # No --skill or --all: show list and prompt + click.echo(f"Fetching skill list from {repo}...") + available = list_skills_in_repo(repo, branch) + if not available: + click.echo(f"No skills found in {repo}.", err=True) + return + click.echo(f"\nAvailable skills in {repo}:\n") + for i, name in enumerate(available, 1): + click.echo(f" {i}. {name}") + raw = click.prompt("\nWhich skills? (e.g. 1,3 or 'all')", default="all") + if raw.strip().lower() == "all": + skill_names = available + else: + indices = [] + for part in raw.split(","): + part = part.strip() + if "-" in part: + a, b = part.split("-", 1) + indices.extend(range(int(a) - 1, int(b))) + elif part.isdigit(): + indices.append(int(part) - 1) + skill_names = [available[i] for i in indices if 0 <= i < len(available)] + + if not skill_names: + click.echo("No skills selected.", err=True) + return + + # Resolve target targets + target_list = _resolve_targets(targets, yes) + if not target_list: + return + + # Confirm plan + if not yes: + click.echo(f"\nInstall {len(skill_names)} skill(s) from {repo}") + click.echo(f" Skills: {', '.join(skill_names)}") + click.echo(f" To: {', '.join(target_list)}") + if not click.confirm("\nProceed?", default=True): + click.echo("Cancelled.") + return + + # Fetch and install + installed_skills = [] + for skill_name in skill_names: + click.echo(f" Fetching {skill_name}...", nl=False) + skill = fetch_skill_from_repo(repo, skill_name, branch) + if not skill: + click.echo(f" not found in {repo}") + continue + + # Save to ~/.apc/skills//SKILL.md + raw_content = skill.pop("_raw_content", skill.get("body", "")) + save_skill_file(skill["name"], raw_content) + + # Apply directly to each target target + _apply_skill_to_targets(skill, target_list) + + # Save metadata to local cache + existing = load_skills() + merged = merge_skills(existing, [skill]) + save_skills(merged) + + installed_skills.append(skill["name"]) + click.echo(" ✓") + + if installed_skills: + click.echo(f"\n✓ Installed {len(installed_skills)} skill(s) to {', '.join(target_list)}") + else: + click.echo("\nNo skills were installed.") diff --git a/src/main.py b/src/main.py index 10de807..6526599 100644 --- a/src/main.py +++ b/src/main.py @@ -5,10 +5,10 @@ from cache import load_local_bundle from collect import collect from export_import import export_cmd, import_cmd +from install import install from llm_config import configure_cmd, models_cmd from mcp import mcp from memory import memory -from share import install from skill import skill from status import status from sync_helpers import count_installed_skills, resolve_target_tools, sync_all @@ -53,6 +53,7 @@ def cli(): # Memory cli.add_command(memory) + # Install cli.add_command(install) diff --git a/src/marketplace.py b/src/marketplace.py deleted file mode 100644 index 7848315..0000000 --- a/src/marketplace.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Marketplace management for skill installation. - -Manages a list of skill sources — GitHub repos (owner/repo) and local -directories — and fetches SKILL.md files from them. No auth required. - -Skills are stored as source-of-truth files in ~/.apc/skills//SKILL.md -and symlinked into each tool's directory. -""" - -import json -import os -from pathlib import Path -from typing import Any, Dict, List, Optional - -import httpx - -from config import get_config_dir -from frontmatter_parser import parse_frontmatter - -DEFAULT_MARKETPLACES = ["anthropics/skills"] -MARKETPLACES_FILENAME = "marketplaces.json" -DEFAULT_BRANCH = "main" - - -def _marketplaces_path() -> Path: - return get_config_dir() / MARKETPLACES_FILENAME - - -def load_marketplaces() -> List[str]: - """Load the list of configured marketplaces. Defaults to ['anthropics/skills'].""" - path = _marketplaces_path() - if not path.exists(): - return list(DEFAULT_MARKETPLACES) - try: - data = json.loads(path.read_text()) - if isinstance(data, list) and data: - return data - return list(DEFAULT_MARKETPLACES) - except (json.JSONDecodeError, TypeError): - return list(DEFAULT_MARKETPLACES) - - -def save_marketplaces(marketplaces: List[str]) -> None: - """Save the list of configured marketplaces.""" - path = _marketplaces_path() - path.write_text(json.dumps(marketplaces, indent=2)) - - -def add_marketplace(source: str) -> List[str]: - """Add a marketplace at highest priority (index 0). Returns updated list.""" - marketplaces = load_marketplaces() - if source in marketplaces: - marketplaces.remove(source) - marketplaces.insert(0, source) - save_marketplaces(marketplaces) - return marketplaces - - -def delete_marketplace(source: str) -> List[str]: - """Remove a marketplace from the list. Returns updated list.""" - marketplaces = load_marketplaces() - if source in marketplaces: - marketplaces.remove(source) - save_marketplaces(marketplaces) - return marketplaces - - -def is_local_path(source: str) -> bool: - """Return True if the source looks like a local directory path.""" - return ( - source.startswith("/") - or source.startswith("./") - or source.startswith("../") - or source.startswith("~") - ) - - -def fetch_skill_from_local(directory_path: str, skill_name: str) -> Optional[Dict[str, Any]]: - """Fetch and parse a SKILL.md from a local directory. - - Expects the file at /skills//SKILL.md. - Returns a skill dict compatible with the cache format, or None if not found. - """ - path = Path(os.path.expanduser(directory_path)) / "skills" / skill_name / "SKILL.md" - if not path.is_file(): - return None - - raw_content = path.read_text(encoding="utf-8") - metadata, body = parse_frontmatter(raw_content) - - return { - "name": metadata.get("name", skill_name), - "description": metadata.get("description", ""), - "body": body.strip(), - "tags": metadata.get("tags", []), - "targets": [], - "version": metadata.get("version", ""), - "source_tool": "local", - "source_repo": directory_path, - "_raw_content": raw_content, - } - - -def _build_skill_url(repo_slug: str, skill_name: str, branch: str = DEFAULT_BRANCH) -> str: - """Build the raw GitHub URL for a SKILL.md file.""" - return f"https://raw.githubusercontent.com/{repo_slug}/{branch}/skills/{skill_name}/SKILL.md" - - -def get_skills_dir() -> Path: - """Get or create the ~/.apc/skills/ directory (source of truth for installed skills).""" - skills_dir = get_config_dir() / "skills" - skills_dir.mkdir(exist_ok=True) - return skills_dir - - -def save_skill_file(skill_name: str, raw_content: str) -> Path: - """Save raw SKILL.md content to ~/.apc/skills//SKILL.md. - - Returns the path to the saved file. - """ - skill_dir = get_skills_dir() / skill_name - skill_dir.mkdir(exist_ok=True) - path = skill_dir / "SKILL.md" - path.write_text(raw_content, encoding="utf-8") - return path - - -def get_skill_source_path(skill_name: str) -> Path: - """Get the source-of-truth path for a skill.""" - return get_skills_dir() / skill_name / "SKILL.md" - - -def fetch_skill_from_repo( - repo_slug: str, - skill_name: str, - branch: str = DEFAULT_BRANCH, -) -> Optional[Dict[str, Any]]: - """Fetch and parse a SKILL.md from a GitHub repo. - - Returns a skill dict compatible with the local cache format, or None if not found. - The raw content is included under the '_raw_content' key for saving to disk. - """ - url = _build_skill_url(repo_slug, skill_name, branch) - try: - resp = httpx.get(url, follow_redirects=True, timeout=15) - if resp.status_code != 200: - return None - except httpx.HTTPError: - return None - - metadata, body = parse_frontmatter(resp.text) - - return { - "name": metadata.get("name", skill_name), - "description": metadata.get("description", ""), - "body": body.strip(), - "tags": metadata.get("tags", []), - "targets": [], - "version": metadata.get("version", ""), - "source_tool": "github", - "source_repo": repo_slug, - "_raw_content": resp.text, - } - - -def search_skill( - skill_name: str, - repos: Optional[List[str]] = None, - branch: str = DEFAULT_BRANCH, -) -> Optional[Dict[str, Any]]: - """Search for a skill across marketplaces in priority order. Returns first match.""" - if repos is None: - repos = load_marketplaces() - - for source in repos: - if is_local_path(source): - skill = fetch_skill_from_local(source, skill_name) - else: - skill = fetch_skill_from_repo(source, skill_name, branch) - if skill is not None: - return skill - - return None diff --git a/src/share.py b/src/share.py deleted file mode 100644 index 622052b..0000000 --- a/src/share.py +++ /dev/null @@ -1,91 +0,0 @@ -"""apc install and apc marketplace commands.""" - -import click - -from cache import load_skills, merge_skills, save_skills -from marketplace import ( - add_marketplace, - delete_marketplace, - is_local_path, - load_marketplaces, - save_skill_file, - search_skill, -) - - -@click.command() -@click.argument("skill_name") -@click.option( - "--repo", - default=None, - help="Specific marketplace source (owner/repo or local path) to fetch from", -) -@click.option("--branch", default="main", help="Git branch to fetch from (default: main)") -def install(skill_name, repo, branch): - """Install a skill from a marketplace. Usage: apc install """ - repos = [repo] if repo else None - - click.echo(f"Searching for '{skill_name}'...") - skill = search_skill(skill_name, repos=repos, branch=branch) - - if not skill: - source = repo if repo else "configured marketplaces" - click.echo(f"Skill '{skill_name}' not found in {source}.", err=True) - return - - click.echo(f"Found '{skill['name']}' in {skill['source_repo']}") - - # Save raw SKILL.md to source-of-truth directory (~/.apc/skills//SKILL.md) - raw_content = skill.pop("_raw_content", skill.get("body", "")) - save_skill_file(skill["name"], raw_content) - - # Save metadata to local cache - existing = load_skills() - merged = merge_skills(existing, [skill]) - save_skills(merged) - - click.echo(f"✓ Skill '{skill['name']}' saved. Run 'apc sync' to apply to your tools.") - - -# --- Marketplace management commands --- - - -@click.group() -def marketplace(): - """Manage skill marketplaces (GitHub repos or local directories).""" - pass - - -@marketplace.command("list") -def marketplace_list(): - """Show configured marketplaces.""" - sources = load_marketplaces() - if not sources: - click.echo("No marketplaces configured.") - return - for i, s in enumerate(sources): - priority = " (highest priority)" if i == 0 else "" - click.echo(f" {s}{priority}") - - -@marketplace.command("add") -@click.argument("source") -def marketplace_add(source): - """Add a marketplace (owner/repo or local directory path). Added at highest priority.""" - if not is_local_path(source): - parts = source.split("/") - if len(parts) != 2: - click.echo( - "Invalid format. Use: apc marketplace add or a local path", err=True - ) - return - add_marketplace(source) - click.echo(f"Added '{source}' (highest priority)") - - -@marketplace.command("delete") -@click.argument("source") -def marketplace_delete(source): - """Remove a marketplace.""" - delete_marketplace(source) - click.echo(f"Removed '{source}'") diff --git a/src/skills.py b/src/skills.py new file mode 100644 index 0000000..d612a79 --- /dev/null +++ b/src/skills.py @@ -0,0 +1,99 @@ +"""Skill installation — fetch skills from GitHub repos. + +Skills are stored in ~/.apc/skills//SKILL.md and linked into each +tool's skill directory on sync. +""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +import httpx + +from config import get_config_dir +from frontmatter_parser import parse_frontmatter + +DEFAULT_BRANCH = "main" +_GITHUB_TREE_API = "https://api.github.com/repos/{repo}/git/trees/{branch}?recursive=1" +_GITHUB_RAW = "https://raw.githubusercontent.com/{repo}/{branch}/skills/{skill}/SKILL.md" + + +# --------------------------------------------------------------------------- +# Skills directory +# --------------------------------------------------------------------------- + + +def get_skills_dir() -> Path: + """Get or create the ~/.apc/skills/ directory (source of truth for installed skills).""" + skills_dir = get_config_dir() / "skills" + skills_dir.mkdir(exist_ok=True) + return skills_dir + + +def save_skill_file(skill_name: str, raw_content: str) -> Path: + """Save raw SKILL.md to ~/.apc/skills//SKILL.md. Returns the path.""" + skill_dir = get_skills_dir() / skill_name + skill_dir.mkdir(exist_ok=True) + path = skill_dir / "SKILL.md" + path.write_text(raw_content, encoding="utf-8") + return path + + +# --------------------------------------------------------------------------- +# GitHub helpers +# --------------------------------------------------------------------------- + + +def list_skills_in_repo(repo: str, branch: str = DEFAULT_BRANCH) -> List[str]: + """Return names of all skills available in a GitHub repo. + + Expects skills under skills//SKILL.md in the repo tree. + Returns an empty list on network error or if no skills found. + """ + url = _GITHUB_TREE_API.format(repo=repo, branch=branch) + try: + resp = httpx.get(url, follow_redirects=True, timeout=15) + if resp.status_code != 200: + return [] + tree = resp.json().get("tree", []) + except (httpx.HTTPError, ValueError): + return [] + + names = [] + for item in tree: + path = item.get("path", "") + # Match: skills//SKILL.md + parts = path.split("/") + if len(parts) == 3 and parts[0] == "skills" and parts[2] == "SKILL.md": + names.append(parts[1]) + return sorted(names) + + +def fetch_skill_from_repo( + repo: str, + skill_name: str, + branch: str = DEFAULT_BRANCH, +) -> Optional[Dict[str, Any]]: + """Fetch and parse a single skill from a GitHub repo. + + Returns a skill dict (with _raw_content) or None if not found. + """ + url = _GITHUB_RAW.format(repo=repo, branch=branch, skill=skill_name) + try: + resp = httpx.get(url, follow_redirects=True, timeout=15) + if resp.status_code != 200: + return None + except httpx.HTTPError: + return None + + metadata, body = parse_frontmatter(resp.text) + return { + "name": metadata.get("name", skill_name), + "description": metadata.get("description", ""), + "body": body.strip(), + "tags": metadata.get("tags", []), + "targets": [], + "version": metadata.get("version", ""), + "source_tool": "github", + "source_repo": repo, + "_raw_content": resp.text, + } diff --git a/src/sync_helpers.py b/src/sync_helpers.py index e0c8517..ec575f0 100644 --- a/src/sync_helpers.py +++ b/src/sync_helpers.py @@ -8,8 +8,8 @@ from appliers import get_applier from cache import load_local_bundle, load_mcp_servers from extractors import detect_installed_tools -from marketplace import get_skills_dir from secrets_manager import retrieve_secret +from skills import get_skills_dir from ui import error, numbered_selection, success, warning diff --git a/src/ui.py b/src/ui.py index 3613a2e..36f8e18 100644 --- a/src/ui.py +++ b/src/ui.py @@ -7,6 +7,7 @@ import click from rich.console import Console +from rich.markup import escape from rich.panel import Panel from rich.table import Table from rich.text import Text @@ -301,7 +302,7 @@ def _skill_panel_content(skill: Dict) -> str: if body: if parts: parts.append("") - parts.append(body) + parts.append(escape(body)) return "\n".join(parts) if parts else "[dim]No content[/dim]" diff --git a/tests/test_appliers.py b/tests/test_appliers.py index a33db86..de27087 100644 --- a/tests/test_appliers.py +++ b/tests/test_appliers.py @@ -333,7 +333,7 @@ def test_apply_skills(self): ] manifest = self._manifest() - with patch("appliers.cursor.CURSOR_RULES_DIR", self.rules_dir): + with patch("appliers.cursor._cursor_rules_dir", return_value=self.rules_dir): from appliers.cursor import CursorApplier applier = CursorApplier() @@ -357,7 +357,7 @@ def test_apply_mcp_servers(self): ] manifest = self._manifest() - with patch("appliers.cursor.CURSOR_MCP_JSON", self.mcp_json): + with patch("appliers.cursor._cursor_mcp_json", return_value=self.mcp_json): from appliers.cursor import CursorApplier applier = CursorApplier() diff --git a/tests/test_docker_integration.py b/tests/test_docker_integration.py index a521dd3..83f1b44 100644 --- a/tests/test_docker_integration.py +++ b/tests/test_docker_integration.py @@ -187,14 +187,17 @@ def test_exits_zero(self, runner, cli): def test_detects_claude(self, runner, cli): result = runner.invoke(cli, ["status"]) assert "claude" in result.output.lower() + assert (HOME / ".claude").is_dir() def test_detects_cursor(self, runner, cli): result = runner.invoke(cli, ["status"]) assert "cursor" in result.output.lower() + assert (HOME / ".cursor").is_dir() def test_detects_gemini(self, runner, cli): result = runner.invoke(cli, ["status"]) assert "gemini" in result.output.lower() + assert (HOME / ".gemini").is_dir() def test_detects_copilot(self, runner, cli): result = runner.invoke(cli, ["status"]) @@ -203,10 +206,12 @@ def test_detects_copilot(self, runner, cli): def test_detects_windsurf(self, runner, cli): result = runner.invoke(cli, ["status"]) assert "windsurf" in result.output.lower() + assert (HOME / ".codeium" / "windsurf").is_dir() def test_detects_openclaw(self, runner, cli): result = runner.invoke(cli, ["status"]) assert "openclaw" in result.output.lower() + assert (HOME / ".openclaw").is_dir() # --------------------------------------------------------------------------- @@ -218,6 +223,11 @@ class TestCollect: def test_collect_exits_zero(self, runner, cli): result = runner.invoke(cli, ["collect", "--yes"]) assert result.exit_code == 0, result.output + cache_dir = HOME / ".apc" / "cache" + assert cache_dir.is_dir() + assert (cache_dir / "skills.json").exists() + assert (cache_dir / "mcp_servers.json").exists() + assert (cache_dir / "memory.json").exists() def test_cache_skills_json_created(self, runner, cli): runner.invoke(cli, ["collect", "--yes"]) @@ -299,23 +309,34 @@ def _ensure_collected(self, runner, cli): def test_skill_list_exits_zero(self, runner, cli): result = runner.invoke(cli, ["skill", "list"]) assert result.exit_code == 0, result.output + assert (HOME / ".apc" / "cache" / "skills.json").exists() def test_skill_list_shows_test_skill(self, runner, cli): result = runner.invoke(cli, ["skill", "list"]) assert "test-skill" in result.output + data = json.loads((HOME / ".apc" / "cache" / "skills.json").read_text()) + names = [s["name"] for s in data] + assert "test-skill" in names def test_skill_list_shows_oc_skill(self, runner, cli): result = runner.invoke(cli, ["skill", "list"]) assert "oc-skill" in result.output + data = json.loads((HOME / ".apc" / "cache" / "skills.json").read_text()) + names = [s["name"] for s in data] + assert "oc-skill" in names def test_skill_show_exits_zero(self, runner, cli): result = runner.invoke(cli, ["skill", "show"]) assert result.exit_code == 0, result.output + assert (HOME / ".apc" / "cache" / "skills.json").exists() def test_skill_show_by_name(self, runner, cli): result = runner.invoke(cli, ["skill", "show", "test-skill"]) assert result.exit_code == 0 assert "test skill" in result.output.lower() or "test-skill" in result.output.lower() + # Skill must be in cache to be displayed + data = json.loads((HOME / ".apc" / "cache" / "skills.json").read_text()) + assert any(s["name"] == "test-skill" for s in data) # --------------------------------------------------------------------------- @@ -331,17 +352,22 @@ def _ensure_collected(self, runner, cli): def test_memory_list_exits_zero(self, runner, cli): result = runner.invoke(cli, ["memory", "list"]) assert result.exit_code == 0, result.output + assert (HOME / ".apc" / "cache" / "memory.json").exists() def test_memory_add_exits_zero(self, runner, cli): result = runner.invoke( cli, ["memory", "add", "Docker test pref", "--category", "preference"] ) assert result.exit_code == 0, result.output + assert (HOME / ".apc" / "cache" / "memory.json").exists() def test_memory_add_persists(self, runner, cli): runner.invoke(cli, ["memory", "add", "Docker test pref", "--category", "preference"]) result = runner.invoke(cli, ["memory", "list"]) assert "Docker test pref" in result.output + data = json.loads((HOME / ".apc" / "cache" / "memory.json").read_text()) + contents = " ".join(e.get("content", "") + e.get("body", "") for e in data) + assert "Docker test pref" in contents def test_memory_add_writes_to_cache(self, runner, cli): runner.invoke(cli, ["memory", "add", "Unique docker mem", "--category", "workflow"]) @@ -352,11 +378,13 @@ def test_memory_add_writes_to_cache(self, runner, cli): def test_memory_show_exits_zero(self, runner, cli): result = runner.invoke(cli, ["memory", "show"]) assert result.exit_code == 0, result.output + assert (HOME / ".apc" / "cache" / "memory.json").exists() def test_memory_list_shows_collected_files(self, runner, cli): result = runner.invoke(cli, ["memory", "list"]) - # Should show raw-file entries from claude and openclaw assert "claude" in result.output.lower() or "openclaw" in result.output.lower() + data = json.loads((HOME / ".apc" / "cache" / "memory.json").read_text()) + assert len(data) > 0, "memory.json is empty after collect" # --------------------------------------------------------------------------- @@ -372,14 +400,21 @@ def _ensure_collected(self, runner, cli): def test_mcp_list_exits_zero(self, runner, cli): result = runner.invoke(cli, ["mcp", "list"]) assert result.exit_code == 0, result.output + assert (HOME / ".apc" / "cache" / "mcp_servers.json").exists() def test_mcp_list_shows_servers(self, runner, cli): result = runner.invoke(cli, ["mcp", "list"]) assert "test-claude-mcp" in result.output + data = json.loads((HOME / ".apc" / "cache" / "mcp_servers.json").read_text()) + names = [s["name"] for s in data] + assert "test-claude-mcp" in names def test_mcp_remove_exits_zero(self, runner, cli): result = runner.invoke(cli, ["mcp", "remove", "test-claude-mcp", "-y"]) assert result.exit_code == 0, result.output + data = json.loads((HOME / ".apc" / "cache" / "mcp_servers.json").read_text()) + names = [s["name"] for s in data] + assert "test-claude-mcp" not in names def test_mcp_remove_deletes_from_cache(self, runner, cli): runner.invoke(cli, ["mcp", "remove", "test-claude-mcp", "-y"]) @@ -396,8 +431,11 @@ def test_mcp_list_after_remove(self, runner, cli): runner.invoke(cli, ["mcp", "remove", "test-claude-mcp", "-y"]) result = runner.invoke(cli, ["mcp", "list"]) assert "test-claude-mcp" not in result.output - # Other servers should still be there assert "test-cursor-mcp" in result.output + data = json.loads((HOME / ".apc" / "cache" / "mcp_servers.json").read_text()) + names = [s["name"] for s in data] + assert "test-claude-mcp" not in names + assert "test-cursor-mcp" in names # --------------------------------------------------------------------------- @@ -413,6 +451,9 @@ def _ensure_collected(self, runner, cli): def test_sync_to_claude_exits_zero(self, runner, cli): result = runner.invoke(cli, ["sync", "--tools", "claude-code", "--yes", "--no-memory"]) assert result.exit_code == 0, result.output + assert (HOME / ".claude.json").exists() + data = json.loads((HOME / ".claude.json").read_text()) + assert "mcpServers" in data def test_sync_writes_claude_json_mcp(self, runner, cli): runner.invoke(cli, ["sync", "--tools", "claude-code", "--yes", "--no-memory"]) @@ -432,6 +473,13 @@ def test_sync_to_cursor_exits_zero(self, runner, cli): cli, ["sync", "--tools", "cursor", "--yes", "--no-memory", "--override-mcp"] ) assert result.exit_code == 0, result.output + assert (HOME / ".cursor" / "mcp.json").exists() + data = json.loads((HOME / ".cursor" / "mcp.json").read_text()) + assert "mcpServers" in data + assert len(data["mcpServers"]) > 0 + rules_dir = HOME / ".cursor" / "rules" + assert rules_dir.is_dir() + assert len(list(rules_dir.glob("*.mdc"))) > 0, "No .mdc skill files written to cursor" def test_sync_writes_cursor_mcp(self, runner, cli): runner.invoke(cli, ["sync", "--tools", "cursor", "--yes", "--no-memory", "--override-mcp"]) @@ -440,9 +488,11 @@ def test_sync_writes_cursor_mcp(self, runner, cli): assert len(data["mcpServers"]) > 0 def test_sync_dry_run(self, runner, cli): + claude_before = (HOME / ".claude.json").read_text() result = runner.invoke(cli, ["sync", "--dry-run", "--all", "--yes"]) assert result.exit_code == 0 assert "no files written" in result.output.lower() + assert (HOME / ".claude.json").read_text() == claude_before, "dry-run modified .claude.json" def test_sync_dry_run_does_not_modify_files(self, runner, cli): # Record state before @@ -465,6 +515,9 @@ def _ensure_collected(self, runner, cli): def test_mcp_sync_exits_zero(self, runner, cli): result = runner.invoke(cli, ["mcp", "sync", "--tools", "claude-code", "--yes"]) assert result.exit_code == 0, result.output + data = json.loads((HOME / ".claude.json").read_text()) + assert "mcpServers" in data + assert len(data["mcpServers"]) > 0 def test_mcp_sync_writes_servers(self, runner, cli): runner.invoke(cli, ["mcp", "sync", "--tools", "claude-code", "--yes"]) @@ -475,6 +528,9 @@ def test_mcp_sync_writes_servers(self, runner, cli): def test_skill_sync_exits_zero(self, runner, cli): result = runner.invoke(cli, ["skill", "sync", "--tools", "claude-code", "--yes"]) assert result.exit_code == 0, result.output + commands_dir = HOME / ".claude" / "commands" + assert commands_dir.is_dir() + assert len(list(commands_dir.glob("*.md"))) > 0, "No skill files written to claude commands" def test_skill_sync_writes_skill_files(self, runner, cli): runner.invoke(cli, ["skill", "sync", "--tools", "claude-code", "--yes"]) @@ -492,10 +548,12 @@ class TestModels: def test_models_status_exits_zero(self, runner, cli): result = runner.invoke(cli, ["model", "status"]) assert result.exit_code == 0, result.output + assert (HOME / ".apc").is_dir() def test_models_list_exits_zero(self, runner, cli): result = runner.invoke(cli, ["model", "list"]) assert result.exit_code == 0, result.output + assert (HOME / ".apc").is_dir() def test_models_set(self, runner, cli): result = runner.invoke(cli, ["model", "set", "anthropic/claude-sonnet-4-6"]) @@ -530,6 +588,10 @@ def test_configure_non_interactive(self, runner, cli): ], ) assert result.exit_code == 0, result.output + auth_path = HOME / ".apc" / "auth-profiles.json" + assert auth_path.exists(), "auth-profiles.json not written by configure" + data = json.loads(auth_path.read_text()) + assert any("anthropic" in k for k in data.get("profiles", {})) def test_configure_writes_auth_profile(self, runner, cli): runner.invoke( @@ -567,24 +629,252 @@ def test_configure_writes_models_json(self, runner, cli): # --------------------------------------------------------------------------- -# Phase 11: apc install (network-dependent, graceful failure) +# Phase 11: apc install (GitHub repo-first UX) +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# Phase 11: apc install (real GitHub network calls, no mocks) # --------------------------------------------------------------------------- class TestInstall: - def test_install_nonexistent_fails_gracefully(self, runner, cli): - result = runner.invoke(cli, ["install", "test-nonexistent-skill-xyz"]) - # Should not crash — either exit 0 with "not found" message - # or exit 1 but with a clean error message - combined = result.output - assert "not found" in combined.lower() or result.exit_code == 0 + """Real-command tests for apc install. + + Uses anthropics/skills as the test repo — a stable public repo with known skills. + All commands invoke the real GitHub API and write real files. + """ + + TEST_REPO = "anthropics/skills" + KNOWN_SKILL = "pdf" # small, stable skill + + def test_install_invalid_repo_url(self, runner, cli): + """Full GitHub URLs are rejected — must be owner/repo slug.""" + result = runner.invoke(cli, ["install", "https://github.com/anthropics/skills"]) + assert result.exit_code != 0 + assert "owner/repo format" in result.output.lower() + + def test_install_invalid_no_slash(self, runner, cli): + """A bare name with no slash is rejected immediately.""" + result = runner.invoke(cli, ["install", "notaslug"]) + assert result.exit_code != 0 + + def test_install_list_real_repo(self, runner, cli): + """--list fetches and prints the real skill index from GitHub.""" + result = runner.invoke(cli, ["install", self.TEST_REPO, "--list"]) + assert result.exit_code == 0 + assert "•" in result.output + assert "skill(s) found" in result.output + assert self.KNOWN_SKILL in result.output + # --list is read-only: nothing written to ~/.apc/skills/ + skills_dir = Path.home() / ".apc" / "skills" + if skills_dir.exists(): + assert self.KNOWN_SKILL not in [d.name for d in skills_dir.iterdir()] + + def test_install_single_skill(self, runner, cli, tmp_path, monkeypatch): + """Install one real skill — verifies cache entry and SKILL.md on disk.""" + monkeypatch.setenv("HOME", str(tmp_path)) + result = runner.invoke( + cli, + ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "-t", "cursor", "-y"], + ) + assert result.exit_code == 0, result.output + assert "✓" in result.output + skill_file = tmp_path / ".apc" / "skills" / self.KNOWN_SKILL / "SKILL.md" + assert skill_file.exists(), "SKILL.md not written to ~/.apc/skills/" + assert len(skill_file.read_text()) > 0 + + def test_install_multiple_skills(self, runner, cli, tmp_path, monkeypatch): + """Install two real skills in one command.""" + monkeypatch.setenv("HOME", str(tmp_path)) + result = runner.invoke( + cli, + [ + "install", + self.TEST_REPO, + "--skill", + "pdf", + "--skill", + "skill-creator", + "-t", + "cursor", + "-y", + ], + ) + assert result.exit_code == 0, result.output + assert "Installed 2 skill(s)" in result.output + assert (tmp_path / ".apc" / "skills" / "pdf" / "SKILL.md").exists() + assert (tmp_path / ".apc" / "skills" / "skill-creator" / "SKILL.md").exists() + + def test_install_nonexistent_skill(self, runner, cli, tmp_path, monkeypatch): + """A skill name that does not exist in the repo prints a clear message.""" + monkeypatch.setenv("HOME", str(tmp_path)) + result = runner.invoke( + cli, + [ + "install", + self.TEST_REPO, + "--skill", + "totally-nonexistent-xyz", + "-t", + "cursor", + "-y", + ], + ) + assert result.exit_code == 0 # not a crash — graceful message + assert ( + "not found" in result.output.lower() + or "no skills were installed" in result.output.lower() + ) + + def test_install_all(self, runner, cli, tmp_path, monkeypatch): + """--all installs every skill from the repo.""" + monkeypatch.setenv("HOME", str(tmp_path)) + result = runner.invoke(cli, ["install", self.TEST_REPO, "--all", "-t", "cursor", "-y"]) + assert result.exit_code == 0, result.output + assert "✓" in result.output + skills_dir = tmp_path / ".apc" / "skills" + installed = list(skills_dir.iterdir()) + assert len(installed) > 5, f"Expected >5 skills installed, got {len(installed)}" + + def test_install_yes_skips_confirmation(self, runner, cli, tmp_path, monkeypatch): + """-y completes without showing a Proceed? prompt.""" + monkeypatch.setenv("HOME", str(tmp_path)) + result = runner.invoke( + cli, + ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "-t", "cursor", "-y"], + ) + assert result.exit_code == 0 + assert "Proceed?" not in result.output + skill_md = tmp_path / ".apc" / "skills" / self.KNOWN_SKILL / "SKILL.md" + assert skill_md.exists(), "SKILL.md not written even with -y" + assert len(skill_md.read_text()) > 0 + + def test_install_target_all_agents(self, runner, cli, tmp_path, monkeypatch): + """--target '*' installs to all detected tools.""" + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / ".cursor").mkdir() + result = runner.invoke( + cli, + ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "--target", "*", "-y"], + ) + assert result.exit_code == 0, result.output + assert "✓" in result.output + skill_md = tmp_path / ".apc" / "skills" / self.KNOWN_SKILL / "SKILL.md" + assert skill_md.exists(), "SKILL.md not written when targeting all agents" # --------------------------------------------------------------------------- -# Phase 12: Full round-trip — collect → sync → verify files +# Phase 12: install → sync end-to-end flow (no mocks) # --------------------------------------------------------------------------- +class TestInstallThenSync: + """Real end-to-end install → sync flow. + + Installs real skills from GitHub, runs apc sync, and verifies the + resulting file-system state in the target tool's directory. + """ + + TEST_REPO = "anthropics/skills" + KNOWN_SKILL = "pdf" + + def test_install_then_sync_symlinks_skill_to_tool(self, runner, cli, tmp_path, monkeypatch): + """Skill installed via apc install is symlinked into tool dir after apc sync.""" + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / ".cursor").mkdir() + (tmp_path / ".cursor" / "mcp.json").write_text("{}") + + r1 = runner.invoke( + cli, ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "-t", "cursor", "-y"] + ) + assert r1.exit_code == 0, r1.output + + r2 = runner.invoke(cli, ["sync", "--tools", "cursor", "--yes"]) + assert r2.exit_code == 0, r2.output + + cursor_skill = tmp_path / ".cursor" / "rules" / f"{self.KNOWN_SKILL}.mdc" + assert cursor_skill.exists(), f"Skill not found at {cursor_skill} after sync" + + def test_installed_skill_appears_in_skill_list(self, runner, cli, tmp_path, monkeypatch): + """Installed skill appears in apc skill list immediately after install.""" + monkeypatch.setenv("HOME", str(tmp_path)) + + runner.invoke( + cli, ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "-t", "cursor", "-y"] + ) + + result = runner.invoke(cli, ["skill", "list"]) + assert result.exit_code == 0 + assert self.KNOWN_SKILL in result.output + skill_md = tmp_path / ".apc" / "skills" / self.KNOWN_SKILL / "SKILL.md" + assert skill_md.exists(), "SKILL.md missing after install" + assert len(skill_md.read_text()) > 0 + + def test_install_multiple_then_sync_all_land_in_tool(self, runner, cli, tmp_path, monkeypatch): + """All installed skills land in the tool directory after sync.""" + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / ".cursor").mkdir() + (tmp_path / ".cursor" / "mcp.json").write_text("{}") + + skills = ["pdf", "skill-creator"] + r_install = runner.invoke( + cli, + [ + "install", + self.TEST_REPO, + "--skill", + skills[0], + "--skill", + skills[1], + "-t", + "cursor", + "-y", + ], + ) + assert r_install.exit_code == 0, r_install.output + assert "Installed 2 skill(s)" in r_install.output + + r_sync = runner.invoke(cli, ["sync", "--tools", "cursor", "--yes"]) + assert r_sync.exit_code == 0, r_sync.output + + rules_dir = tmp_path / ".cursor" / "rules" + for name in skills: + assert (rules_dir / f"{name}.mdc").exists(), ( + f"Skill {name} missing from cursor after sync" + ) + + def test_install_all_then_sync_dry_run(self, runner, cli, tmp_path, monkeypatch): + """Install all skills then dry-run sync — no files written but plan is shown.""" + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / ".cursor").mkdir() + (tmp_path / ".cursor" / "mcp.json").write_text("{}") + + runner.invoke(cli, ["install", self.TEST_REPO, "--all", "-t", "cursor", "-y"]) + + installed_count = len(list((tmp_path / ".apc" / "skills").iterdir())) + assert installed_count > 5 + + r_sync = runner.invoke(cli, ["sync", "--tools", "cursor", "--dry-run"]) + assert r_sync.exit_code == 0 + assert "No files written" in r_sync.output or "dry-run" in r_sync.output.lower() + + def test_status_synced_after_install_and_sync(self, runner, cli, tmp_path, monkeypatch): + """apc status shows cursor as synced after a full install + sync cycle.""" + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / ".cursor").mkdir() + (tmp_path / ".cursor" / "mcp.json").write_text("{}") + + runner.invoke( + cli, ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "-t", "cursor", "-y"] + ) + runner.invoke(cli, ["sync", "--tools", "cursor", "--yes"]) + + r_status = runner.invoke(cli, ["status"]) + assert r_status.exit_code == 0 + assert "synced" in r_status.output.lower() + + class TestRoundTrip: """Verify the full collect → sync → read-back cycle.""" @@ -666,6 +956,10 @@ def export_path(self, tmp_path): def test_export_exits_zero(self, runner, cli, export_path): result = runner.invoke(cli, ["export", str(export_path), "--yes"]) assert result.exit_code == 0, result.output + assert (export_path / "apc-export.json").exists(), "apc-export.json not created" + assert (export_path / "cache").is_dir(), "cache/ dir not created" + assert (export_path / "cache" / "skills.json").exists() + assert (export_path / "cache" / "mcp_servers.json").exists() def test_export_creates_metadata(self, runner, cli, export_path): runner.invoke(cli, ["export", str(export_path), "--yes"]) @@ -779,6 +1073,8 @@ def test_import_exits_zero(self, runner, cli, export_path): self._do_export(runner, cli, export_path) result = runner.invoke(cli, ["import", str(export_path), "--yes"]) assert result.exit_code == 0, result.output + assert (HOME / ".apc" / "cache" / "skills.json").exists() + assert (HOME / ".apc" / "cache" / "mcp_servers.json").exists() def test_import_invalid_path(self, runner, cli, tmp_path): result = runner.invoke(cli, ["import", str(tmp_path / "nonexistent"), "--yes"]) @@ -788,11 +1084,14 @@ def test_import_suggests_sync(self, runner, cli, export_path): self._do_export(runner, cli, export_path) result = runner.invoke(cli, ["import", str(export_path), "--yes"]) assert "apc sync" in result.output + assert (HOME / ".apc" / "cache" / "skills.json").exists() def test_import_no_secrets_flag(self, runner, cli, export_path): self._do_export(runner, cli, export_path) result = runner.invoke(cli, ["import", str(export_path), "--no-secrets", "--yes"]) assert result.exit_code == 0, result.output + assert (HOME / ".apc" / "cache" / "skills.json").exists() + assert (HOME / ".apc" / "cache" / "mcp_servers.json").exists() class TestExportImportRoundTrip: diff --git a/tests/test_marketplace.py b/tests/test_marketplace.py deleted file mode 100644 index d92e652..0000000 --- a/tests/test_marketplace.py +++ /dev/null @@ -1,437 +0,0 @@ -"""Unit tests for marketplace management, skill fetching, and symlink installation.""" - -import os -import tempfile -import unittest -from pathlib import Path -from unittest.mock import MagicMock, patch - -from marketplace import ( - DEFAULT_MARKETPLACES, - _build_skill_url, - add_marketplace, - delete_marketplace, - fetch_skill_from_local, - fetch_skill_from_repo, - get_skills_dir, - is_local_path, - load_marketplaces, - save_marketplaces, - save_skill_file, - search_skill, -) - -SAMPLE_SKILL_MD = """\ ---- -name: pdf -description: Extract and analyze PDF files -tags: - - utility -version: "1.0.0" ---- - -Use this skill to handle PDF files. Read them with the Read tool. -""" - - -class TestMarketplaceConfig(unittest.TestCase): - """Tests for marketplace CRUD operations.""" - - def setUp(self): - self.tmpdir = tempfile.mkdtemp() - self.config_dir = Path(self.tmpdir) - self.patcher = patch( - "marketplace.get_config_dir", - return_value=self.config_dir, - ) - self.patcher.start() - - def tearDown(self): - self.patcher.stop() - - def test_load_defaults_when_no_file(self): - marketplaces = load_marketplaces() - self.assertEqual(marketplaces, list(DEFAULT_MARKETPLACES)) - - def test_save_and_load(self): - save_marketplaces(["myorg/skills", "anthropics/skills"]) - marketplaces = load_marketplaces() - self.assertEqual(marketplaces, ["myorg/skills", "anthropics/skills"]) - - def test_load_falls_back_on_invalid_json(self): - (self.config_dir / "marketplaces.json").write_text("not json") - marketplaces = load_marketplaces() - self.assertEqual(marketplaces, list(DEFAULT_MARKETPLACES)) - - def test_load_falls_back_on_empty_list(self): - (self.config_dir / "marketplaces.json").write_text("[]") - marketplaces = load_marketplaces() - self.assertEqual(marketplaces, list(DEFAULT_MARKETPLACES)) - - def test_add_marketplace_inserts_at_front(self): - save_marketplaces(["anthropics/skills"]) - marketplaces = add_marketplace("myorg/tools") - self.assertEqual(marketplaces[0], "myorg/tools") - self.assertIn("anthropics/skills", marketplaces) - - def test_add_existing_marketplace_moves_to_front(self): - save_marketplaces(["a/b", "c/d"]) - marketplaces = add_marketplace("c/d") - self.assertEqual(marketplaces, ["c/d", "a/b"]) - - def test_delete_marketplace(self): - save_marketplaces(["a/b", "c/d"]) - marketplaces = delete_marketplace("a/b") - self.assertEqual(marketplaces, ["c/d"]) - - def test_delete_nonexistent_is_safe(self): - save_marketplaces(["a/b"]) - marketplaces = delete_marketplace("x/y") - self.assertEqual(marketplaces, ["a/b"]) - - -class TestLocalDirectory(unittest.TestCase): - """Tests for local directory support.""" - - def test_is_local_path_absolute(self): - self.assertTrue(is_local_path("/home/user/skills")) - - def test_is_local_path_relative_dot(self): - self.assertTrue(is_local_path("./my-skills")) - - def test_is_local_path_relative_dotdot(self): - self.assertTrue(is_local_path("../my-skills")) - - def test_is_local_path_home(self): - self.assertTrue(is_local_path("~/my-skills")) - - def test_is_local_path_github_repo(self): - self.assertFalse(is_local_path("anthropics/skills")) - - def test_fetch_skill_from_local_success(self): - with tempfile.TemporaryDirectory() as tmpdir: - skill_dir = Path(tmpdir) / "skills" / "pdf" - skill_dir.mkdir(parents=True) - (skill_dir / "SKILL.md").write_text(SAMPLE_SKILL_MD) - - skill = fetch_skill_from_local(tmpdir, "pdf") - - self.assertIsNotNone(skill) - self.assertEqual(skill["name"], "pdf") - self.assertEqual(skill["description"], "Extract and analyze PDF files") - self.assertIn("PDF files", skill["body"]) - self.assertEqual(skill["source_tool"], "local") - self.assertEqual(skill["source_repo"], tmpdir) - self.assertEqual(skill["_raw_content"], SAMPLE_SKILL_MD) - - def test_fetch_skill_from_local_not_found(self): - with tempfile.TemporaryDirectory() as tmpdir: - skill = fetch_skill_from_local(tmpdir, "nonexistent") - self.assertIsNone(skill) - - def test_search_skill_mixed_sources(self): - """Search dispatches to local fetch for local paths and repo fetch for GitHub slugs.""" - with tempfile.TemporaryDirectory() as tmpdir: - # Create a local skill - skill_dir = Path(tmpdir) / "skills" / "pdf" - skill_dir.mkdir(parents=True) - (skill_dir / "SKILL.md").write_text(SAMPLE_SKILL_MD) - - result = search_skill("pdf", repos=[tmpdir]) - self.assertIsNotNone(result) - self.assertEqual(result["source_tool"], "local") - - @patch("marketplace.fetch_skill_from_repo") - def test_search_falls_through_local_to_repo(self, mock_fetch): - """If local directory doesn't have the skill, fall through to GitHub repo.""" - mock_fetch.return_value = {"name": "pdf", "source_repo": "a/skills"} - - with tempfile.TemporaryDirectory() as tmpdir: - result = search_skill("pdf", repos=[tmpdir, "a/skills"]) - self.assertEqual(result["source_repo"], "a/skills") - mock_fetch.assert_called_once_with("a/skills", "pdf", "main") - - -class TestUrlBuilding(unittest.TestCase): - """Tests for raw GitHub URL construction.""" - - def test_default_branch(self): - url = _build_skill_url("anthropics/skills", "pdf") - self.assertEqual( - url, - "https://raw.githubusercontent.com/anthropics/skills/main/skills/pdf/SKILL.md", - ) - - def test_custom_branch(self): - url = _build_skill_url("myorg/tools", "commit", branch="develop") - self.assertEqual( - url, - "https://raw.githubusercontent.com/myorg/tools/develop/skills/commit/SKILL.md", - ) - - -class TestFetchSkill(unittest.TestCase): - """Tests for fetching and parsing SKILL.md from GitHub.""" - - @patch("marketplace.httpx.get") - def test_fetch_success(self, mock_get): - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.text = SAMPLE_SKILL_MD - mock_get.return_value = mock_resp - - skill = fetch_skill_from_repo("anthropics/skills", "pdf") - - self.assertIsNotNone(skill) - self.assertEqual(skill["name"], "pdf") - self.assertEqual(skill["description"], "Extract and analyze PDF files") - self.assertIn("PDF files", skill["body"]) - self.assertEqual(skill["tags"], ["utility"]) - self.assertEqual(skill["targets"], []) - self.assertEqual(skill["version"], "1.0.0") - self.assertEqual(skill["source_tool"], "github") - self.assertEqual(skill["source_repo"], "anthropics/skills") - self.assertEqual(skill["_raw_content"], SAMPLE_SKILL_MD) - - mock_get.assert_called_once_with( - "https://raw.githubusercontent.com/anthropics/skills/main/skills/pdf/SKILL.md", - follow_redirects=True, - timeout=15, - ) - - @patch("marketplace.httpx.get") - def test_fetch_not_found(self, mock_get): - mock_resp = MagicMock() - mock_resp.status_code = 404 - mock_get.return_value = mock_resp - - skill = fetch_skill_from_repo("anthropics/skills", "nonexistent") - self.assertIsNone(skill) - - @patch("marketplace.httpx.get") - def test_fetch_network_error(self, mock_get): - import httpx - - mock_get.side_effect = httpx.ConnectError("connection refused") - - skill = fetch_skill_from_repo("anthropics/skills", "pdf") - self.assertIsNone(skill) - - @patch("marketplace.httpx.get") - def test_fetch_no_frontmatter(self, mock_get): - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.text = "Just plain markdown content." - mock_get.return_value = mock_resp - - skill = fetch_skill_from_repo("anthropics/skills", "simple") - self.assertIsNotNone(skill) - self.assertEqual(skill["name"], "simple") # falls back to skill_name arg - self.assertEqual(skill["body"], "Just plain markdown content.") - - -class TestSearchSkill(unittest.TestCase): - """Tests for searching across multiple marketplaces.""" - - @patch("marketplace.fetch_skill_from_repo") - def test_search_returns_first_match(self, mock_fetch): - skill_a = {"name": "pdf", "source_repo": "a/skills"} - skill_b = {"name": "pdf", "source_repo": "b/skills"} - mock_fetch.side_effect = [skill_a, skill_b] - - result = search_skill("pdf", repos=["a/skills", "b/skills"]) - self.assertEqual(result["source_repo"], "a/skills") - # Should only call once since first repo matched - mock_fetch.assert_called_once_with("a/skills", "pdf", "main") - - @patch("marketplace.fetch_skill_from_repo") - def test_search_falls_through_to_second_repo(self, mock_fetch): - mock_fetch.side_effect = [None, {"name": "pdf", "source_repo": "b/skills"}] - - result = search_skill("pdf", repos=["a/skills", "b/skills"]) - self.assertEqual(result["source_repo"], "b/skills") - self.assertEqual(mock_fetch.call_count, 2) - - @patch("marketplace.fetch_skill_from_repo") - def test_search_returns_none_when_not_found(self, mock_fetch): - mock_fetch.return_value = None - - result = search_skill("pdf", repos=["a/skills"]) - self.assertIsNone(result) - - @patch("marketplace.fetch_skill_from_repo") - def test_search_uses_custom_branch(self, mock_fetch): - mock_fetch.return_value = {"name": "pdf", "source_repo": "a/skills"} - - search_skill("pdf", repos=["a/skills"], branch="develop") - mock_fetch.assert_called_once_with("a/skills", "pdf", "develop") - - @patch("marketplace.load_marketplaces", return_value=["anthropics/skills"]) - @patch("marketplace.fetch_skill_from_repo") - def test_search_uses_default_marketplaces(self, mock_fetch, mock_load): - mock_fetch.return_value = {"name": "pdf", "source_repo": "anthropics/skills"} - - search_skill("pdf") - mock_load.assert_called_once() - mock_fetch.assert_called_once_with("anthropics/skills", "pdf", "main") - - -class TestSkillStorage(unittest.TestCase): - """Tests for saving skill files to source-of-truth directory.""" - - def setUp(self): - self.tmpdir = tempfile.mkdtemp() - self.config_dir = Path(self.tmpdir) - self.patcher = patch( - "marketplace.get_config_dir", - return_value=self.config_dir, - ) - self.patcher.start() - - def tearDown(self): - self.patcher.stop() - - def test_get_skills_dir_creates_directory(self): - skills_dir = get_skills_dir() - self.assertTrue(skills_dir.exists()) - self.assertEqual(skills_dir, self.config_dir / "skills") - - def test_save_skill_file(self): - path = save_skill_file("pdf", SAMPLE_SKILL_MD) - self.assertTrue(path.exists()) - self.assertEqual(path, self.config_dir / "skills" / "pdf" / "SKILL.md") - self.assertEqual(path.read_text(), SAMPLE_SKILL_MD) - - def test_save_skill_file_overwrites(self): - save_skill_file("pdf", "old content") - path = save_skill_file("pdf", "new content") - self.assertEqual(path.read_text(), "new content") - - -class TestLinkSkills(unittest.TestCase): - """Tests for symlink-based skill installation via appliers.""" - - def setUp(self): - self.tmpdir = tempfile.mkdtemp() - # Source of truth directory (~/.apc/skills/) - self.source_dir = Path(self.tmpdir) / "skills" - self.source_dir.mkdir() - # Create a sample skill source directory with SKILL.md + supporting file - skill_dir = self.source_dir / "pdf" - skill_dir.mkdir() - (skill_dir / "SKILL.md").write_text(SAMPLE_SKILL_MD) - (skill_dir / "REFERENCE.md").write_text("# Reference\nExtra docs.") - # Target directories for tools - self.claude_skills = Path(self.tmpdir) / "claude_skills" - self.claude_skills.mkdir() - self.cursor_rules = Path(self.tmpdir) / "cursor_rules" - self.cursor_rules.mkdir() - - def _manifest(self, tool="claude"): - from appliers.manifest import ToolManifest - - return ToolManifest(tool, path=Path(self.tmpdir) / f"{tool}_manifest.json") - - def test_claude_link_skills_directory_symlink(self): - """Claude creates directory symlinks: ~/.claude/skills/pdf -> source/pdf""" - from appliers.claude import ClaudeApplier - - applier = ClaudeApplier() - applier.SKILL_DIR = self.claude_skills - skills = [{"name": "pdf", "targets": []}] - count = applier.link_skills(skills, self.source_dir, self._manifest()) - - self.assertEqual(count, 1) - link = self.claude_skills / "pdf" - self.assertTrue(link.is_symlink()) - # Should point to the source directory, not the file - self.assertEqual(link.resolve(), (self.source_dir / "pdf").resolve()) - # SKILL.md and supporting files should be accessible through the link - self.assertTrue((link / "SKILL.md").exists()) - self.assertTrue((link / "REFERENCE.md").exists()) - - def test_cursor_link_skills_file_symlink(self): - """Cursor creates file symlinks: .cursor/rules/pdf.mdc -> source/pdf/SKILL.md""" - from appliers.cursor import CursorApplier - - applier = CursorApplier() - applier.SKILL_DIR = self.cursor_rules - skills = [{"name": "pdf", "targets": []}] - count = applier.link_skills(skills, self.source_dir, self._manifest("cursor")) - - self.assertEqual(count, 1) - link = self.cursor_rules / "pdf.mdc" - self.assertTrue(link.is_symlink()) - # Should point to the SKILL.md file directly - self.assertEqual(link.resolve(), (self.source_dir / "pdf" / "SKILL.md").resolve()) - - def test_link_skills_replaces_existing_directory(self): - """Replaces a pre-existing real directory with a symlink.""" - existing_dir = self.claude_skills / "pdf" - existing_dir.mkdir() - (existing_dir / "old.md").write_text("old") - - from appliers.claude import ClaudeApplier - - applier = ClaudeApplier() - applier.SKILL_DIR = self.claude_skills - skills = [{"name": "pdf", "targets": []}] - count = applier.link_skills(skills, self.source_dir, self._manifest()) - - self.assertEqual(count, 1) - link = self.claude_skills / "pdf" - self.assertTrue(link.is_symlink()) - - def test_link_skills_replaces_broken_symlink(self): - broken_link = self.claude_skills / "pdf" - os.symlink("/nonexistent/path", broken_link) - - from appliers.claude import ClaudeApplier - - applier = ClaudeApplier() - applier.SKILL_DIR = self.claude_skills - skills = [{"name": "pdf", "targets": []}] - count = applier.link_skills(skills, self.source_dir, self._manifest()) - - self.assertEqual(count, 1) - self.assertTrue(broken_link.is_symlink()) - self.assertEqual(broken_link.resolve(), (self.source_dir / "pdf").resolve()) - - def test_link_skills_skips_missing_source(self): - from appliers.claude import ClaudeApplier - - applier = ClaudeApplier() - applier.SKILL_DIR = self.claude_skills - skills = [{"name": "nonexistent", "targets": []}] - count = applier.link_skills(skills, self.source_dir, self._manifest()) - - self.assertEqual(count, 0) - - def test_link_skills_returns_zero_when_no_skill_dir(self): - """Appliers without SKILL_DIR (e.g. Gemini) should return 0.""" - from appliers.gemini import GeminiApplier - - applier = GeminiApplier() - skills = [{"name": "pdf", "targets": []}] - count = applier.link_skills(skills, self.source_dir, self._manifest("gemini")) - - self.assertEqual(count, 0) - - def test_cursor_replaces_existing_file(self): - """Cursor replaces an old .mdc file with a symlink.""" - existing = self.cursor_rules / "pdf.mdc" - existing.write_text("old content") - - from appliers.cursor import CursorApplier - - applier = CursorApplier() - applier.SKILL_DIR = self.cursor_rules - skills = [{"name": "pdf", "targets": []}] - count = applier.link_skills(skills, self.source_dir, self._manifest("cursor")) - - self.assertEqual(count, 1) - self.assertTrue(existing.is_symlink()) - - -if __name__ == "__main__": - unittest.main()