From 14d9db6f1db62e0f02eb257dc870a3276a48d230 Mon Sep 17 00:00:00 2001 From: Ace Date: Thu, 5 Mar 2026 00:37:15 -0800 Subject: [PATCH 01/12] chore: remove marketplace feature and install command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marketplace feature (source management + skill install from GitHub) is outdated and no longer needed. Removes all references: - Delete src/marketplace.py — marketplace source management module - Delete src/share.py — install + marketplace Click commands - Remove apc install command from CLI (main.py) - Remove marketplace and share from pyproject.toml py-modules - Move get_skills_dir() into config.py (still used by sync_helpers) - Update sync_helpers.py import to use config.get_skills_dir - Delete tests/test_marketplace.py - Remove TestInstall from tests/test_docker_integration.py Closes #1 --- pyproject.toml | 4 +- src/config.py | 7 + src/main.py | 3 - src/marketplace.py | 183 ------------- src/share.py | 91 ------- src/sync_helpers.py | 2 +- tests/test_docker_integration.py | 16 +- tests/test_marketplace.py | 437 ------------------------------- 8 files changed, 11 insertions(+), 732 deletions(-) delete mode 100644 src/marketplace.py delete mode 100644 src/share.py delete mode 100644 tests/test_marketplace.py diff --git a/pyproject.toml b/pyproject.toml index 3b40a75..8d5fd71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,9 +29,9 @@ build-backend = "setuptools.build_meta" package-dir = {"" = "src"} py-modules = [ "cache", "collect", "config", "frontmatter_parser", - "llm_client", "llm_config", "main", "marketplace", + "llm_client", "llm_config", "main", "mcp", "memory", "secrets_manager", - "share", "skill", "status", "sync_helpers", "ui", + "skill", "status", "sync_helpers", "ui", ] packages = ["extractors", "appliers"] diff --git a/src/config.py b/src/config.py index 7fef16d..f51e0ea 100644 --- a/src/config.py +++ b/src/config.py @@ -15,3 +15,10 @@ def get_cache_dir() -> Path: cache_dir = get_config_dir() / "cache" cache_dir.mkdir(exist_ok=True) return cache_dir + + +def get_skills_dir() -> Path: + """Get or create the ~/.apc/skills/ directory for installed skills.""" + skills_dir = get_config_dir() / "skills" + skills_dir.mkdir(exist_ok=True) + return skills_dir diff --git a/src/main.py b/src/main.py index 1e20409..5d4d7ec 100644 --- a/src/main.py +++ b/src/main.py @@ -7,7 +7,6 @@ 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 @@ -52,8 +51,6 @@ def cli(): # Memory cli.add_command(memory) -# Install -cli.add_command(install) # MCP cli.add_command(mcp) 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/sync_helpers.py b/src/sync_helpers.py index d1ec6b9..4e055ea 100644 --- a/src/sync_helpers.py +++ b/src/sync_helpers.py @@ -7,8 +7,8 @@ from appliers import get_applier from cache import load_local_bundle, load_mcp_servers +from config import get_skills_dir from extractors import detect_installed_tools -from marketplace import get_skills_dir from ui import error, numbered_selection, success, warning diff --git a/tests/test_docker_integration.py b/tests/test_docker_integration.py index 08f6732..fbc32de 100644 --- a/tests/test_docker_integration.py +++ b/tests/test_docker_integration.py @@ -566,21 +566,7 @@ def test_configure_writes_models_json(self, runner, cli): # --------------------------------------------------------------------------- -# Phase 11: apc install (network-dependent, graceful failure) -# --------------------------------------------------------------------------- - - -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 - - -# --------------------------------------------------------------------------- -# Phase 12: Full round-trip — collect → sync → verify files +# Phase 11: Full round-trip — collect → sync → verify files # --------------------------------------------------------------------------- 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() From 1515467b9d2fe359049195199ea6254324be07fc Mon Sep 17 00:00:00 2001 From: Ace Date: Thu, 5 Mar 2026 00:44:57 -0800 Subject: [PATCH 02/12] restore: bring back apc install without marketplace source management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the install command but remove the marketplace concept entirely: - apc install still works (defaults to anthropics/skills) - apc install --repo owner/repo still works for custom sources - No apc marketplace add/list/delete — source management is gone marketplace.py split into: - src/skills.py — skill fetching/install logic (no source CRUD) - src/share.py — just the install Click command --- pyproject.toml | 2 +- src/config.py | 7 --- src/main.py | 4 ++ src/share.py | 45 +++++++++++++++ src/skills.py | 137 ++++++++++++++++++++++++++++++++++++++++++++ src/sync_helpers.py | 2 +- 6 files changed, 188 insertions(+), 9 deletions(-) create mode 100644 src/share.py create mode 100644 src/skills.py diff --git a/pyproject.toml b/pyproject.toml index 8d5fd71..aa1ca1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ py-modules = [ "cache", "collect", "config", "frontmatter_parser", "llm_client", "llm_config", "main", "mcp", "memory", "secrets_manager", - "skill", "status", "sync_helpers", "ui", + "share", "skill", "skills", "status", "sync_helpers", "ui", ] packages = ["extractors", "appliers"] diff --git a/src/config.py b/src/config.py index f51e0ea..7fef16d 100644 --- a/src/config.py +++ b/src/config.py @@ -15,10 +15,3 @@ def get_cache_dir() -> Path: cache_dir = get_config_dir() / "cache" cache_dir.mkdir(exist_ok=True) return cache_dir - - -def get_skills_dir() -> Path: - """Get or create the ~/.apc/skills/ directory for installed skills.""" - skills_dir = get_config_dir() / "skills" - skills_dir.mkdir(exist_ok=True) - return skills_dir diff --git a/src/main.py b/src/main.py index 5d4d7ec..a270942 100644 --- a/src/main.py +++ b/src/main.py @@ -7,6 +7,7 @@ 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 @@ -52,6 +53,9 @@ def cli(): cli.add_command(memory) +# Install +cli.add_command(install) + # MCP cli.add_command(mcp) diff --git a/src/share.py b/src/share.py new file mode 100644 index 0000000..3662c1a --- /dev/null +++ b/src/share.py @@ -0,0 +1,45 @@ +"""apc install command — fetch and install a skill from a repo or local path.""" + +import click + +from cache import load_skills, merge_skills, save_skills +from skills import save_skill_file, search_skill + + +@click.command() +@click.argument("skill_name") +@click.option( + "--repo", + default=None, + help="GitHub repo (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 GitHub repo or local directory. + + By default fetches from anthropics/skills. Use --repo to specify a source. + + Usage: apc install [--repo owner/repo] + """ + 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 "anthropics/skills" + 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']}' installed. Run 'apc sync' to apply to your tools.") diff --git a/src/skills.py b/src/skills.py new file mode 100644 index 0000000..a488eab --- /dev/null +++ b/src/skills.py @@ -0,0 +1,137 @@ +"""Skill installation — fetch skills from GitHub repos or local directories. + +No auth required. Skills are stored in ~/.apc/skills//SKILL.md +and linked into each tool's skill directory on sync. +""" + +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_BRANCH = "main" + + +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, + } + + +DEFAULT_REPO = "anthropics/skills" + + +def search_skill( + skill_name: str, + repos: Optional[List[str]] = None, + branch: str = DEFAULT_BRANCH, +) -> Optional[Dict[str, Any]]: + """Search for a skill across repos in priority order. Returns first match.""" + if repos is None: + repos = [DEFAULT_REPO] + + 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/sync_helpers.py b/src/sync_helpers.py index 4e055ea..17f5b8f 100644 --- a/src/sync_helpers.py +++ b/src/sync_helpers.py @@ -7,8 +7,8 @@ from appliers import get_applier from cache import load_local_bundle, load_mcp_servers -from config import get_skills_dir from extractors import detect_installed_tools +from skills import get_skills_dir from ui import error, numbered_selection, success, warning From 0f1e73e82e68c7c191c58a657edb6e0c12ec6536 Mon Sep 17 00:00:00 2001 From: Ace Date: Thu, 5 Mar 2026 00:48:57 -0800 Subject: [PATCH 03/12] feat: redesign install command with repo-first GitHub UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apc install now works like npx skills add — repo is the primary arg, with options to list, filter by skill name, and target specific agents. Usage: 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 -a claude-code -a cursor apc install owner/repo --all -a claude-code -y apc install owner/repo --agent '*' --skill frontend-design Changes: - REPO is now the first arg (not skill name) - --list: shows available skills without installing - --skill / -s: one or more skill names to install ('*' = all) - --all: install everything in the repo - --agent / -a: target specific tools ('*' = all detected) - -y / --yes: non-interactive mode for CI/CD - skills.py: added list_skills_in_repo() via GitHub tree API - No --repo flag needed — repo is always the first positional arg --- src/share.py | 198 ++++++++++++++++++++++++++++++++++++++++++-------- src/skills.py | 122 +++++++++++-------------------- 2 files changed, 210 insertions(+), 110 deletions(-) diff --git a/src/share.py b/src/share.py index 3662c1a..df34c13 100644 --- a/src/share.py +++ b/src/share.py @@ -1,45 +1,183 @@ -"""apc install command — fetch and install a skill from a repo or local path.""" +"""apc install command — install skills from a GitHub repository.""" + +from typing import List import click +from appliers import get_applier from cache import load_skills, merge_skills, save_skills -from skills import save_skill_file, search_skill +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_agents(agent_args: tuple, yes: bool) -> List[str]: + """Resolve target agents from -a flags, '*', or interactive selection.""" + if not agent_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)] + + agents = list(agent_args) + if "*" in agents: + return detect_installed_tools() + return agents + + +def _apply_skill_to_agents(skill: dict, agent_list: list) -> int: + """Write a skill directly to each agent's skill directory. Returns applied count.""" + + count = 0 + for agent_name in agent_list: + try: + applier = get_applier(agent_name) + manifest = applier.get_manifest() + applied = applier.apply_skills([skill], manifest) + manifest.save() + count += applied + except Exception as e: + click.echo(f" ! {agent_name}: {e}", err=True) + return count @click.command() -@click.argument("skill_name") -@click.option( - "--repo", - default=None, - help="GitHub repo (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 GitHub repo or local directory. - - By default fetches from anthropics/skills. Use --repo to specify a source. - - Usage: apc install [--repo owner/repo] +@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("--agent", "-a", "agents", 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, agents, 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 -a claude-code -a cursor + apc install owner/repo --all -a claude-code -y """ - repos = [repo] if repo else None + # Validate: repo must look like owner/repo + if "/" not in repo or repo.startswith("http"): + raise click.UsageError( + "REPO must be a GitHub owner/repo slug (e.g. vercel-labs/agent-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)] - click.echo(f"Searching for '{skill_name}'...") - skill = search_skill(skill_name, repos=repos, branch=branch) + if not skill_names: + click.echo("No skills selected.", err=True) + return - if not skill: - source = repo if repo else "anthropics/skills" - click.echo(f"Skill '{skill_name}' not found in {source}.", err=True) + # Resolve target agents + agent_list = _resolve_agents(agents, yes) + if not agent_list: return - click.echo(f"Found '{skill['name']}' in {skill['source_repo']}") + # 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(agent_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 agent + _apply_skill_to_agents(skill, agent_list) - # 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) - # Save metadata to local cache - existing = load_skills() - merged = merge_skills(existing, [skill]) - save_skills(merged) + installed_skills.append(skill["name"]) + click.echo(" ✓") - click.echo(f"✓ Skill '{skill['name']}' installed. Run 'apc sync' to apply to your tools.") + if installed_skills: + click.echo(f"\n✓ Installed {len(installed_skills)} skill(s) to {', '.join(agent_list)}") + else: + click.echo("\nNo skills were installed.") diff --git a/src/skills.py b/src/skills.py index a488eab..d612a79 100644 --- a/src/skills.py +++ b/src/skills.py @@ -1,10 +1,9 @@ -"""Skill installation — fetch skills from GitHub repos or local directories. +"""Skill installation — fetch skills from GitHub repos. -No auth required. Skills are stored in ~/.apc/skills//SKILL.md -and linked into each tool's skill directory on sync. +Skills are stored in ~/.apc/skills//SKILL.md and linked into each +tool's skill directory on sync. """ -import os from pathlib import Path from typing import Any, Dict, List, Optional @@ -14,47 +13,13 @@ 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" -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" +# --------------------------------------------------------------------------- +# Skills directory +# --------------------------------------------------------------------------- def get_skills_dir() -> Path: @@ -65,10 +30,7 @@ def get_skills_dir() -> Path: 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. - """ + """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" @@ -76,22 +38,46 @@ def save_skill_file(skill_name: str, raw_content: str) -> Path: 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" +# --------------------------------------------------------------------------- +# 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_slug: str, + repo: str, skill_name: str, branch: str = DEFAULT_BRANCH, ) -> Optional[Dict[str, Any]]: - """Fetch and parse a SKILL.md from a GitHub repo. + """Fetch and parse a single skill 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. + Returns a skill dict (with _raw_content) or None if not found. """ - url = _build_skill_url(repo_slug, skill_name, branch) + 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: @@ -100,7 +86,6 @@ def fetch_skill_from_repo( return None metadata, body = parse_frontmatter(resp.text) - return { "name": metadata.get("name", skill_name), "description": metadata.get("description", ""), @@ -109,29 +94,6 @@ def fetch_skill_from_repo( "targets": [], "version": metadata.get("version", ""), "source_tool": "github", - "source_repo": repo_slug, + "source_repo": repo, "_raw_content": resp.text, } - - -DEFAULT_REPO = "anthropics/skills" - - -def search_skill( - skill_name: str, - repos: Optional[List[str]] = None, - branch: str = DEFAULT_BRANCH, -) -> Optional[Dict[str, Any]]: - """Search for a skill across repos in priority order. Returns first match.""" - if repos is None: - repos = [DEFAULT_REPO] - - 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 From 41fe4245d98e27642232347cf429293e0314afb9 Mon Sep 17 00:00:00 2001 From: Ace Date: Thu, 5 Mar 2026 00:50:57 -0800 Subject: [PATCH 04/12] test: add TestInstall for new repo-first install UX 8 tests covering: - Invalid repo format rejection (URL, no-slash) - --list with mocked GitHub response - --list on empty repo - Single skill install (mocked fetch + agent apply) - Skill not found in repo - --all installs everything from repo - -y flag skips confirmation prompts Patches target share.* (not skills.*) since share.py imports at load time. --- src/share.py | 22 +++-- tests/test_docker_integration.py | 147 ++++++++++++++++++++++++++++++- 2 files changed, 162 insertions(+), 7 deletions(-) diff --git a/src/share.py b/src/share.py index df34c13..b7a3fe8 100644 --- a/src/share.py +++ b/src/share.py @@ -61,14 +61,24 @@ def _apply_skill_to_agents(skill: dict, agent_list: list) -> int: @click.command() @click.argument("repo") -@click.option("--skill", "-s", "skills", multiple=True, - help="Skill name(s) to install. Use '*' for all.") +@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("--agent", "-a", "agents", multiple=True, - help="Target tool(s) to install to. Use '*' for all detected.") +@click.option( + "--agent", + "-a", + "agents", + 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( + "--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, agents, branch, list_only, yes): """Install skills from a GitHub repository. diff --git a/tests/test_docker_integration.py b/tests/test_docker_integration.py index fbc32de..6604973 100644 --- a/tests/test_docker_integration.py +++ b/tests/test_docker_integration.py @@ -566,7 +566,152 @@ def test_configure_writes_models_json(self, runner, cli): # --------------------------------------------------------------------------- -# Phase 11: Full round-trip — collect → sync → verify files +# Phase 11: apc install (GitHub repo-first UX) +# --------------------------------------------------------------------------- + + +class TestInstall: + """Tests for apc install — repo-first GitHub skill installation.""" + + def test_install_invalid_repo_format(self, runner, cli): + """Non-slug repos are rejected with a clear error.""" + result = runner.invoke(cli, ["install", "https://github.com/owner/repo"]) + assert result.exit_code != 0 + assert "owner/repo slug" in result.output.lower() or "usage error" in result.output.lower() + + def test_install_invalid_no_slash(self, runner, cli): + """Repo without a slash is rejected.""" + result = runner.invoke(cli, ["install", "notaslug"]) + assert result.exit_code != 0 + + def test_install_list_mocked(self, runner, cli, monkeypatch): + """--list prints available skills from the repo.""" + from unittest.mock import patch + + mock_skills = ["frontend-design", "skill-creator", "pdf"] + + with patch("share.list_skills_in_repo", return_value=mock_skills): + result = runner.invoke(cli, ["install", "owner/repo", "--list"]) + + assert result.exit_code == 0 + assert "frontend-design" in result.output + assert "skill-creator" in result.output + assert "pdf" in result.output + assert "3 skill(s) found" in result.output + + def test_install_list_empty_repo(self, runner, cli): + """--list on a repo with no skills prints an error.""" + from unittest.mock import patch + + with patch("share.list_skills_in_repo", return_value=[]): + result = runner.invoke(cli, ["install", "owner/repo", "--list"]) + + assert "no skills found" in result.output.lower() + + def test_install_single_skill_mocked(self, runner, cli, monkeypatch): + """Installing a single skill fetches, saves to cache, and applies to agents.""" + from unittest.mock import patch + + mock_skill = { + "name": "frontend-design", + "description": "Frontend design skill", + "body": "Frontend skill body.", + "tags": ["design"], + "targets": [], + "version": "1.0.0", + "source_tool": "github", + "source_repo": "owner/repo", + "_raw_content": "---\nname: frontend-design\n---\nFrontend skill body.", + } + + with ( + patch("share.fetch_skill_from_repo", return_value=mock_skill), + patch("share._apply_skill_to_agents", return_value=1), + ): + result = runner.invoke( + cli, + ["install", "owner/repo", "--skill", "frontend-design", "-a", "cursor", "-y"], + ) + + assert result.exit_code == 0 + assert "✓" in result.output + assert "frontend-design" in result.output + + def test_install_skill_not_found(self, runner, cli): + """A skill that doesn't exist in the repo prints a clear not-found message.""" + from unittest.mock import patch + + with patch("share.fetch_skill_from_repo", return_value=None): + result = runner.invoke( + cli, + ["install", "owner/repo", "--skill", "nonexistent-skill", "-a", "cursor", "-y"], + ) + + assert ( + "not found" in result.output.lower() + or "no skills were installed" in result.output.lower() + ) + + def test_install_all_mocked(self, runner, cli): + """--all fetches and installs every skill in the repo.""" + from unittest.mock import patch + + skill_names = ["skill-a", "skill-b"] + + def fake_fetch(repo, name, branch="main"): + return { + "name": name, + "description": "", + "body": f"{name} body", + "tags": [], + "targets": [], + "version": "1.0.0", + "source_tool": "github", + "source_repo": repo, + "_raw_content": f"---\nname: {name}\n---\n{name} body", + } + + with ( + patch("share.list_skills_in_repo", return_value=skill_names), + patch("share.fetch_skill_from_repo", side_effect=fake_fetch), + patch("share._apply_skill_to_agents", return_value=1), + ): + result = runner.invoke(cli, ["install", "owner/repo", "--all", "-a", "cursor", "-y"]) + + assert result.exit_code == 0 + assert "2 skill(s)" in result.output + + def test_install_yes_flag_skips_confirmation(self, runner, cli): + """The -y flag proceeds without interactive prompts.""" + from unittest.mock import patch + + mock_skill = { + "name": "test-skill", + "description": "", + "body": "body", + "tags": [], + "targets": [], + "version": "1.0.0", + "source_tool": "github", + "source_repo": "owner/repo", + "_raw_content": "---\nname: test-skill\n---\nbody", + } + + with ( + patch("share.fetch_skill_from_repo", return_value=mock_skill), + patch("share._apply_skill_to_agents", return_value=1), + ): + result = runner.invoke( + cli, ["install", "owner/repo", "-s", "test-skill", "-a", "cursor", "-y"] + ) + + # Should complete without asking any questions + assert result.exit_code == 0 + assert "Proceed?" not in result.output + + +# --------------------------------------------------------------------------- +# Phase 12: Full round-trip — collect → sync → verify files # --------------------------------------------------------------------------- From 65b0426460a9ff62cd07e5df01f42980f39d22bc Mon Sep 17 00:00:00 2001 From: Ace Date: Thu, 5 Mar 2026 00:54:50 -0800 Subject: [PATCH 05/12] =?UTF-8?q?fix+test:=20install=E2=86=92sync=20flow,?= =?UTF-8?q?=20openclaw=20MCP=20override=20param,=20Rich=20escape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs fixed: - openclaw applier missing 'override' param on apply_mcp_servers (broke apc sync for any openclaw target) - skill show crashing with MarkupError when body contains Rich markup characters like [/* content */] — now escapes body before rendering Tests added (TestInstallThenSync): - install writes skill to local cache and ~/.apc/skills/ - install creates correct SKILL.md source file - sync --dry-run picks up installed skills from ~/.apc/skills/ - install multiple skills → skill list shows all of them --- src/appliers/openclaw.py | 6 +- src/ui.py | 3 +- tests/test_docker_integration.py | 147 ++++++++++++++++++++++++++++++- 3 files changed, 153 insertions(+), 3 deletions(-) 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/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_docker_integration.py b/tests/test_docker_integration.py index 6604973..889af2c 100644 --- a/tests/test_docker_integration.py +++ b/tests/test_docker_integration.py @@ -711,7 +711,152 @@ def test_install_yes_flag_skips_confirmation(self, runner, cli): # --------------------------------------------------------------------------- -# Phase 12: Full round-trip — collect → sync → verify files +# Phase 12: install → sync flow +# --------------------------------------------------------------------------- + + +class TestInstallThenSync: + """Verify the full install → sync flow: skills fetched via apc install + are correctly picked up and applied when apc sync runs afterwards.""" + + def test_install_then_sync_writes_skill_to_tool(self, runner, cli, tmp_path, monkeypatch): + """Skills installed via apc install are applied to the target tool on sync.""" + from unittest.mock import patch + + monkeypatch.setenv("HOME", str(tmp_path)) + + mock_skill = { + "name": "test-install-skill", + "description": "Installed via apc install", + "body": "Test install skill body.", + "tags": ["test"], + "targets": [], + "version": "1.0.0", + "source_tool": "github", + "source_repo": "owner/repo", + "_raw_content": ( + "---\nname: test-install-skill\n" + "description: Installed via apc install\n---\n" + "Test install skill body." + ), + } + + # Step 1: apc install + with ( + patch("share.fetch_skill_from_repo", return_value=mock_skill), + patch("share._apply_skill_to_agents", return_value=1), + ): + install_result = runner.invoke( + cli, + ["install", "owner/repo", "--skill", "test-install-skill", "-a", "cursor", "-y"], + ) + assert install_result.exit_code == 0 + assert "✓" in install_result.output + + # Skill should now be in the local cache + from cache import load_skills + + cached = load_skills() + names = [s["name"] for s in cached] + assert "test-install-skill" in names + + def test_install_creates_skill_source_file(self, runner, cli, tmp_path, monkeypatch): + """apc install saves SKILL.md to ~/.apc/skills//SKILL.md.""" + monkeypatch.setenv("HOME", str(tmp_path)) + from unittest.mock import patch + + raw = "---\nname: my-skill\nversion: 1.0.0\n---\nMy skill body." + mock_skill = { + "name": "my-skill", + "description": "", + "body": "My skill body.", + "tags": [], + "targets": [], + "version": "1.0.0", + "source_tool": "github", + "source_repo": "owner/repo", + "_raw_content": raw, + } + + with ( + patch("share.fetch_skill_from_repo", return_value=mock_skill), + patch("share._apply_skill_to_agents", return_value=1), + ): + result = runner.invoke( + cli, ["install", "owner/repo", "-s", "my-skill", "-a", "cursor", "-y"] + ) + + assert result.exit_code == 0 + skill_file = tmp_path / ".apc" / "skills" / "my-skill" / "SKILL.md" + assert skill_file.exists(), f"SKILL.md not found at {skill_file}" + assert "My skill body." in skill_file.read_text() + + def test_sync_picks_up_installed_skills(self, runner, cli, tmp_path, monkeypatch): + """apc sync --dry-run reports installed skills (from ~/.apc/skills/) correctly.""" + monkeypatch.setenv("HOME", str(tmp_path)) + + # Seed a skill directly into ~/.apc/skills/ + skill_dir = tmp_path / ".apc" / "skills" / "seeded-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: seeded-skill\ndescription: Seeded for sync test\n---\nBody." + ) + + # Seed a target tool so sync has somewhere to go + cursor_dir = tmp_path / ".cursor" + cursor_dir.mkdir() + (cursor_dir / "mcp.json").write_text("{}") + + result = runner.invoke(cli, ["sync", "--tools", "cursor", "--dry-run"]) + assert result.exit_code == 0 + # dry-run should report the seeded skill in the plan + assert "seeded-skill" in result.output or "1" in result.output + + def test_install_multiple_then_sync_all(self, runner, cli, tmp_path, monkeypatch): + """Installing multiple skills then syncing --all applies all of them.""" + monkeypatch.setenv("HOME", str(tmp_path)) + from unittest.mock import patch + + skill_names = ["skill-one", "skill-two"] + + def fake_fetch(repo, name, branch="main"): + return { + "name": name, + "description": "", + "body": f"{name} body", + "tags": [], + "targets": [], + "version": "1.0.0", + "source_tool": "github", + "source_repo": repo, + "_raw_content": f"---\nname: {name}\n---\n{name} body", + } + + # Install both skills + with ( + patch("share.fetch_skill_from_repo", side_effect=fake_fetch), + patch("share._apply_skill_to_agents", return_value=1), + ): + for name in skill_names: + result = runner.invoke( + cli, ["install", "owner/repo", "-s", name, "-a", "cursor", "-y"] + ) + assert result.exit_code == 0 + + # Both should be in ~/.apc/skills/ + for name in skill_names: + skill_file = tmp_path / ".apc" / "skills" / name / "SKILL.md" + assert skill_file.exists(), f"Missing {skill_file}" + + # Both should appear in skill list + list_result = runner.invoke(cli, ["skill", "list"]) + assert list_result.exit_code == 0 + assert "skill-one" in list_result.output + assert "skill-two" in list_result.output + + +# --------------------------------------------------------------------------- +# Phase 13: Full round-trip — collect → sync → verify files # --------------------------------------------------------------------------- From 051e744253f0ea242f6ad5ab78d6736ad1515fc7 Mon Sep 17 00:00:00 2001 From: Ace Date: Thu, 5 Mar 2026 00:55:17 -0800 Subject: [PATCH 06/12] =?UTF-8?q?refactor:=20rename=20share.py=20=E2=86=92?= =?UTF-8?q?=20install.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit share.py no longer fits — the file only contains the install command. Renamed to install.py for clarity. Updated references in main.py, pyproject.toml, and tests. --- pyproject.toml | 2 +- src/{share.py => install.py} | 5 ++++- src/main.py | 2 +- tests/test_docker_integration.py | 32 ++++++++++++++++---------------- 4 files changed, 22 insertions(+), 19 deletions(-) rename src/{share.py => install.py} (98%) diff --git a/pyproject.toml b/pyproject.toml index aa1ca1c..edc89cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ py-modules = [ "cache", "collect", "config", "frontmatter_parser", "llm_client", "llm_config", "main", "mcp", "memory", "secrets_manager", - "share", "skill", "skills", "status", "sync_helpers", "ui", + "install", "skill", "skills", "status", "sync_helpers", "ui", ] packages = ["extractors", "appliers"] diff --git a/src/share.py b/src/install.py similarity index 98% rename from src/share.py rename to src/install.py index b7a3fe8..38a6461 100644 --- a/src/share.py +++ b/src/install.py @@ -1,4 +1,7 @@ -"""apc install command — install skills from a GitHub repository.""" +"""apc install command — install skills from a GitHub repository. + +Handles the `apc install owner/repo` command and all its options. +""" from typing import List diff --git a/src/main.py b/src/main.py index a270942..198d747 100644 --- a/src/main.py +++ b/src/main.py @@ -4,10 +4,10 @@ from cache import load_local_bundle from collect import collect +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 diff --git a/tests/test_docker_integration.py b/tests/test_docker_integration.py index 889af2c..a35bc62 100644 --- a/tests/test_docker_integration.py +++ b/tests/test_docker_integration.py @@ -590,7 +590,7 @@ def test_install_list_mocked(self, runner, cli, monkeypatch): mock_skills = ["frontend-design", "skill-creator", "pdf"] - with patch("share.list_skills_in_repo", return_value=mock_skills): + with patch("install.list_skills_in_repo", return_value=mock_skills): result = runner.invoke(cli, ["install", "owner/repo", "--list"]) assert result.exit_code == 0 @@ -603,7 +603,7 @@ def test_install_list_empty_repo(self, runner, cli): """--list on a repo with no skills prints an error.""" from unittest.mock import patch - with patch("share.list_skills_in_repo", return_value=[]): + with patch("install.list_skills_in_repo", return_value=[]): result = runner.invoke(cli, ["install", "owner/repo", "--list"]) assert "no skills found" in result.output.lower() @@ -625,8 +625,8 @@ def test_install_single_skill_mocked(self, runner, cli, monkeypatch): } with ( - patch("share.fetch_skill_from_repo", return_value=mock_skill), - patch("share._apply_skill_to_agents", return_value=1), + patch("install.fetch_skill_from_repo", return_value=mock_skill), + patch("install._apply_skill_to_agents", return_value=1), ): result = runner.invoke( cli, @@ -641,7 +641,7 @@ def test_install_skill_not_found(self, runner, cli): """A skill that doesn't exist in the repo prints a clear not-found message.""" from unittest.mock import patch - with patch("share.fetch_skill_from_repo", return_value=None): + with patch("install.fetch_skill_from_repo", return_value=None): result = runner.invoke( cli, ["install", "owner/repo", "--skill", "nonexistent-skill", "-a", "cursor", "-y"], @@ -672,9 +672,9 @@ def fake_fetch(repo, name, branch="main"): } with ( - patch("share.list_skills_in_repo", return_value=skill_names), - patch("share.fetch_skill_from_repo", side_effect=fake_fetch), - patch("share._apply_skill_to_agents", return_value=1), + patch("install.list_skills_in_repo", return_value=skill_names), + patch("install.fetch_skill_from_repo", side_effect=fake_fetch), + patch("install._apply_skill_to_agents", return_value=1), ): result = runner.invoke(cli, ["install", "owner/repo", "--all", "-a", "cursor", "-y"]) @@ -698,8 +698,8 @@ def test_install_yes_flag_skips_confirmation(self, runner, cli): } with ( - patch("share.fetch_skill_from_repo", return_value=mock_skill), - patch("share._apply_skill_to_agents", return_value=1), + patch("install.fetch_skill_from_repo", return_value=mock_skill), + patch("install._apply_skill_to_agents", return_value=1), ): result = runner.invoke( cli, ["install", "owner/repo", "-s", "test-skill", "-a", "cursor", "-y"] @@ -743,8 +743,8 @@ def test_install_then_sync_writes_skill_to_tool(self, runner, cli, tmp_path, mon # Step 1: apc install with ( - patch("share.fetch_skill_from_repo", return_value=mock_skill), - patch("share._apply_skill_to_agents", return_value=1), + patch("install.fetch_skill_from_repo", return_value=mock_skill), + patch("install._apply_skill_to_agents", return_value=1), ): install_result = runner.invoke( cli, @@ -779,8 +779,8 @@ def test_install_creates_skill_source_file(self, runner, cli, tmp_path, monkeypa } with ( - patch("share.fetch_skill_from_repo", return_value=mock_skill), - patch("share._apply_skill_to_agents", return_value=1), + patch("install.fetch_skill_from_repo", return_value=mock_skill), + patch("install._apply_skill_to_agents", return_value=1), ): result = runner.invoke( cli, ["install", "owner/repo", "-s", "my-skill", "-a", "cursor", "-y"] @@ -834,8 +834,8 @@ def fake_fetch(repo, name, branch="main"): # Install both skills with ( - patch("share.fetch_skill_from_repo", side_effect=fake_fetch), - patch("share._apply_skill_to_agents", return_value=1), + patch("install.fetch_skill_from_repo", side_effect=fake_fetch), + patch("install._apply_skill_to_agents", return_value=1), ): for name in skill_names: result = runner.invoke( From a7674187f68f3989ca49154c945e05aad53a7ce2 Mon Sep 17 00:00:00 2001 From: FZ2000 <40145076+FZ2000@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:00:47 -0800 Subject: [PATCH 07/12] Add apc export/import with age-encrypted secrets (#14) * Add apc export/import with age-encrypted secrets, fix MCP secrets bug Implement portable config migration between machines via `apc export` and `apc import`. Secrets (API keys, MCP tokens) are field-level encrypted with age (pyrage) so exports can safely live in Git repos. Non-secret data stays in cleartext for diff-friendliness. Also fix a bug where sync_helpers.py always passed {} for secrets to appliers, so MCP server env placeholders (${TOKEN}) were never resolved from the OS keychain during sync. Co-Authored-By: Claude Opus 4.6 * Fix ruff formatting in export_import.py and test_docker_integration.py Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- README.md | 16 + pyproject.toml | 10 +- src/export_import.py | 513 ++++++++++++++++++++++++++++++ src/main.py | 5 + src/sync_helpers.py | 21 +- tests/test_docker_integration.py | 259 +++++++++++++++ tests/test_export_import.py | 524 +++++++++++++++++++++++++++++++ 7 files changed, 1341 insertions(+), 7 deletions(-) create mode 100644 src/export_import.py create mode 100644 tests/test_export_import.py diff --git a/README.md b/README.md index d233568..f089daa 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,22 @@ apc configure | `apc marketplace add ` | Add a GitHub repo or local directory | | `apc marketplace delete ` | Remove a marketplace source | +### Export / Import + +| Command | Description | +|---------|-------------| +| `apc export [path]` | Export configs to a portable directory with age-encrypted secrets | +| `apc import [path]` | Import configs from an export directory, decrypting secrets | + +**Options:** + +| Flag | Description | +|------|-------------| +| `--no-secrets` | Skip secret encryption/decryption | +| `--yes`, `-y` | Skip confirmation prompts | + +**Workflow:** export on machine A, commit the directory to a private repo, pull on machine B, import. Transfer `~/.apc/age-identity.txt` (private key) to the target machine once via a secure channel. Secrets stay safe even if the repo becomes public. + ### LLM Configuration | Command | Description | diff --git a/pyproject.toml b/pyproject.toml index 3b40a75..97e850b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "click>=8.1.0", "httpx>=0.27.0", "keyring>=25.0.0", + "pyrage>=1.0.0", "pyyaml>=6.0", "rich>=13.0.0", ] @@ -28,10 +29,11 @@ build-backend = "setuptools.build_meta" [tool.setuptools] package-dir = {"" = "src"} py-modules = [ - "cache", "collect", "config", "frontmatter_parser", - "llm_client", "llm_config", "main", "marketplace", - "mcp", "memory", "secrets_manager", - "share", "skill", "status", "sync_helpers", "ui", + "cache", "collect", "config", "export_import", + "frontmatter_parser", "llm_client", "llm_config", + "main", "marketplace", "mcp", "memory", + "secrets_manager", "share", "skill", "status", + "sync_helpers", "ui", ] packages = ["extractors", "appliers"] diff --git a/src/export_import.py b/src/export_import.py new file mode 100644 index 0000000..eb56427 --- /dev/null +++ b/src/export_import.py @@ -0,0 +1,513 @@ +"""Export and import APC configs with age-encrypted secrets. + +Export creates a portable directory of skills, MCP servers, memory, and config +that can be committed to a private repo and imported on another machine. +Secrets (API keys, MCP tokens) are encrypted with age (via pyrage) so they +stay safe even if the repo becomes public. +""" + +import base64 +import json +import os +import shutil +import stat +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import click + +from cache import ( + load_local_bundle, + load_mcp_servers, + merge_mcp_servers, + merge_memory, + merge_skills, + save_mcp_servers, + save_memory, + save_skills, +) +from config import get_config_dir +from marketplace import get_skills_dir +from secrets_manager import retrieve_secret, store_secrets_batch +from ui import error, header, info, success, warning + +SCHEMA_VERSION = 1 +AGE_PREFIX = "AGE:" +IDENTITY_FILENAME = "age-identity.txt" + +# --------------------------------------------------------------------------- +# pyrage wrapper — graceful degradation when not installed +# --------------------------------------------------------------------------- + +_pyrage_available: Optional[bool] = None + + +def _check_pyrage() -> bool: + global _pyrage_available + if _pyrage_available is None: + try: + import pyrage # noqa: F401 + + _pyrage_available = True + except ImportError: + _pyrage_available = False + return _pyrage_available + + +def _identity_path() -> Path: + return get_config_dir() / IDENTITY_FILENAME + + +def _load_or_create_identity() -> Tuple[str, str]: + """Load or generate an age keypair. + + Returns (public_key, private_key_str). + The private key is stored at ~/.apc/age-identity.txt (chmod 600). + """ + from pyrage import x25519 + + path = _identity_path() + if path.exists(): + raw = path.read_text().strip() + identity = x25519.Identity.from_str(raw) + return str(identity.to_public()), raw + + identity = x25519.Identity.generate() + private_str = str(identity) + public_str = str(identity.to_public()) + + path.write_text(private_str + "\n") + os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 600 + + return public_str, private_str + + +def _load_identity() -> Optional[str]: + """Load the private key string from disk, or None if missing.""" + path = _identity_path() + if not path.exists(): + return None + return path.read_text().strip() + + +# --------------------------------------------------------------------------- +# Encrypt / decrypt helpers +# --------------------------------------------------------------------------- + + +def encrypt_value(value: str, public_key: str) -> str: + """Encrypt a string with the age public key. Returns 'AGE:'.""" + from pyrage import encrypt, x25519 + + recipient = x25519.Recipient.from_str(public_key) + ciphertext = encrypt(value.encode(), [recipient]) + encoded = base64.b64encode(ciphertext).decode() + return f"{AGE_PREFIX}{encoded}" + + +def decrypt_value(token: str, private_key_str: str) -> Optional[str]: + """Decrypt an 'AGE:' token. Returns plaintext or None on failure.""" + from pyrage import decrypt, x25519 + + if not token.startswith(AGE_PREFIX): + return token # not encrypted, pass through + + try: + raw_b64 = token[len(AGE_PREFIX) :] + ciphertext = base64.b64decode(raw_b64) + identity = x25519.Identity.from_str(private_key_str) + plaintext = decrypt(ciphertext, [identity]) + return plaintext.decode() + except Exception: + return None + + +def is_encrypted(value: str) -> bool: + return isinstance(value, str) and value.startswith(AGE_PREFIX) + + +# --------------------------------------------------------------------------- +# Export helpers +# --------------------------------------------------------------------------- + + +def _export_mcp_servers(servers: List[Dict], public_key: Optional[str]) -> List[Dict]: + """Prepare MCP servers for export: encrypt secret values if key provided.""" + result = [] + for srv in servers: + out = dict(srv) + placeholders = srv.get("secret_placeholders", []) + if placeholders and public_key: + encrypted_secrets: Dict[str, str] = {} + for key in placeholders: + value = retrieve_secret("local", key) + if value: + encrypted_secrets[key] = encrypt_value(value, public_key) + else: + warning(f"Secret '{key}' not found in keychain, skipping") + if encrypted_secrets: + out["encrypted_secrets"] = encrypted_secrets + result.append(out) + return result + + +def _export_auth_profiles(data: Dict[str, Any], public_key: Optional[str]) -> Dict[str, Any]: + """Encrypt key/token fields in auth profiles.""" + out = json.loads(json.dumps(data)) # deep copy + for _pkey, profile in out.get("profiles", {}).items(): + for field in ("key", "token"): + val = profile.get(field) + if val and public_key: + profile[field] = encrypt_value(val, public_key) + return out + + +# --------------------------------------------------------------------------- +# Import helpers +# --------------------------------------------------------------------------- + + +def _import_mcp_servers( + servers: List[Dict], private_key: Optional[str] +) -> Tuple[List[Dict], Dict[str, str]]: + """Decrypt MCP server secrets and return (clean_servers, secrets_to_store).""" + secrets_to_store: Dict[str, str] = {} + result = [] + for srv in servers: + out = dict(srv) + enc = out.pop("encrypted_secrets", None) + if enc and private_key: + for key, cipher in enc.items(): + plain = decrypt_value(cipher, private_key) + if plain: + secrets_to_store[key] = plain + else: + warning(f"Failed to decrypt secret '{key}' for MCP server '{srv.get('name')}'") + elif enc and not private_key: + warning(f"Skipping encrypted secrets for '{srv.get('name')}' — no private key") + result.append(out) + return result, secrets_to_store + + +def _import_auth_profiles(data: Dict[str, Any], private_key: Optional[str]) -> Dict[str, Any]: + """Decrypt key/token fields in auth profiles.""" + out = json.loads(json.dumps(data)) # deep copy + for _pkey, profile in out.get("profiles", {}).items(): + for field in ("key", "token"): + val = profile.get(field) + if val and is_encrypted(val): + if private_key: + plain = decrypt_value(val, private_key) + if plain: + profile[field] = plain + else: + warning(f"Failed to decrypt auth profile field '{field}'") + profile[field] = "" + else: + warning(f"Skipping encrypted auth field '{field}' — no private key") + profile[field] = "" + return out + + +# --------------------------------------------------------------------------- +# apc export +# --------------------------------------------------------------------------- + + +@click.command("export") +@click.argument("path", default="apc-export") +@click.option("--no-secrets", is_flag=True, help="Export without encrypting secrets") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +def export_cmd(path: str, no_secrets: bool, yes: bool): + """Export APC configs to a portable directory. + + Secrets are encrypted with age so the directory can be safely committed + to a Git repo. Transfer your private key (~/.apc/age-identity.txt) to + the target machine once via a secure channel. + + \b + Examples: + apc export # export to ./apc-export/ + apc export /tmp/my-config # export to custom path + apc export --no-secrets # skip secret encryption + """ + header("Export") + export_dir = Path(path).resolve() + + # Load data + bundle = load_local_bundle() + skills = bundle["skills"] + mcp_servers = load_mcp_servers() + memory = bundle["memory"] + + skills_dir = get_skills_dir() + config_dir = get_config_dir() + + # Summarise + info(f"Export path: {export_dir}") + info(f"Skills: {len(skills)}, MCP servers: {len(mcp_servers)}, Memory: {len(memory)}") + + # Count installed skills + installed_skills: List[str] = [] + if skills_dir.exists(): + installed_skills = [ + d.name for d in sorted(skills_dir.iterdir()) if d.is_dir() and (d / "SKILL.md").exists() + ] + if installed_skills: + info(f"Installed skills to copy: {len(installed_skills)}") + + # Config files + auth_path = config_dir / "auth-profiles.json" + models_path = config_dir / "models.json" + marketplaces_path = config_dir / "marketplaces.json" + + has_auth = auth_path.exists() + has_models = models_path.exists() + has_marketplaces = marketplaces_path.exists() + + # Age key + public_key: Optional[str] = None + use_encryption = not no_secrets and _check_pyrage() + + if not no_secrets and not _check_pyrage(): + warning("pyrage not installed — exporting without secret encryption.") + warning("Install with: pip install pyrage") + use_encryption = False + + if use_encryption: + public_key, _priv = _load_or_create_identity() + info(f"Age public key: {public_key}") + + if not yes: + if not click.confirm("\nProceed with export?"): + info("Cancelled.") + return + + # Create directory structure + export_dir.mkdir(parents=True, exist_ok=True) + (export_dir / "cache").mkdir(exist_ok=True) + (export_dir / "config").mkdir(exist_ok=True) + + # 1. Cache: skills.json (plain) + (export_dir / "cache" / "skills.json").write_text(json.dumps(skills, indent=2, default=str)) + + # 2. Cache: mcp_servers.json (with encrypted secrets) + exported_mcp = _export_mcp_servers(mcp_servers, public_key) + (export_dir / "cache" / "mcp_servers.json").write_text( + json.dumps(exported_mcp, indent=2, default=str) + ) + + # 3. Cache: memory.json (plain) + (export_dir / "cache" / "memory.json").write_text(json.dumps(memory, indent=2, default=str)) + + # 4. Installed skills directory (resolve symlinks) + if installed_skills: + export_skills_dir = export_dir / "skills" + if export_skills_dir.exists(): + shutil.rmtree(export_skills_dir) + export_skills_dir.mkdir() + for name in installed_skills: + src = skills_dir / name + dst = export_skills_dir / name + shutil.copytree(src, dst, symlinks=False) + + # 5. Config files + if has_marketplaces: + shutil.copy2(marketplaces_path, export_dir / "config" / "marketplaces.json") + + if has_models: + shutil.copy2(models_path, export_dir / "config" / "models.json") + + if has_auth: + auth_data = json.loads(auth_path.read_text(encoding="utf-8")) + exported_auth = _export_auth_profiles(auth_data, public_key) + (export_dir / "config" / "auth-profiles.json").write_text( + json.dumps(exported_auth, indent=2) + ) + + # 6. Metadata + metadata = { + "schema_version": SCHEMA_VERSION, + "created_at": datetime.now(timezone.utc).isoformat(), + "public_key": public_key, + "stats": { + "skills": len(skills), + "mcp_servers": len(mcp_servers), + "memory": len(memory), + "installed_skills": len(installed_skills), + }, + } + (export_dir / "apc-export.json").write_text(json.dumps(metadata, indent=2)) + + success(f"Exported to {export_dir}") + if public_key: + info(f"Private key: {_identity_path()}") + info("Transfer this key to the target machine to decrypt secrets.") + + +# --------------------------------------------------------------------------- +# apc import +# --------------------------------------------------------------------------- + + +@click.command("import") +@click.argument("path", default="apc-export") +@click.option("--no-secrets", is_flag=True, help="Skip secret decryption") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +def import_cmd(path: str, no_secrets: bool, yes: bool): + """Import APC configs from an export directory. + + Decrypts secrets using the age private key at ~/.apc/age-identity.txt. + Transfer the key from the source machine before importing. + + \b + Examples: + apc import # import from ./apc-export/ + apc import /tmp/my-config # import from custom path + apc import --no-secrets # skip secret decryption + """ + header("Import") + import_dir = Path(path).resolve() + + # Validate + meta_path = import_dir / "apc-export.json" + if not meta_path.exists(): + error(f"Not a valid export directory: {import_dir}") + error("Expected apc-export.json metadata file.") + raise SystemExit(1) + + metadata = json.loads(meta_path.read_text()) + schema = metadata.get("schema_version", 0) + if schema > SCHEMA_VERSION: + error(f"Export schema version {schema} is newer than supported ({SCHEMA_VERSION}).") + error("Please upgrade APC: pip install --upgrade apc") + raise SystemExit(1) + + # Load private key + private_key: Optional[str] = None + has_encrypted = metadata.get("public_key") is not None + + if has_encrypted and not no_secrets: + if _check_pyrage(): + private_key = _load_identity() + if not private_key: + warning("Age private key not found at ~/.apc/age-identity.txt") + warning("Transfer it from the source machine to decrypt secrets.") + warning("Continuing without secret decryption.") + else: + warning("pyrage not installed — cannot decrypt secrets.") + warning("Install with: pip install pyrage") + + stats = metadata.get("stats", {}) + info(f"Import path: {import_dir}") + info(f"Created: {metadata.get('created_at', 'unknown')}") + info( + f"Skills: {stats.get('skills', 0)}, " + f"MCP servers: {stats.get('mcp_servers', 0)}, " + f"Memory: {stats.get('memory', 0)}" + ) + if stats.get("installed_skills"): + info(f"Installed skills: {stats['installed_skills']}") + + if not yes: + if not click.confirm("\nProceed with import?"): + info("Cancelled.") + return + + config_dir = get_config_dir() + + # 1. Import skills cache + skills_path = import_dir / "cache" / "skills.json" + if skills_path.exists(): + new_skills = json.loads(skills_path.read_text()) + existing = load_local_bundle()["skills"] + merged = merge_skills(existing, new_skills) + save_skills(merged) + success(f"Skills: {len(new_skills)} imported ({len(merged)} total)") + + # 2. Import memory cache + memory_path = import_dir / "cache" / "memory.json" + if memory_path.exists(): + new_memory = json.loads(memory_path.read_text()) + existing_mem = load_local_bundle()["memory"] + merged_mem = merge_memory(existing_mem, new_memory) + save_memory(merged_mem) + success(f"Memory: {len(new_memory)} imported ({len(merged_mem)} total)") + + # 3. Import MCP servers cache (decrypt secrets) + mcp_path = import_dir / "cache" / "mcp_servers.json" + if mcp_path.exists(): + new_mcp = json.loads(mcp_path.read_text()) + clean_mcp, secrets = _import_mcp_servers(new_mcp, private_key) + + if secrets: + store_secrets_batch("local", secrets) + success(f"Stored {len(secrets)} secrets in keychain") + + existing_mcp = load_mcp_servers() + merged_mcp = merge_mcp_servers(existing_mcp, clean_mcp) + save_mcp_servers(merged_mcp) + success(f"MCP servers: {len(new_mcp)} imported ({len(merged_mcp)} total)") + + # 4. Copy installed skills + import_skills_dir = import_dir / "skills" + if import_skills_dir.exists(): + skills_dir = get_skills_dir() + skills_dir.mkdir(parents=True, exist_ok=True) + count = 0 + for src in sorted(import_skills_dir.iterdir()): + if src.is_dir() and (src / "SKILL.md").exists(): + dst = skills_dir / src.name + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst) + count += 1 + if count: + success(f"Installed skills: {count} copied to {skills_dir}") + + # 5. Import config files + _import_config_file( + import_dir / "config" / "marketplaces.json", + config_dir / "marketplaces.json", + "marketplaces.json", + ) + _import_config_file( + import_dir / "config" / "models.json", + config_dir / "models.json", + "models.json", + ) + + # Auth profiles (decrypt) + auth_src = import_dir / "config" / "auth-profiles.json" + if auth_src.exists(): + imported_auth = json.loads(auth_src.read_text()) + decrypted_auth = _import_auth_profiles(imported_auth, private_key) + + # Merge with existing + auth_dst = config_dir / "auth-profiles.json" + if auth_dst.exists(): + existing_auth = json.loads(auth_dst.read_text(encoding="utf-8")) + # Merge profiles + for pkey, profile in decrypted_auth.get("profiles", {}).items(): + existing_auth.setdefault("profiles", {})[pkey] = profile + # Merge order + for provider, order in decrypted_auth.get("order", {}).items(): + existing_order = existing_auth.setdefault("order", {}).setdefault(provider, []) + for key in order: + if key not in existing_order: + existing_order.append(key) + auth_dst.write_text(json.dumps(existing_auth, indent=2), encoding="utf-8") + else: + auth_dst.write_text(json.dumps(decrypted_auth, indent=2), encoding="utf-8") + success("Imported auth-profiles.json") + + success("Import complete.") + info("Run 'apc sync' to apply to your tools.") + + +def _import_config_file(src: Path, dst: Path, label: str) -> None: + """Copy a config file if it exists in the export.""" + if src.exists(): + shutil.copy2(src, dst) + success(f"Imported {label}") diff --git a/src/main.py b/src/main.py index 1e20409..10de807 100644 --- a/src/main.py +++ b/src/main.py @@ -4,6 +4,7 @@ from cache import load_local_bundle from collect import collect +from export_import import export_cmd, import_cmd from llm_config import configure_cmd, models_cmd from mcp import mcp from memory import memory @@ -62,6 +63,10 @@ def cli(): cli.add_command(configure_cmd) cli.add_command(models_cmd) +# Export / Import +cli.add_command(export_cmd) +cli.add_command(import_cmd) + @cli.command() @click.option( diff --git a/src/sync_helpers.py b/src/sync_helpers.py index d1ec6b9..e0c8517 100644 --- a/src/sync_helpers.py +++ b/src/sync_helpers.py @@ -3,15 +3,28 @@ Used by `apc sync`, `apc skill sync`, `apc memory sync`, and `apc mcp sync`. """ -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple 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 ui import error, numbered_selection, success, warning +def _resolve_all_mcp_secrets(mcp_servers: List[Dict]) -> Dict[str, str]: + """Collect all secret_placeholders from MCP servers and resolve from keychain.""" + secrets: Dict[str, str] = {} + for srv in mcp_servers: + for key in srv.get("secret_placeholders", []): + if key not in secrets: + value = retrieve_secret("local", key) + if value: + secrets[key] = value + return secrets + + def _discover_installed_skills() -> List[dict]: """Find installed skills from ~/.apc/skills/ (directories with SKILL.md).""" skills_dir = get_skills_dir() @@ -113,7 +126,8 @@ def sync_mcp(tool_list: List[str], override: bool = False) -> int: applier = get_applier(tool_name) manifest = applier.get_manifest() - m = applier.apply_mcp_servers(mcp_servers, {}, manifest, override=override) + secrets = _resolve_all_mcp_secrets(mcp_servers) + m = applier.apply_mcp_servers(mcp_servers, secrets, manifest, override=override) # Prune orphaned MCP servers (keep skill names empty — not our concern) applier.prune([], current_mcp_names, manifest) manifest.save() @@ -186,7 +200,8 @@ def sync_all(tool_list: List[str], no_memory: bool = False, override_mcp: bool = lk = applier.link_skills(installed_skills, skills_dir, manifest) # MCP servers - m = applier.apply_mcp_servers(mcp_servers, {}, manifest, override=override_mcp) + secrets = _resolve_all_mcp_secrets(mcp_servers) + m = applier.apply_mcp_servers(mcp_servers, secrets, manifest, override=override_mcp) # Memory mem = 0 diff --git a/tests/test_docker_integration.py b/tests/test_docker_integration.py index 7deeb53..a521dd3 100644 --- a/tests/test_docker_integration.py +++ b/tests/test_docker_integration.py @@ -8,6 +8,7 @@ """ import json +import shutil import textwrap from pathlib import Path @@ -646,3 +647,261 @@ def test_memory_add_persists_across_collect(self, runner, cli): data = json.loads((HOME / ".apc" / "cache" / "memory.json").read_text()) contents = [e.get("content", "") for e in data] assert "Persist across collect" in contents + + +# --------------------------------------------------------------------------- +# Phase 13: apc export / apc import +# --------------------------------------------------------------------------- + + +class TestExport: + @pytest.fixture(autouse=True) + def _ensure_collected(self, runner, cli): + runner.invoke(cli, ["collect", "--yes"]) + + @pytest.fixture + def export_path(self, tmp_path): + return tmp_path / "test-export" + + 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 + + def test_export_creates_metadata(self, runner, cli, export_path): + runner.invoke(cli, ["export", str(export_path), "--yes"]) + meta_path = export_path / "apc-export.json" + assert meta_path.exists(), "apc-export.json not created" + meta = json.loads(meta_path.read_text()) + assert meta["schema_version"] == 1 + assert "created_at" in meta + assert "stats" in meta + + def test_export_creates_cache_files(self, runner, cli, export_path): + runner.invoke(cli, ["export", str(export_path), "--yes"]) + assert (export_path / "cache" / "skills.json").exists() + assert (export_path / "cache" / "mcp_servers.json").exists() + assert (export_path / "cache" / "memory.json").exists() + + def test_export_cache_has_data(self, runner, cli, export_path): + runner.invoke(cli, ["export", str(export_path), "--yes"]) + + skills = json.loads((export_path / "cache" / "skills.json").read_text()) + assert len(skills) > 0, "Exported skills.json is empty" + + mcp = json.loads((export_path / "cache" / "mcp_servers.json").read_text()) + assert len(mcp) > 0, "Exported mcp_servers.json is empty" + + memory = json.loads((export_path / "cache" / "memory.json").read_text()) + assert len(memory) > 0, "Exported memory.json is empty" + + def test_export_stats_match_cache(self, runner, cli, export_path): + runner.invoke(cli, ["export", str(export_path), "--yes"]) + + meta = json.loads((export_path / "apc-export.json").read_text()) + stats = meta["stats"] + + skills = json.loads((export_path / "cache" / "skills.json").read_text()) + mcp = json.loads((export_path / "cache" / "mcp_servers.json").read_text()) + memory = json.loads((export_path / "cache" / "memory.json").read_text()) + + assert stats["skills"] == len(skills) + assert stats["mcp_servers"] == len(mcp) + assert stats["memory"] == len(memory) + + def test_export_no_secrets_flag(self, runner, cli, export_path): + result = runner.invoke(cli, ["export", str(export_path), "--no-secrets", "--yes"]) + assert result.exit_code == 0, result.output + + meta = json.loads((export_path / "apc-export.json").read_text()) + assert meta["public_key"] is None + + def test_export_has_age_public_key(self, runner, cli, export_path): + result = runner.invoke(cli, ["export", str(export_path), "--yes"]) + assert result.exit_code == 0, result.output + + meta = json.loads((export_path / "apc-export.json").read_text()) + # pyrage should be installed, so public key should be present + assert meta["public_key"] is not None + assert meta["public_key"].startswith("age1") + + def test_export_creates_age_identity(self, runner, cli, export_path): + runner.invoke(cli, ["export", str(export_path), "--yes"]) + identity_path = HOME / ".apc" / "age-identity.txt" + assert identity_path.exists(), "age-identity.txt not created" + + def test_export_idempotent(self, runner, cli, export_path): + """Exporting twice to the same path should succeed (overwrite).""" + r1 = runner.invoke(cli, ["export", str(export_path), "--yes"]) + assert r1.exit_code == 0 + r2 = runner.invoke(cli, ["export", str(export_path), "--yes"]) + assert r2.exit_code == 0 + + def test_export_copies_config_files(self, runner, cli, export_path): + """After configure, exported dir should contain config files.""" + # Set up auth profile and models + runner.invoke( + cli, + ["configure", "--provider", "anthropic", "--api-key", "sk-test", "--non-interactive"], + ) + runner.invoke(cli, ["export", str(export_path), "--yes"]) + + assert (export_path / "config" / "models.json").exists() + assert (export_path / "config" / "auth-profiles.json").exists() + + def test_export_encrypts_auth_profile_keys(self, runner, cli, export_path): + """Auth profile keys should be encrypted in the export.""" + runner.invoke( + cli, + ["configure", "--provider", "openai", "--api-key", "sk-real-key", "--non-interactive"], + ) + runner.invoke(cli, ["export", str(export_path), "--yes"]) + + auth = json.loads((export_path / "config" / "auth-profiles.json").read_text()) + profile = auth["profiles"].get("openai:default", {}) + key_val = profile.get("key", "") + assert key_val.startswith("AGE:"), f"Expected encrypted key, got: {key_val[:20]}" + + +class TestImport: + @pytest.fixture(autouse=True) + def _ensure_collected(self, runner, cli): + runner.invoke(cli, ["collect", "--yes"]) + + @pytest.fixture + def export_path(self, tmp_path): + return tmp_path / "test-export" + + def _do_export(self, runner, cli, export_path): + result = runner.invoke(cli, ["export", str(export_path), "--yes"]) + assert result.exit_code == 0, result.output + + 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 + + def test_import_invalid_path(self, runner, cli, tmp_path): + result = runner.invoke(cli, ["import", str(tmp_path / "nonexistent"), "--yes"]) + assert result.exit_code != 0 + + 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 + + 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 + + +class TestExportImportRoundTrip: + """Full export → wipe → import → verify cycle.""" + + @pytest.fixture(autouse=True) + def _ensure_collected(self, runner, cli): + runner.invoke(cli, ["collect", "--yes"]) + # Add a manual memory entry to test persistence + runner.invoke(cli, ["memory", "add", "Round-trip test memory", "--category", "preference"]) + + @pytest.fixture + def export_path(self, tmp_path): + return tmp_path / "roundtrip-export" + + def test_round_trip_preserves_skills(self, runner, cli, export_path): + """Export, wipe cache, import, verify skills restored.""" + # Export + r = runner.invoke(cli, ["export", str(export_path), "--yes"]) + assert r.exit_code == 0 + + # Record original skills + orig = json.loads((HOME / ".apc" / "cache" / "skills.json").read_text()) + orig_names = sorted(s.get("name") for s in orig) + + # Wipe cache skills + (HOME / ".apc" / "cache" / "skills.json").write_text("[]") + + # Import + r = runner.invoke(cli, ["import", str(export_path), "--yes"]) + assert r.exit_code == 0 + + # Verify + restored = json.loads((HOME / ".apc" / "cache" / "skills.json").read_text()) + restored_names = sorted(s.get("name") for s in restored) + assert restored_names == orig_names + + def test_round_trip_preserves_mcp_servers(self, runner, cli, export_path): + """Export, wipe cache, import, verify MCP servers restored.""" + r = runner.invoke(cli, ["export", str(export_path), "--yes"]) + assert r.exit_code == 0 + + orig = json.loads((HOME / ".apc" / "cache" / "mcp_servers.json").read_text()) + orig_names = sorted(s.get("name") for s in orig) + + (HOME / ".apc" / "cache" / "mcp_servers.json").write_text("[]") + + r = runner.invoke(cli, ["import", str(export_path), "--yes"]) + assert r.exit_code == 0 + + restored = json.loads((HOME / ".apc" / "cache" / "mcp_servers.json").read_text()) + restored_names = sorted(s.get("name") for s in restored) + assert restored_names == orig_names + + def test_round_trip_preserves_memory(self, runner, cli, export_path): + """Export, wipe cache, import, verify memory restored including manual entry.""" + r = runner.invoke(cli, ["export", str(export_path), "--yes"]) + assert r.exit_code == 0 + + (HOME / ".apc" / "cache" / "memory.json").write_text("[]") + + r = runner.invoke(cli, ["import", str(export_path), "--yes"]) + assert r.exit_code == 0 + + restored = json.loads((HOME / ".apc" / "cache" / "memory.json").read_text()) + contents = [e.get("content", "") for e in restored] + assert "Round-trip test memory" in contents + + def test_round_trip_preserves_config(self, runner, cli, export_path): + """Export with auth profile, wipe, import, verify config restored.""" + # Set up auth + runner.invoke( + cli, + [ + "configure", + "--provider", + "anthropic", + "--api-key", + "sk-roundtrip", + "--non-interactive", + ], + ) + + r = runner.invoke(cli, ["export", str(export_path), "--yes"]) + assert r.exit_code == 0 + + # Wipe auth profiles + auth_path = HOME / ".apc" / "auth-profiles.json" + auth_path.write_text(json.dumps({"version": 1, "profiles": {}, "order": {}})) + + r = runner.invoke(cli, ["import", str(export_path), "--yes"]) + assert r.exit_code == 0 + + # Verify auth profile was restored (key should be decrypted) + data = json.loads(auth_path.read_text()) + profile = data.get("profiles", {}).get("anthropic:default", {}) + assert profile.get("key") == "sk-roundtrip" + + def test_status_after_round_trip(self, runner, cli, export_path): + """After export → wipe → import, apc status should still work.""" + runner.invoke(cli, ["export", str(export_path), "--yes"]) + + # Wipe all cache + cache_dir = HOME / ".apc" / "cache" + if cache_dir.exists(): + shutil.rmtree(cache_dir) + cache_dir.mkdir() + + runner.invoke(cli, ["import", str(export_path), "--yes"]) + + r = runner.invoke(cli, ["status"]) + assert r.exit_code == 0 diff --git a/tests/test_export_import.py b/tests/test_export_import.py new file mode 100644 index 0000000..c5d1e31 --- /dev/null +++ b/tests/test_export_import.py @@ -0,0 +1,524 @@ +"""Tests for export/import functionality and age encryption.""" + +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from export_import import ( + AGE_PREFIX, + SCHEMA_VERSION, + _export_auth_profiles, + _export_mcp_servers, + _import_auth_profiles, + _import_mcp_servers, + decrypt_value, + encrypt_value, + export_cmd, + import_cmd, + is_encrypted, +) + + +class TestAgeEncryption(unittest.TestCase): + """Test encrypt/decrypt round-trip with pyrage.""" + + def setUp(self): + try: + from pyrage import x25519 + + identity = x25519.Identity.generate() + self.public_key = str(identity.to_public()) + self.private_key = str(identity) + self.pyrage_available = True + except ImportError: + self.pyrage_available = False + + @unittest.skipUnless( + __import__("importlib").util.find_spec("pyrage"), + "pyrage not installed", + ) + def test_encrypt_decrypt_round_trip(self): + plaintext = "sk-ant-api03-secret-key-here" + encrypted = encrypt_value(plaintext, self.public_key) + + self.assertTrue(encrypted.startswith(AGE_PREFIX)) + self.assertNotEqual(encrypted, plaintext) + + decrypted = decrypt_value(encrypted, self.private_key) + self.assertEqual(decrypted, plaintext) + + @unittest.skipUnless( + __import__("importlib").util.find_spec("pyrage"), + "pyrage not installed", + ) + def test_encrypt_empty_string(self): + encrypted = encrypt_value("", self.public_key) + self.assertTrue(encrypted.startswith(AGE_PREFIX)) + decrypted = decrypt_value(encrypted, self.private_key) + self.assertEqual(decrypted, "") + + @unittest.skipUnless( + __import__("importlib").util.find_spec("pyrage"), + "pyrage not installed", + ) + def test_decrypt_wrong_key_returns_none(self): + from pyrage import x25519 + + other = x25519.Identity.generate() + encrypted = encrypt_value("secret", self.public_key) + result = decrypt_value(encrypted, str(other)) + self.assertIsNone(result) + + def test_decrypt_non_encrypted_passes_through(self): + result = decrypt_value("plain-text-value", "unused-key") + self.assertEqual(result, "plain-text-value") + + def test_is_encrypted(self): + self.assertTrue(is_encrypted("AGE:abc123")) + self.assertFalse(is_encrypted("plain")) + self.assertFalse(is_encrypted("")) + + +class TestExportMCPServers(unittest.TestCase): + """Test MCP server export with secret encryption.""" + + @unittest.skipUnless( + __import__("importlib").util.find_spec("pyrage"), + "pyrage not installed", + ) + def test_encrypts_secrets_from_keychain(self): + from pyrage import x25519 + + identity = x25519.Identity.generate() + pub = str(identity.to_public()) + priv = str(identity) + + servers = [ + { + "name": "test-server", + "transport": "stdio", + "command": "node", + "args": ["server.js"], + "env": {"TOKEN": "${TOKEN}", "URL": "http://localhost"}, + "secret_placeholders": ["TOKEN"], + "source_tool": "claude", + } + ] + + with patch("export_import.retrieve_secret", return_value="my-secret-token"): + result = _export_mcp_servers(servers, pub) + + self.assertEqual(len(result), 1) + self.assertIn("encrypted_secrets", result[0]) + self.assertTrue(result[0]["encrypted_secrets"]["TOKEN"].startswith(AGE_PREFIX)) + + # Verify round-trip + decrypted = decrypt_value(result[0]["encrypted_secrets"]["TOKEN"], priv) + self.assertEqual(decrypted, "my-secret-token") + + def test_no_encryption_without_key(self): + servers = [ + { + "name": "test", + "secret_placeholders": ["TOKEN"], + } + ] + with patch("export_import.retrieve_secret", return_value="secret"): + result = _export_mcp_servers(servers, None) + + self.assertNotIn("encrypted_secrets", result[0]) + + def test_missing_secret_warns_and_skips(self): + servers = [ + { + "name": "test", + "secret_placeholders": ["MISSING_TOKEN"], + } + ] + # pyrage needed for public key + try: + from pyrage import x25519 + + pub = str(x25519.Identity.generate().to_public()) + except ImportError: + self.skipTest("pyrage not installed") + + with patch("export_import.retrieve_secret", return_value=None): + result = _export_mcp_servers(servers, pub) + + # Should not have encrypted_secrets since the secret was missing + self.assertNotIn("encrypted_secrets", result[0]) + + +class TestImportMCPServers(unittest.TestCase): + """Test MCP server import with secret decryption.""" + + @unittest.skipUnless( + __import__("importlib").util.find_spec("pyrage"), + "pyrage not installed", + ) + def test_decrypts_secrets(self): + from pyrage import x25519 + + identity = x25519.Identity.generate() + pub = str(identity.to_public()) + priv = str(identity) + + encrypted = encrypt_value("my-token", pub) + servers = [ + { + "name": "test-server", + "env": {"TOKEN": "${TOKEN}"}, + "secret_placeholders": ["TOKEN"], + "encrypted_secrets": {"TOKEN": encrypted}, + } + ] + + clean, secrets = _import_mcp_servers(servers, priv) + + self.assertEqual(secrets, {"TOKEN": "my-token"}) + self.assertNotIn("encrypted_secrets", clean[0]) + + def test_no_key_skips_decryption(self): + servers = [ + { + "name": "test-server", + "encrypted_secrets": {"TOKEN": "AGE:abc123"}, + } + ] + clean, secrets = _import_mcp_servers(servers, None) + + self.assertEqual(secrets, {}) + self.assertNotIn("encrypted_secrets", clean[0]) + + def test_no_encrypted_secrets_is_passthrough(self): + servers = [{"name": "plain-server", "env": {"URL": "http://localhost"}}] + clean, secrets = _import_mcp_servers(servers, None) + + self.assertEqual(len(clean), 1) + self.assertEqual(clean[0]["name"], "plain-server") + self.assertEqual(secrets, {}) + + +class TestExportAuthProfiles(unittest.TestCase): + """Test auth profile export with encryption.""" + + @unittest.skipUnless( + __import__("importlib").util.find_spec("pyrage"), + "pyrage not installed", + ) + def test_encrypts_key_and_token(self): + from pyrage import x25519 + + identity = x25519.Identity.generate() + pub = str(identity.to_public()) + priv = str(identity) + + data = { + "version": 1, + "profiles": { + "anthropic:default": { + "type": "api_key", + "provider": "anthropic", + "key": "sk-ant-api03-secret", + }, + "anthropic:token": { + "type": "token", + "provider": "anthropic", + "token": "setup-token-value", + }, + }, + "order": {"anthropic": ["anthropic:default", "anthropic:token"]}, + } + + result = _export_auth_profiles(data, pub) + + # Keys should be encrypted + self.assertTrue(result["profiles"]["anthropic:default"]["key"].startswith(AGE_PREFIX)) + self.assertTrue(result["profiles"]["anthropic:token"]["token"].startswith(AGE_PREFIX)) + + # Round-trip + self.assertEqual( + decrypt_value(result["profiles"]["anthropic:default"]["key"], priv), + "sk-ant-api03-secret", + ) + + def test_no_encryption_without_key(self): + data = { + "version": 1, + "profiles": { + "openai:default": {"type": "api_key", "key": "sk-openai"}, + }, + "order": {}, + } + result = _export_auth_profiles(data, None) + self.assertEqual(result["profiles"]["openai:default"]["key"], "sk-openai") + + +class TestImportAuthProfiles(unittest.TestCase): + """Test auth profile import with decryption.""" + + @unittest.skipUnless( + __import__("importlib").util.find_spec("pyrage"), + "pyrage not installed", + ) + def test_decrypts_profiles(self): + from pyrage import x25519 + + identity = x25519.Identity.generate() + pub = str(identity.to_public()) + priv = str(identity) + + encrypted_key = encrypt_value("sk-real-key", pub) + data = { + "version": 1, + "profiles": { + "openai:default": { + "type": "api_key", + "provider": "openai", + "key": encrypted_key, + }, + }, + "order": {}, + } + + result = _import_auth_profiles(data, priv) + self.assertEqual(result["profiles"]["openai:default"]["key"], "sk-real-key") + + def test_no_key_clears_encrypted_fields(self): + data = { + "version": 1, + "profiles": { + "test:default": { + "type": "api_key", + "key": "AGE:encrypted-data", + }, + }, + "order": {}, + } + result = _import_auth_profiles(data, None) + self.assertEqual(result["profiles"]["test:default"]["key"], "") + + +class TestExportCommand(unittest.TestCase): + """Test the export CLI command end-to-end.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.export_dir = Path(self.tmpdir) / "test-export" + + @patch("export_import._check_pyrage", return_value=False) + @patch("export_import.get_skills_dir") + @patch("export_import.get_config_dir") + @patch("export_import.load_mcp_servers", return_value=[]) + @patch( + "export_import.load_local_bundle", + return_value={"skills": [], "mcp_servers": [], "memory": []}, + ) + def test_export_creates_structure( + self, mock_bundle, mock_mcp, mock_config, mock_skills, mock_pyrage + ): + config_dir = Path(self.tmpdir) / "config" + config_dir.mkdir() + mock_config.return_value = config_dir + mock_skills.return_value = config_dir / "skills" + + from click.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(export_cmd, [str(self.export_dir), "--yes"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertTrue((self.export_dir / "apc-export.json").exists()) + self.assertTrue((self.export_dir / "cache" / "skills.json").exists()) + self.assertTrue((self.export_dir / "cache" / "mcp_servers.json").exists()) + self.assertTrue((self.export_dir / "cache" / "memory.json").exists()) + + meta = json.loads((self.export_dir / "apc-export.json").read_text()) + self.assertEqual(meta["schema_version"], SCHEMA_VERSION) + self.assertIsNone(meta["public_key"]) + + @patch("export_import._check_pyrage", return_value=False) + @patch("export_import.get_skills_dir") + @patch("export_import.get_config_dir") + @patch("export_import.load_mcp_servers", return_value=[]) + @patch( + "export_import.load_local_bundle", + return_value={ + "skills": [{"name": "test-skill", "body": "# Test"}], + "mcp_servers": [], + "memory": [{"id": "mem1", "content": "Remember this"}], + }, + ) + def test_export_writes_cache_data( + self, mock_bundle, mock_mcp, mock_config, mock_skills, mock_pyrage + ): + config_dir = Path(self.tmpdir) / "config" + config_dir.mkdir() + mock_config.return_value = config_dir + mock_skills.return_value = config_dir / "skills" + + from click.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(export_cmd, [str(self.export_dir), "--yes"]) + + self.assertEqual(result.exit_code, 0, result.output) + + skills = json.loads((self.export_dir / "cache" / "skills.json").read_text()) + self.assertEqual(len(skills), 1) + self.assertEqual(skills[0]["name"], "test-skill") + + memory = json.loads((self.export_dir / "cache" / "memory.json").read_text()) + self.assertEqual(len(memory), 1) + + +class TestImportCommand(unittest.TestCase): + """Test the import CLI command end-to-end.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.export_dir = Path(self.tmpdir) / "test-export" + self._create_export_fixture() + + def _create_export_fixture(self): + """Create a minimal valid export directory.""" + self.export_dir.mkdir(parents=True) + (self.export_dir / "cache").mkdir() + (self.export_dir / "config").mkdir() + + meta = { + "schema_version": SCHEMA_VERSION, + "created_at": "2026-01-01T00:00:00+00:00", + "public_key": None, + "stats": {"skills": 1, "mcp_servers": 0, "memory": 1, "installed_skills": 0}, + } + (self.export_dir / "apc-export.json").write_text(json.dumps(meta)) + (self.export_dir / "cache" / "skills.json").write_text( + json.dumps([{"name": "imported-skill", "body": "# Imported"}]) + ) + (self.export_dir / "cache" / "mcp_servers.json").write_text(json.dumps([])) + (self.export_dir / "cache" / "memory.json").write_text( + json.dumps([{"id": "m1", "content": "Imported memory"}]) + ) + + @patch("export_import._check_pyrage", return_value=False) + @patch("export_import.get_skills_dir") + @patch("export_import.get_config_dir") + @patch("export_import.save_mcp_servers") + @patch("export_import.load_mcp_servers", return_value=[]) + @patch("export_import.save_memory") + @patch("export_import.save_skills") + @patch( + "export_import.load_local_bundle", + return_value={"skills": [], "mcp_servers": [], "memory": []}, + ) + def test_import_merges_cache( + self, + mock_bundle, + mock_save_skills, + mock_save_memory, + mock_load_mcp, + mock_save_mcp, + mock_config, + mock_skills, + mock_pyrage, + ): + config_dir = Path(self.tmpdir) / "config" + config_dir.mkdir(exist_ok=True) + mock_config.return_value = config_dir + mock_skills.return_value = config_dir / "skills" + + from click.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(import_cmd, [str(self.export_dir), "--yes"]) + + self.assertEqual(result.exit_code, 0, result.output) + mock_save_skills.assert_called_once() + mock_save_memory.assert_called_once() + + # Verify merged data + saved_skills = mock_save_skills.call_args[0][0] + self.assertEqual(len(saved_skills), 1) + self.assertEqual(saved_skills[0]["name"], "imported-skill") + + def test_import_rejects_invalid_directory(self): + from click.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(import_cmd, ["/nonexistent/path", "--yes"]) + self.assertNotEqual(result.exit_code, 0) + + def test_import_rejects_future_schema(self): + meta = { + "schema_version": 999, + "created_at": "2026-01-01T00:00:00+00:00", + "public_key": None, + "stats": {}, + } + (self.export_dir / "apc-export.json").write_text(json.dumps(meta)) + + from click.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(import_cmd, [str(self.export_dir), "--yes"]) + self.assertNotEqual(result.exit_code, 0) + + +class TestSyncHelpersMCPSecretsFix(unittest.TestCase): + """Test that _resolve_all_mcp_secrets works correctly.""" + + def test_resolves_secrets_from_keychain(self): + from sync_helpers import _resolve_all_mcp_secrets + + servers = [ + { + "name": "server-a", + "env": {"TOKEN": "${TOKEN}", "URL": "http://localhost"}, + "secret_placeholders": ["TOKEN"], + }, + { + "name": "server-b", + "env": {"API_KEY": "${API_KEY}"}, + "secret_placeholders": ["API_KEY"], + }, + ] + + with patch("sync_helpers.retrieve_secret") as mock_retrieve: + mock_retrieve.side_effect = lambda uid, key: { + "TOKEN": "token-value", + "API_KEY": "key-value", + }.get(key) + + result = _resolve_all_mcp_secrets(servers) + + self.assertEqual(result, {"TOKEN": "token-value", "API_KEY": "key-value"}) + + def test_missing_secret_excluded(self): + from sync_helpers import _resolve_all_mcp_secrets + + servers = [ + { + "name": "server", + "secret_placeholders": ["MISSING"], + } + ] + + with patch("sync_helpers.retrieve_secret", return_value=None): + result = _resolve_all_mcp_secrets(servers) + + self.assertEqual(result, {}) + + def test_no_placeholders(self): + from sync_helpers import _resolve_all_mcp_secrets + + servers = [{"name": "plain", "env": {"URL": "http://localhost"}}] + result = _resolve_all_mcp_secrets(servers) + self.assertEqual(result, {}) + + +if __name__ == "__main__": + unittest.main() From 3beb519f880de47e23b0d4f9f4642c721bf0e0ba Mon Sep 17 00:00:00 2001 From: Ace Date: Thu, 5 Mar 2026 01:02:03 -0800 Subject: [PATCH 08/12] =?UTF-8?q?test:=20real=20install=E2=86=92sync=20int?= =?UTF-8?q?egration=20tests,=20lazy=20cursor=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test changes: - Replace all mock/patch in TestInstall and TestInstallThenSync with real GitHub network calls against anthropics/skills - Tests now verify actual file-system results (SKILL.md written, .mdc symlinked into tool dir, skill list, status after sync) Applier fix: - cursor.py: replace module-level Path.home() constants (evaluated once at import) with lazy _cursor_dir/rules/mcp_json() helper functions evaluated at call time — required for HOME isolation in tests - SKILL_DIR changed from class attribute to @property so it respects the current /Users/frank at sync time Unit test fix: - test_appliers.py: patch _cursor_rules_dir/_cursor_mcp_json functions instead of removed module-level constants --- src/appliers/cursor.py | 52 ++-- tests/test_appliers.py | 4 +- tests/test_docker_integration.py | 420 +++++++++++++------------------ 3 files changed, 212 insertions(+), 264 deletions(-) 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/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 a35bc62..ca4ccdb 100644 --- a/tests/test_docker_integration.py +++ b/tests/test_docker_integration.py @@ -570,294 +570,234 @@ def test_configure_writes_models_json(self, runner, cli): # --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# Phase 11: apc install (real GitHub network calls, no mocks) +# --------------------------------------------------------------------------- + + class TestInstall: - """Tests for apc install — repo-first GitHub skill installation.""" + """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. + """ - def test_install_invalid_repo_format(self, runner, cli): - """Non-slug repos are rejected with a clear error.""" - result = runner.invoke(cli, ["install", "https://github.com/owner/repo"]) + 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 slug" in result.output.lower() or "usage error" in result.output.lower() + assert "owner/repo slug" in result.output.lower() def test_install_invalid_no_slash(self, runner, cli): - """Repo without a slash is rejected.""" + """A bare name with no slash is rejected immediately.""" result = runner.invoke(cli, ["install", "notaslug"]) assert result.exit_code != 0 - def test_install_list_mocked(self, runner, cli, monkeypatch): - """--list prints available skills from the repo.""" - from unittest.mock import patch - - mock_skills = ["frontend-design", "skill-creator", "pdf"] - - with patch("install.list_skills_in_repo", return_value=mock_skills): - result = runner.invoke(cli, ["install", "owner/repo", "--list"]) - + 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 "frontend-design" in result.output - assert "skill-creator" in result.output - assert "pdf" in result.output - assert "3 skill(s) found" in result.output - - def test_install_list_empty_repo(self, runner, cli): - """--list on a repo with no skills prints an error.""" - from unittest.mock import patch - - with patch("install.list_skills_in_repo", return_value=[]): - result = runner.invoke(cli, ["install", "owner/repo", "--list"]) - - assert "no skills found" in result.output.lower() - - def test_install_single_skill_mocked(self, runner, cli, monkeypatch): - """Installing a single skill fetches, saves to cache, and applies to agents.""" - from unittest.mock import patch - - mock_skill = { - "name": "frontend-design", - "description": "Frontend design skill", - "body": "Frontend skill body.", - "tags": ["design"], - "targets": [], - "version": "1.0.0", - "source_tool": "github", - "source_repo": "owner/repo", - "_raw_content": "---\nname: frontend-design\n---\nFrontend skill body.", - } - - with ( - patch("install.fetch_skill_from_repo", return_value=mock_skill), - patch("install._apply_skill_to_agents", return_value=1), - ): - result = runner.invoke( - cli, - ["install", "owner/repo", "--skill", "frontend-design", "-a", "cursor", "-y"], - ) + assert "•" in result.output + assert "skill(s) found" in result.output + assert self.KNOWN_SKILL in result.output - assert result.exit_code == 0 + 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, "-a", "cursor", "-y"], + ) + assert result.exit_code == 0, result.output assert "✓" in result.output - assert "frontend-design" 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_skill_not_found(self, runner, cli): - """A skill that doesn't exist in the repo prints a clear not-found message.""" - from unittest.mock import patch - - with patch("install.fetch_skill_from_repo", return_value=None): - result = runner.invoke( - cli, - ["install", "owner/repo", "--skill", "nonexistent-skill", "-a", "cursor", "-y"], - ) + 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", + "-a", + "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", + "-a", + "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_mocked(self, runner, cli): - """--all fetches and installs every skill in the repo.""" - from unittest.mock import patch - - skill_names = ["skill-a", "skill-b"] - - def fake_fetch(repo, name, branch="main"): - return { - "name": name, - "description": "", - "body": f"{name} body", - "tags": [], - "targets": [], - "version": "1.0.0", - "source_tool": "github", - "source_repo": repo, - "_raw_content": f"---\nname: {name}\n---\n{name} body", - } - - with ( - patch("install.list_skills_in_repo", return_value=skill_names), - patch("install.fetch_skill_from_repo", side_effect=fake_fetch), - patch("install._apply_skill_to_agents", return_value=1), - ): - result = runner.invoke(cli, ["install", "owner/repo", "--all", "-a", "cursor", "-y"]) - - assert result.exit_code == 0 - assert "2 skill(s)" in result.output - - def test_install_yes_flag_skips_confirmation(self, runner, cli): - """The -y flag proceeds without interactive prompts.""" - from unittest.mock import patch - - mock_skill = { - "name": "test-skill", - "description": "", - "body": "body", - "tags": [], - "targets": [], - "version": "1.0.0", - "source_tool": "github", - "source_repo": "owner/repo", - "_raw_content": "---\nname: test-skill\n---\nbody", - } - - with ( - patch("install.fetch_skill_from_repo", return_value=mock_skill), - patch("install._apply_skill_to_agents", return_value=1), - ): - result = runner.invoke( - cli, ["install", "owner/repo", "-s", "test-skill", "-a", "cursor", "-y"] - ) + 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", "-a", "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)}" - # Should complete without asking any questions + 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, "-a", "cursor", "-y"], + ) assert result.exit_code == 0 assert "Proceed?" not in result.output + def test_install_target_all_agents(self, runner, cli, tmp_path, monkeypatch): + """--agent '*' installs to all detected tools.""" + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / ".cursor").mkdir() # seed so cursor is detectable + result = runner.invoke( + cli, + ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "--agent", "*", "-y"], + ) + assert result.exit_code == 0, result.output + assert "✓" in result.output + # --------------------------------------------------------------------------- -# Phase 12: install → sync flow +# Phase 12: install → sync end-to-end flow (no mocks) # --------------------------------------------------------------------------- class TestInstallThenSync: - """Verify the full install → sync flow: skills fetched via apc install - are correctly picked up and applied when apc sync runs afterwards.""" + """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. + """ - def test_install_then_sync_writes_skill_to_tool(self, runner, cli, tmp_path, monkeypatch): - """Skills installed via apc install are applied to the target tool on sync.""" - from unittest.mock import patch + 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("{}") - mock_skill = { - "name": "test-install-skill", - "description": "Installed via apc install", - "body": "Test install skill body.", - "tags": ["test"], - "targets": [], - "version": "1.0.0", - "source_tool": "github", - "source_repo": "owner/repo", - "_raw_content": ( - "---\nname: test-install-skill\n" - "description: Installed via apc install\n---\n" - "Test install skill body." - ), - } - - # Step 1: apc install - with ( - patch("install.fetch_skill_from_repo", return_value=mock_skill), - patch("install._apply_skill_to_agents", return_value=1), - ): - install_result = runner.invoke( - cli, - ["install", "owner/repo", "--skill", "test-install-skill", "-a", "cursor", "-y"], - ) - assert install_result.exit_code == 0 - assert "✓" in install_result.output + r1 = runner.invoke( + cli, ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "-a", "cursor", "-y"] + ) + assert r1.exit_code == 0, r1.output - # Skill should now be in the local cache - from cache import load_skills + r2 = runner.invoke(cli, ["sync", "--tools", "cursor", "--yes"]) + assert r2.exit_code == 0, r2.output - cached = load_skills() - names = [s["name"] for s in cached] - assert "test-install-skill" in names + 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_install_creates_skill_source_file(self, runner, cli, tmp_path, monkeypatch): - """apc install saves SKILL.md to ~/.apc/skills//SKILL.md.""" + 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)) - from unittest.mock import patch - - raw = "---\nname: my-skill\nversion: 1.0.0\n---\nMy skill body." - mock_skill = { - "name": "my-skill", - "description": "", - "body": "My skill body.", - "tags": [], - "targets": [], - "version": "1.0.0", - "source_tool": "github", - "source_repo": "owner/repo", - "_raw_content": raw, - } - - with ( - patch("install.fetch_skill_from_repo", return_value=mock_skill), - patch("install._apply_skill_to_agents", return_value=1), - ): - result = runner.invoke( - cli, ["install", "owner/repo", "-s", "my-skill", "-a", "cursor", "-y"] - ) + runner.invoke( + cli, ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "-a", "cursor", "-y"] + ) + + result = runner.invoke(cli, ["skill", "list"]) assert result.exit_code == 0 - skill_file = tmp_path / ".apc" / "skills" / "my-skill" / "SKILL.md" - assert skill_file.exists(), f"SKILL.md not found at {skill_file}" - assert "My skill body." in skill_file.read_text() + assert self.KNOWN_SKILL in result.output - def test_sync_picks_up_installed_skills(self, runner, cli, tmp_path, monkeypatch): - """apc sync --dry-run reports installed skills (from ~/.apc/skills/) correctly.""" + 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("{}") - # Seed a skill directly into ~/.apc/skills/ - skill_dir = tmp_path / ".apc" / "skills" / "seeded-skill" - skill_dir.mkdir(parents=True) - (skill_dir / "SKILL.md").write_text( - "---\nname: seeded-skill\ndescription: Seeded for sync test\n---\nBody." + skills = ["pdf", "skill-creator"] + r_install = runner.invoke( + cli, + [ + "install", + self.TEST_REPO, + "--skill", + skills[0], + "--skill", + skills[1], + "-a", + "cursor", + "-y", + ], ) + assert r_install.exit_code == 0, r_install.output + assert "Installed 2 skill(s)" in r_install.output - # Seed a target tool so sync has somewhere to go - cursor_dir = tmp_path / ".cursor" - cursor_dir.mkdir() - (cursor_dir / "mcp.json").write_text("{}") + r_sync = runner.invoke(cli, ["sync", "--tools", "cursor", "--yes"]) + assert r_sync.exit_code == 0, r_sync.output - result = runner.invoke(cli, ["sync", "--tools", "cursor", "--dry-run"]) - assert result.exit_code == 0 - # dry-run should report the seeded skill in the plan - assert "seeded-skill" in result.output or "1" in result.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_multiple_then_sync_all(self, runner, cli, tmp_path, monkeypatch): - """Installing multiple skills then syncing --all applies all of them.""" + 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)) - from unittest.mock import patch - - skill_names = ["skill-one", "skill-two"] - - def fake_fetch(repo, name, branch="main"): - return { - "name": name, - "description": "", - "body": f"{name} body", - "tags": [], - "targets": [], - "version": "1.0.0", - "source_tool": "github", - "source_repo": repo, - "_raw_content": f"---\nname: {name}\n---\n{name} body", - } - - # Install both skills - with ( - patch("install.fetch_skill_from_repo", side_effect=fake_fetch), - patch("install._apply_skill_to_agents", return_value=1), - ): - for name in skill_names: - result = runner.invoke( - cli, ["install", "owner/repo", "-s", name, "-a", "cursor", "-y"] - ) - assert result.exit_code == 0 - - # Both should be in ~/.apc/skills/ - for name in skill_names: - skill_file = tmp_path / ".apc" / "skills" / name / "SKILL.md" - assert skill_file.exists(), f"Missing {skill_file}" - - # Both should appear in skill list - list_result = runner.invoke(cli, ["skill", "list"]) - assert list_result.exit_code == 0 - assert "skill-one" in list_result.output - assert "skill-two" in list_result.output + (tmp_path / ".cursor").mkdir() + (tmp_path / ".cursor" / "mcp.json").write_text("{}") + runner.invoke(cli, ["install", self.TEST_REPO, "--all", "-a", "cursor", "-y"]) -# --------------------------------------------------------------------------- -# Phase 13: Full round-trip — collect → sync → verify files -# --------------------------------------------------------------------------- + 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, "-a", "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: From 8a09b367a0d81a80795de0035c4191404f0a8ea0 Mon Sep 17 00:00:00 2001 From: Ace Date: Thu, 5 Mar 2026 21:13:50 -0800 Subject: [PATCH 09/12] chore: remove .cursor/ from repo, add tool dirs to .gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .cursor/rules/*.mdc are generated output from apc sync — they should never be committed. Added .cursor/, .claude/, .gemini/, .codeium/ to .gitignore so sync output stays local. --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) 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/ From a498aff5071752d430999bafd9cb2b765b74511c Mon Sep 17 00:00:00 2001 From: Ace Date: Thu, 5 Mar 2026 21:17:12 -0800 Subject: [PATCH 10/12] fix: use 'owner/repo format' instead of 'slug' in install error message --- src/install.py | 2 +- tests/test_docker_integration.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/install.py b/src/install.py index 38a6461..bada9ab 100644 --- a/src/install.py +++ b/src/install.py @@ -99,7 +99,7 @@ def install(repo, skills, install_all, agents, branch, list_only, yes): # Validate: repo must look like owner/repo if "/" not in repo or repo.startswith("http"): raise click.UsageError( - "REPO must be a GitHub owner/repo slug (e.g. vercel-labs/agent-skills)" + "REPO must be a GitHub repository name in owner/repo format (e.g. vercel-labs/agent-skills)" ) # --list: just show available skills and exit diff --git a/tests/test_docker_integration.py b/tests/test_docker_integration.py index 0b4be35..a741b42 100644 --- a/tests/test_docker_integration.py +++ b/tests/test_docker_integration.py @@ -590,7 +590,7 @@ 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 slug" in result.output.lower() + 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.""" From 8692331cf9aaf701bedcb14ce69fcaca23302882 Mon Sep 17 00:00:00 2001 From: Ace Date: Thu, 5 Mar 2026 21:21:41 -0800 Subject: [PATCH 11/12] test: add file-system assertions to all integration tests Previously 41 tests verified terminal output only. Now 82/93 tests assert on actual file-system side effects: - TestStatus: detect_* tests check tool dirs exist (.claude/, .cursor/, etc.) - TestCollect: verify cache/ dir + all 3 JSON files created - TestSkill: verify skills.json contains expected entries - TestMemory: verify memory.json written and contains added entries - TestMcp: verify mcp_servers.json reflects add/remove operations - TestSync: verify .claude.json mcpServers written; .cursor/rules/*.mdc created - TestSubSync: verify mcp/skill sync writes to tool dirs - TestInstall: verify SKILL.md written to ~/.apc/skills// - TestInstallThenSync: verify .mdc files land in cursor rules dir - TestExport: verify export dir structure created - TestImport: verify cache files restored - TestConfigure: verify auth-profiles.json written with correct provider 5 tests intentionally output-only (error/rejection paths): test_exits_zero, test_detects_copilot, test_mcp_remove_nonexistent, test_install_invalid_repo_url, test_install_nonexistent_skill --- src/install.py | 3 +- tests/test_docker_integration.py | 89 ++++++++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/install.py b/src/install.py index bada9ab..b0ed586 100644 --- a/src/install.py +++ b/src/install.py @@ -99,7 +99,8 @@ def install(repo, skills, install_all, agents, branch, list_only, yes): # 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/agent-skills)" + "REPO must be a GitHub repository name in owner/repo format" + " (e.g. vercel-labs/agent-skills)" ) # --list: just show available skills and exit diff --git a/tests/test_docker_integration.py b/tests/test_docker_integration.py index a741b42..4241228 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( @@ -604,6 +666,10 @@ def test_install_list_real_repo(self, runner, cli): 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.""" @@ -680,17 +746,22 @@ def test_install_yes_skips_confirmation(self, runner, cli, tmp_path, monkeypatch ) 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): """--agent '*' installs to all detected tools.""" monkeypatch.setenv("HOME", str(tmp_path)) - (tmp_path / ".cursor").mkdir() # seed so cursor is detectable + (tmp_path / ".cursor").mkdir() result = runner.invoke( cli, ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "--agent", "*", "-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" # --------------------------------------------------------------------------- @@ -736,6 +807,9 @@ def test_installed_skill_appears_in_skill_list(self, runner, cli, tmp_path, monk 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.""" @@ -882,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"]) @@ -995,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"]) @@ -1004,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: From d6617d498712e65a515ce9368a609d07772efcdd Mon Sep 17 00:00:00 2001 From: Ace Date: Thu, 5 Mar 2026 21:55:59 -0800 Subject: [PATCH 12/12] feat: rename install flag -a/--agent to -t/--target --- src/install.py | 50 ++++++++++++++++---------------- tests/test_docker_integration.py | 24 +++++++-------- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/src/install.py b/src/install.py index b0ed586..49fe984 100644 --- a/src/install.py +++ b/src/install.py @@ -15,9 +15,9 @@ _AGENTS = ["claude-code", "cursor", "gemini-cli", "github-copilot", "openclaw", "windsurf"] -def _resolve_agents(agent_args: tuple, yes: bool) -> List[str]: - """Resolve target agents from -a flags, '*', or interactive selection.""" - if not agent_args: +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) @@ -40,25 +40,25 @@ def _resolve_agents(agent_args: tuple, yes: bool) -> List[str]: indices.append(int(part) - 1) return [detected[i] for i in indices if 0 <= i < len(detected)] - agents = list(agent_args) - if "*" in agents: + targets = list(target_args) + if "*" in targets: return detect_installed_tools() - return agents + return targets -def _apply_skill_to_agents(skill: dict, agent_list: list) -> int: - """Write a skill directly to each agent's skill directory. Returns applied count.""" +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 agent_name in agent_list: + for target_name in target_list: try: - applier = get_applier(agent_name) + 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" ! {agent_name}: {e}", err=True) + click.echo(f" ! {target_name}: {e}", err=True) return count @@ -69,9 +69,9 @@ def _apply_skill_to_agents(skill: dict, agent_list: list) -> int: ) @click.option("--all", "install_all", is_flag=True, help="Install all skills from the repo.") @click.option( - "--agent", - "-a", - "agents", + "--target", + "-t", + "targets", multiple=True, help="Target tool(s) to install to. Use '*' for all detected.", ) @@ -83,7 +83,7 @@ def _apply_skill_to_agents(skill: dict, agent_list: list) -> int: 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, agents, branch, list_only, yes): +def install(repo, skills, install_all, targets, branch, list_only, yes): """Install skills from a GitHub repository. \b @@ -93,14 +93,14 @@ def install(repo, skills, install_all, agents, branch, list_only, yes): 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 -a claude-code -a cursor - apc install owner/repo --all -a claude-code -y + 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/agent-skills)" + " (e.g. vercel-labs/target-skills)" ) # --list: just show available skills and exit @@ -153,16 +153,16 @@ def install(repo, skills, install_all, agents, branch, list_only, yes): click.echo("No skills selected.", err=True) return - # Resolve target agents - agent_list = _resolve_agents(agents, yes) - if not agent_list: + # 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(agent_list)}") + click.echo(f" To: {', '.join(target_list)}") if not click.confirm("\nProceed?", default=True): click.echo("Cancelled.") return @@ -180,8 +180,8 @@ def install(repo, skills, install_all, agents, branch, list_only, yes): raw_content = skill.pop("_raw_content", skill.get("body", "")) save_skill_file(skill["name"], raw_content) - # Apply directly to each target agent - _apply_skill_to_agents(skill, agent_list) + # Apply directly to each target target + _apply_skill_to_targets(skill, target_list) # Save metadata to local cache existing = load_skills() @@ -192,6 +192,6 @@ def install(repo, skills, install_all, agents, branch, list_only, yes): click.echo(" ✓") if installed_skills: - click.echo(f"\n✓ Installed {len(installed_skills)} skill(s) to {', '.join(agent_list)}") + click.echo(f"\n✓ Installed {len(installed_skills)} skill(s) to {', '.join(target_list)}") else: click.echo("\nNo skills were installed.") diff --git a/tests/test_docker_integration.py b/tests/test_docker_integration.py index 4241228..83f1b44 100644 --- a/tests/test_docker_integration.py +++ b/tests/test_docker_integration.py @@ -676,7 +676,7 @@ def test_install_single_skill(self, runner, cli, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) result = runner.invoke( cli, - ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "-a", "cursor", "-y"], + ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "-t", "cursor", "-y"], ) assert result.exit_code == 0, result.output assert "✓" in result.output @@ -696,7 +696,7 @@ def test_install_multiple_skills(self, runner, cli, tmp_path, monkeypatch): "pdf", "--skill", "skill-creator", - "-a", + "-t", "cursor", "-y", ], @@ -716,7 +716,7 @@ def test_install_nonexistent_skill(self, runner, cli, tmp_path, monkeypatch): self.TEST_REPO, "--skill", "totally-nonexistent-xyz", - "-a", + "-t", "cursor", "-y", ], @@ -730,7 +730,7 @@ def test_install_nonexistent_skill(self, runner, cli, tmp_path, monkeypatch): 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", "-a", "cursor", "-y"]) + 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" @@ -742,7 +742,7 @@ def test_install_yes_skips_confirmation(self, runner, cli, tmp_path, monkeypatch monkeypatch.setenv("HOME", str(tmp_path)) result = runner.invoke( cli, - ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "-a", "cursor", "-y"], + ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "-t", "cursor", "-y"], ) assert result.exit_code == 0 assert "Proceed?" not in result.output @@ -751,12 +751,12 @@ def test_install_yes_skips_confirmation(self, runner, cli, tmp_path, monkeypatch assert len(skill_md.read_text()) > 0 def test_install_target_all_agents(self, runner, cli, tmp_path, monkeypatch): - """--agent '*' installs to all detected tools.""" + """--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, "--agent", "*", "-y"], + ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "--target", "*", "-y"], ) assert result.exit_code == 0, result.output assert "✓" in result.output @@ -786,7 +786,7 @@ def test_install_then_sync_symlinks_skill_to_tool(self, runner, cli, tmp_path, m (tmp_path / ".cursor" / "mcp.json").write_text("{}") r1 = runner.invoke( - cli, ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "-a", "cursor", "-y"] + cli, ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "-t", "cursor", "-y"] ) assert r1.exit_code == 0, r1.output @@ -801,7 +801,7 @@ def test_installed_skill_appears_in_skill_list(self, runner, cli, tmp_path, monk monkeypatch.setenv("HOME", str(tmp_path)) runner.invoke( - cli, ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "-a", "cursor", "-y"] + cli, ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "-t", "cursor", "-y"] ) result = runner.invoke(cli, ["skill", "list"]) @@ -827,7 +827,7 @@ def test_install_multiple_then_sync_all_land_in_tool(self, runner, cli, tmp_path skills[0], "--skill", skills[1], - "-a", + "-t", "cursor", "-y", ], @@ -850,7 +850,7 @@ def test_install_all_then_sync_dry_run(self, runner, cli, tmp_path, monkeypatch) (tmp_path / ".cursor").mkdir() (tmp_path / ".cursor" / "mcp.json").write_text("{}") - runner.invoke(cli, ["install", self.TEST_REPO, "--all", "-a", "cursor", "-y"]) + runner.invoke(cli, ["install", self.TEST_REPO, "--all", "-t", "cursor", "-y"]) installed_count = len(list((tmp_path / ".apc" / "skills").iterdir())) assert installed_count > 5 @@ -866,7 +866,7 @@ def test_status_synced_after_install_and_sync(self, runner, cli, tmp_path, monke (tmp_path / ".cursor" / "mcp.json").write_text("{}") runner.invoke( - cli, ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "-a", "cursor", "-y"] + cli, ["install", self.TEST_REPO, "--skill", self.KNOWN_SKILL, "-t", "cursor", "-y"] ) runner.invoke(cli, ["sync", "--tools", "cursor", "--yes"])