diff --git a/README.md b/README.md index 75b9f4a..01ca3cc 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ apc status apc sync # 4. Or sync to specific tools only -apc sync --tools cursor,gemini +apc sync --tools cursor,gemini-cli # 5. Install skills from a GitHub repo apc install owner/repo --skill my-skill @@ -113,7 +113,7 @@ apc configure | Flag | Description | |------|-------------| -| `--tools ` | Comma-separated tool list (e.g., `cursor,gemini`) | +| `--tools ` | Comma-separated tool list (e.g., `cursor,gemini-cli`) | | `--all` | Apply to all detected tools without prompting | | `--no-memory` | Skip memory entries | | `--override-mcp` | Replace existing MCP servers instead of merging | diff --git a/src/appliers/__init__.py b/src/appliers/__init__.py index e9e30fb..77f7ec4 100644 --- a/src/appliers/__init__.py +++ b/src/appliers/__init__.py @@ -13,24 +13,16 @@ "openclaw": "appliers.openclaw:OpenClawApplier", } -_ALIASES = { - "claude": "claude-code", - "gemini": "gemini-cli", - "copilot": "github-copilot", -} - def get_applier(tool_name: str) -> BaseApplier: """Get the applier for a supported tool. Raises ValueError if the tool is not supported. """ - resolved = _ALIASES.get(tool_name, tool_name) - - if resolved not in _SPECIALIZED: - raise ValueError(f"Unsupported tool: {tool_name}") + if tool_name not in _SPECIALIZED: + raise ValueError(f"Unsupported tool: {tool_name!r}. Valid tools: {', '.join(_SPECIALIZED)}") - module_path, cls_name = _SPECIALIZED[resolved].split(":") + module_path, cls_name = _SPECIALIZED[tool_name].split(":") mod = importlib.import_module(module_path) cls = getattr(mod, cls_name) return cls() diff --git a/src/appliers/claude.py b/src/appliers/claude.py index e3eed77..40898c7 100644 --- a/src/appliers/claude.py +++ b/src/appliers/claude.py @@ -26,7 +26,7 @@ class ClaudeApplier(BaseApplier): SKILL_DIR = CLAUDE_SKILLS_DIR - TOOL_NAME = "claude" + TOOL_NAME = "claude-code" MEMORY_SCHEMA = CLAUDE_MEMORY_SCHEMA def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int: diff --git a/src/appliers/copilot.py b/src/appliers/copilot.py index 0602ab4..1a2e707 100644 --- a/src/appliers/copilot.py +++ b/src/appliers/copilot.py @@ -69,7 +69,7 @@ class CopilotApplier(BaseApplier): - TOOL_NAME = "copilot" + TOOL_NAME = "github-copilot" MEMORY_SCHEMA = COPILOT_MEMORY_SCHEMA def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int: diff --git a/src/appliers/gemini.py b/src/appliers/gemini.py index b5a0e91..8b6531c 100644 --- a/src/appliers/gemini.py +++ b/src/appliers/gemini.py @@ -64,7 +64,7 @@ class GeminiApplier(BaseApplier): - TOOL_NAME = "gemini" + TOOL_NAME = "gemini-cli" MEMORY_SCHEMA = GEMINI_MEMORY_SCHEMA def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int: diff --git a/src/extractors/__init__.py b/src/extractors/__init__.py index ed3f721..fdf6fe7 100644 --- a/src/extractors/__init__.py +++ b/src/extractors/__init__.py @@ -15,12 +15,6 @@ "openclaw": "extractors.openclaw:OpenClawExtractor", } -_ALIASES = { - "claude": "claude-code", - "gemini": "gemini-cli", - "copilot": "github-copilot", -} - # Filesystem paths used to detect if a tool is installed _DETECT_PATHS = { "claude-code": [Path.home() / ".claude", Path.home() / ".claude.json"], @@ -46,12 +40,10 @@ def get_extractor(tool_name: str) -> BaseExtractor: Raises ValueError if the tool is not supported. """ - resolved = _ALIASES.get(tool_name, tool_name) - - if resolved not in _SPECIALIZED: - raise ValueError(f"Unsupported tool: {tool_name}") + if tool_name not in _SPECIALIZED: + raise ValueError(f"Unsupported tool: {tool_name!r}. Valid tools: {', '.join(_SPECIALIZED)}") - module_path, cls_name = _SPECIALIZED[resolved].split(":") + module_path, cls_name = _SPECIALIZED[tool_name].split(":") mod = importlib.import_module(module_path) cls = getattr(mod, cls_name) return cls() diff --git a/src/extractors/claude.py b/src/extractors/claude.py index 7378ed4..9aee380 100644 --- a/src/extractors/claude.py +++ b/src/extractors/claude.py @@ -45,7 +45,7 @@ def extract_skills(self) -> List[Dict]: "tags": metadata.get("tags", []), "targets": [], "version": metadata.get("version", "1.0.0"), - "source_tool": "claude", + "source_tool": "claude-code", "source_path": str(md_file), "checksum": checksum, } @@ -70,7 +70,7 @@ def extract_mcp_servers(self) -> List[Dict]: "command": cfg.get("command"), "args": cfg.get("args", []), "env": cfg.get("env", {}), - "source_tool": "claude", + "source_tool": "claude-code", "targets": [], } ) @@ -92,8 +92,8 @@ def extract_memory(self) -> List[Dict]: continue entries.append( { - "id": _content_hash_id("claude", path.name, content), - "source_tool": "claude", + "id": _content_hash_id("claude-code", path.name, content), + "source_tool": "claude-code", "source_file": path.name, "source_path": str(path), "label": mf["label"], diff --git a/src/extractors/copilot.py b/src/extractors/copilot.py index 042e3f4..4752543 100644 --- a/src/extractors/copilot.py +++ b/src/extractors/copilot.py @@ -28,7 +28,7 @@ def extract_skills(self) -> List[Dict]: "tags": ["copilot", "instructions"], "targets": [], "version": "1.0.0", - "source_tool": "copilot", + "source_tool": "github-copilot", "source_path": str(COPILOT_INSTRUCTIONS), "checksum": checksum, } @@ -53,7 +53,7 @@ def extract_mcp_servers(self) -> List[Dict]: "command": cfg.get("command"), "args": cfg.get("args", []), "env": cfg.get("env", {}), - "source_tool": "copilot", + "source_tool": "github-copilot", "targets": [], } ) diff --git a/src/extractors/gemini.py b/src/extractors/gemini.py index a89a1d5..cce8c74 100644 --- a/src/extractors/gemini.py +++ b/src/extractors/gemini.py @@ -29,7 +29,7 @@ def extract_mcp_servers(self) -> List[Dict]: "command": cfg.get("command"), "args": cfg.get("args", []), "env": cfg.get("env", {}), - "source_tool": "gemini", + "source_tool": "gemini-cli", "targets": [], } ) diff --git a/src/status.py b/src/status.py index ea9af6d..4e4ba07 100644 --- a/src/status.py +++ b/src/status.py @@ -3,8 +3,11 @@ No login required. No network calls. """ +from pathlib import Path + import click +from appliers.manifest import ToolManifest from cache import load_local_bundle from extractors import detect_installed_tools from ui import ( @@ -17,20 +20,48 @@ ) -def _build_tools_status(tool_list, bundle): - """Build tool status list with basic sync detection.""" - tools = [] - for name in tool_list: - has_skills = any(s.get("source_tool") == name for s in bundle["skills"]) - has_mcp = any(s.get("source_tool") == name for s in bundle["mcp_servers"]) - has_data = has_skills or has_mcp - tools.append( - { - "name": name, - "status": "synced" if has_data else "not synced", - } - ) - return tools +def _tool_sync_status(name: str) -> str: + """Return sync status for a tool by comparing manifest records to disk. + + - "not synced" — apc has never synced to this tool + - "synced" — all manifest-recorded files exist on disk + - "out of sync" — manifest exists but one or more recorded files are missing + + Manifests are keyed by the detected tool name (e.g. "claude-code"), + matching TOOL_NAME on every applier. + """ + manifest = ToolManifest(name) + + if manifest.is_first_sync: + return "not synced" + + # Gather all file paths APC last wrote for this tool + recorded_paths: list[str] = [] + + for info_dict in manifest._data.get("skills", {}).values(): + if fp := info_dict.get("file_path"): + recorded_paths.append(fp) + + for info_dict in manifest._data.get("linked_skills", {}).values(): + if fp := info_dict.get("link_path"): + recorded_paths.append(fp) + + for info_dict in manifest._data.get("memory", {}).values(): + if fp := info_dict.get("file_path"): + recorded_paths.append(fp) + + # If nothing was recorded (e.g. only MCP servers were synced), trust the timestamp + if not recorded_paths: + return "synced" + + # Check every recorded file still exists on disk + all_present = all(Path(fp).exists() for fp in recorded_paths) + return "synced" if all_present else "out of sync" + + +def _build_tools_status(tool_list): + """Build tool status list with real consistency check against disk.""" + return [{"name": name, "status": _tool_sync_status(name)} for name in tool_list] @click.command() @@ -43,7 +74,7 @@ def status(): bundle = load_local_bundle() if tool_list: - tools = _build_tools_status(tool_list, bundle) + tools = _build_tools_status(tool_list) tools_status_table(tools) else: warning("No AI tools detected on this machine.") diff --git a/src/ui.py b/src/ui.py index 36f8e18..d61572e 100644 --- a/src/ui.py +++ b/src/ui.py @@ -112,7 +112,7 @@ def cache_summary_table(skills: int, mcp: int, memory: int, title: str = "Cache def tools_status_table(tools: List[Dict[str, str]]) -> None: """Display tool status with sync badges. - tools: [{"name": "claude", "status": "synced"}, ...] + tools: [{"name": "claude-code", "status": "synced"}, ...] """ table = Table(title="Detected Tools", show_lines=False) table.add_column("Tool", style="cyan", no_wrap=True) diff --git a/tests/test_appliers.py b/tests/test_appliers.py index de27087..9eda72b 100644 --- a/tests/test_appliers.py +++ b/tests/test_appliers.py @@ -23,7 +23,7 @@ def setUp(self): self.manifest_path = Path(self.tmpdir) / "manifest.json" def _manifest(self) -> ToolManifest: - return ToolManifest("claude", path=self.manifest_path) + return ToolManifest("claude-code", path=self.manifest_path) def test_apply_skills(self): skills = [ @@ -248,7 +248,7 @@ def test_apply_mcp_prunes_orphaned_server(self): manifest.save() # Second sync: only "fs" remains - manifest2 = ToolManifest("claude", path=self.manifest_path) + manifest2 = ToolManifest("claude-code", path=self.manifest_path) servers_v2 = [ { "name": "fs", diff --git a/tests/test_docker_integration.py b/tests/test_docker_integration.py index 83f1b44..ccb15b5 100644 --- a/tests/test_docker_integration.py +++ b/tests/test_docker_integration.py @@ -276,14 +276,14 @@ def test_mcp_servers_have_correct_source_tools(self, runner, cli): runner.invoke(cli, ["collect", "--yes"]) data = json.loads((HOME / ".apc" / "cache" / "mcp_servers.json").read_text()) sources = {s.get("source_tool") for s in data} - expected = {"claude", "cursor", "gemini", "copilot", "windsurf"} + expected = {"claude-code", "cursor", "gemini-cli", "github-copilot", "windsurf"} assert expected.issubset(sources), f"Missing sources: {expected - sources}" def test_memory_has_claude_entry(self, runner, cli): runner.invoke(cli, ["collect", "--yes"]) data = json.loads((HOME / ".apc" / "cache" / "memory.json").read_text()) sources = {e.get("source_tool") for e in data} - assert "claude" in sources + assert "claude-code" in sources def test_memory_has_openclaw_entry(self, runner, cli): runner.invoke(cli, ["collect", "--yes"]) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index a688545..c82380f 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -22,7 +22,7 @@ def test_llm_memory_sync_writes_claude_md(self): claude_dir = Path(tmpdir) / ".claude" claude_dir.mkdir() claude_md = claude_dir / "CLAUDE.md" - manifest = ToolManifest("claude", path=Path(tmpdir) / "manifest.json") + manifest = ToolManifest("claude-code", path=Path(tmpdir) / "manifest.json") collected = [ { @@ -60,7 +60,7 @@ def test_no_llm_configured_shows_warning(self): from appliers.claude import ClaudeApplier tmpdir = tempfile.mkdtemp() - manifest = ToolManifest("claude", path=Path(tmpdir) / "manifest.json") + manifest = ToolManifest("claude-code", path=Path(tmpdir) / "manifest.json") collected = [{"id": "abc", "source_tool": "test", "content": "test"}] diff --git a/tests/test_extractors.py b/tests/test_extractors.py index 0707d30..6f04612 100644 --- a/tests/test_extractors.py +++ b/tests/test_extractors.py @@ -92,7 +92,7 @@ def test_extract_memory_raw_file_format(self): entry = entries[0] # New format fields self.assertIn("id", entry) - self.assertEqual(entry["source_tool"], "claude") + self.assertEqual(entry["source_tool"], "claude-code") self.assertEqual(entry["source_file"], "CLAUDE.md") self.assertIn("content", entry) self.assertIn("TypeScript", entry["content"]) diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 3c40178..29f6877 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -11,16 +11,16 @@ class TestToolManifest(unittest.TestCase): def setUp(self): self.tmpdir = tempfile.mkdtemp() - self.manifest_path = Path(self.tmpdir) / "claude.json" + self.manifest_path = Path(self.tmpdir) / "claude-code.json" def _make_manifest(self) -> ToolManifest: - return ToolManifest("claude", path=self.manifest_path) + return ToolManifest("claude-code", path=self.manifest_path) # -- empty / first sync --------------------------------------------------- def test_empty_manifest_creation(self): m = self._make_manifest() - self.assertEqual(m.tool, "claude") + self.assertEqual(m.tool, "claude-code") self.assertEqual(m.managed_skill_names(), []) self.assertEqual(m.managed_linked_skill_names(), []) self.assertEqual(m.managed_mcp_names(), []) @@ -112,7 +112,7 @@ def test_save_and_reload(self): self.assertTrue(self.manifest_path.exists()) - m2 = ToolManifest("claude", path=self.manifest_path) + m2 = ToolManifest("claude-code", path=self.manifest_path) self.assertEqual(m2.managed_skill_names(), ["pdf"]) self.assertEqual(m2.managed_mcp_names(), ["fs"]) self.assertEqual(m2.memory_entry_ids(), ["e1"]) @@ -120,7 +120,7 @@ def test_save_and_reload(self): def test_reload_corrupt_json_creates_empty(self): self.manifest_path.write_text("NOT JSON", encoding="utf-8") - m = ToolManifest("claude", path=self.manifest_path) + m = ToolManifest("claude-code", path=self.manifest_path) self.assertEqual(m.managed_skill_names(), []) self.assertTrue(m.is_first_sync) @@ -129,7 +129,7 @@ def test_reload_wrong_schema_version_creates_empty(self): json.dumps({"schema_version": 999, "tool": "claude"}), encoding="utf-8", ) - m = ToolManifest("claude", path=self.manifest_path) + m = ToolManifest("claude-code", path=self.manifest_path) self.assertEqual(m.managed_skill_names(), []) # -- is_first_sync -------------------------------------------------------- @@ -139,7 +139,7 @@ def test_is_first_sync_false_after_save(self): self.assertTrue(m.is_first_sync) m.save() - m2 = ToolManifest("claude", path=self.manifest_path) + m2 = ToolManifest("claude-code", path=self.manifest_path) self.assertFalse(m2.is_first_sync)