diff --git a/src/appliers/claude.py b/src/appliers/claude.py index 40898c7..0f1e345 100644 --- a/src/appliers/claude.py +++ b/src/appliers/claude.py @@ -8,12 +8,6 @@ from appliers.manifest import ToolManifest from frontmatter_parser import render_frontmatter -CLAUDE_DIR = Path.home() / ".claude" -CLAUDE_JSON = Path.home() / ".claude.json" -CLAUDE_COMMANDS_DIR = CLAUDE_DIR / "commands" -CLAUDE_SKILLS_DIR = CLAUDE_DIR / "skills" -CLAUDE_MD = CLAUDE_DIR / "CLAUDE.md" - CLAUDE_MEMORY_SCHEMA = """ Claude Code reads instructions from ~/.claude/CLAUDE.md. This is a plain Markdown file with no special schema. @@ -24,13 +18,40 @@ """ +def _claude_dir() -> Path: + return Path.home() / ".claude" + + +def _claude_json() -> Path: + return Path.home() / ".claude.json" + + +def _claude_commands_dir() -> Path: + return Path.home() / ".claude/commands" + + +def _claude_skills_dir() -> Path: + return Path.home() / ".claude/skills" + + +def _claude_md() -> Path: + return Path.home() / ".claude/CLAUDE.md" + + class ClaudeApplier(BaseApplier): - SKILL_DIR = CLAUDE_SKILLS_DIR + @property + def SKILL_DIR(self): + return getattr(self, "_skill_dir_override", None) or _claude_skills_dir() + + @SKILL_DIR.setter + def SKILL_DIR(self, value): + self._skill_dir_override = value + TOOL_NAME = "claude-code" MEMORY_SCHEMA = CLAUDE_MEMORY_SCHEMA def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int: - CLAUDE_COMMANDS_DIR.mkdir(parents=True, exist_ok=True) + _claude_commands_dir().mkdir(parents=True, exist_ok=True) count = 0 for skill in skills: name = skill.get("name", "unnamed") @@ -43,7 +64,7 @@ def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int: metadata["version"] = skill["version"] content = render_frontmatter(metadata, skill.get("body", "")) - path = CLAUDE_COMMANDS_DIR / f"{name}.md" + path = _claude_commands_dir() / f"{name}.md" path.write_text(content, encoding="utf-8") manifest.record_skill(name, file_path=str(path), content=content) count += 1 @@ -57,9 +78,9 @@ def apply_mcp_servers( override: bool = False, ) -> int: # Read existing claude.json or start fresh - if CLAUDE_JSON.exists(): + if _claude_json().exists(): try: - data = json.loads(CLAUDE_JSON.read_text(encoding="utf-8")) + data = json.loads(_claude_json().read_text(encoding="utf-8")) except json.JSONDecodeError: data = {} else: @@ -100,15 +121,15 @@ def apply_mcp_servers( count += 1 data["mcpServers"] = mcp_servers - CLAUDE_JSON.write_text(json.dumps(data, indent=2), encoding="utf-8") + _claude_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 Claude's memory files.""" result = {} - if CLAUDE_MD.exists(): + if _claude_md().exists(): try: - result[str(CLAUDE_MD)] = CLAUDE_MD.read_text(encoding="utf-8") + result[str(_claude_md())] = _claude_md().read_text(encoding="utf-8") except IOError: pass return result diff --git a/src/appliers/gemini.py b/src/appliers/gemini.py index 8b6531c..53eeaa1 100644 --- a/src/appliers/gemini.py +++ b/src/appliers/gemini.py @@ -7,10 +7,6 @@ from appliers.base import BaseApplier from appliers.manifest import ToolManifest -GEMINI_DIR = Path.home() / ".gemini" -GEMINI_SETTINGS = GEMINI_DIR / "settings.json" -GEMINI_MD = GEMINI_DIR / "GEMINI.md" - GEMINI_MEMORY_SCHEMA = """ Gemini CLI reads instructions from ~/.gemini/GEMINI.md (global) and ./GEMINI.md (per-project). This applier targets the GLOBAL file at ~/.gemini/GEMINI.md. @@ -63,6 +59,18 @@ """ +def _gemini_dir() -> Path: + return Path.home() / ".gemini" + + +def _gemini_settings() -> Path: + return Path.home() / ".gemini/settings.json" + + +def _gemini_md() -> Path: + return Path.home() / ".gemini/GEMINI.md" + + class GeminiApplier(BaseApplier): TOOL_NAME = "gemini-cli" MEMORY_SCHEMA = GEMINI_MEMORY_SCHEMA @@ -77,9 +85,9 @@ def apply_mcp_servers( manifest: ToolManifest, override: bool = False, ) -> int: - if GEMINI_SETTINGS.exists(): + if _gemini_settings().exists(): try: - data = json.loads(GEMINI_SETTINGS.read_text(encoding="utf-8")) + data = json.loads(_gemini_settings().read_text(encoding="utf-8")) except json.JSONDecodeError: data = {} else: @@ -119,16 +127,16 @@ def apply_mcp_servers( count += 1 data["mcpServers"] = mcp_servers - GEMINI_DIR.mkdir(parents=True, exist_ok=True) - GEMINI_SETTINGS.write_text(json.dumps(data, indent=2), encoding="utf-8") + _gemini_dir().mkdir(parents=True, exist_ok=True) + _gemini_settings().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 Gemini's memory files.""" result = {} - if GEMINI_MD.exists(): + if _gemini_md().exists(): try: - result[str(GEMINI_MD)] = GEMINI_MD.read_text(encoding="utf-8") + result[str(_gemini_md())] = _gemini_md().read_text(encoding="utf-8") except IOError: pass return result diff --git a/src/appliers/openclaw.py b/src/appliers/openclaw.py index a252c4b..5bf19ac 100644 --- a/src/appliers/openclaw.py +++ b/src/appliers/openclaw.py @@ -7,15 +7,6 @@ from appliers.manifest import ToolManifest from frontmatter_parser import render_frontmatter -OPENCLAW_DIR = Path.home() / ".openclaw" -OPENCLAW_SKILLS_DIR = OPENCLAW_DIR / "skills" -OPENCLAW_WORKSPACE = OPENCLAW_DIR / "workspace" -OPENCLAW_USER_MD = OPENCLAW_WORKSPACE / "USER.md" -OPENCLAW_MEMORY_MD = OPENCLAW_WORKSPACE / "MEMORY.md" -OPENCLAW_IDENTITY_MD = OPENCLAW_WORKSPACE / "IDENTITY.md" -OPENCLAW_SOUL_MD = OPENCLAW_WORKSPACE / "SOUL.md" -OPENCLAW_TOOLS_MD = OPENCLAW_WORKSPACE / "TOOLS.md" - OPENCLAW_MEMORY_SCHEMA = """ OpenClaw uses these files in ~/.openclaw/workspace/: 1. USER.md — Personal context about the user (name, timezone, pronouns, preferences). @@ -29,13 +20,52 @@ """ +def _openclaw_dir() -> Path: + return Path.home() / ".openclaw" + + +def _openclaw_skills_dir() -> Path: + return Path.home() / ".openclaw/skills" + + +def _openclaw_workspace() -> Path: + return Path.home() / ".openclaw/workspace" + + +def _openclaw_user_md() -> Path: + return Path.home() / ".openclaw/workspace/USER.md" + + +def _openclaw_memory_md() -> Path: + return Path.home() / ".openclaw/workspace/MEMORY.md" + + +def _openclaw_identity_md() -> Path: + return Path.home() / ".openclaw/workspace/IDENTITY.md" + + +def _openclaw_soul_md() -> Path: + return Path.home() / ".openclaw/workspace/SOUL.md" + + +def _openclaw_tools_md() -> Path: + return Path.home() / ".openclaw/workspace/TOOLS.md" + + class OpenClawApplier(BaseApplier): - SKILL_DIR = OPENCLAW_SKILLS_DIR + @property + def SKILL_DIR(self): + return getattr(self, "_skill_dir_override", None) or _openclaw_skills_dir() + + @SKILL_DIR.setter + def SKILL_DIR(self, value): + self._skill_dir_override = value + TOOL_NAME = "openclaw" MEMORY_SCHEMA = OPENCLAW_MEMORY_SCHEMA def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int: - OPENCLAW_SKILLS_DIR.mkdir(parents=True, exist_ok=True) + _openclaw_skills_dir().mkdir(parents=True, exist_ok=True) count = 0 for skill in skills: name = skill.get("name", "unnamed") @@ -50,7 +80,7 @@ def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int: content = render_frontmatter(metadata, skill.get("body", "")) # OpenClaw uses directory-based skills: /SKILL.md - skill_dir = OPENCLAW_SKILLS_DIR / name + skill_dir = _openclaw_skills_dir() / name skill_dir.mkdir(parents=True, exist_ok=True) path = skill_dir / "SKILL.md" path.write_text(content, encoding="utf-8") @@ -72,11 +102,11 @@ def _read_existing_memory_files(self) -> Dict[str, str]: """Return {file_path: content} for OpenClaw's memory files.""" result = {} for path in [ - OPENCLAW_USER_MD, - OPENCLAW_MEMORY_MD, - OPENCLAW_IDENTITY_MD, - OPENCLAW_SOUL_MD, - OPENCLAW_TOOLS_MD, + _openclaw_user_md(), + _openclaw_memory_md(), + _openclaw_identity_md(), + _openclaw_soul_md(), + _openclaw_tools_md(), ]: if path.exists(): try: diff --git a/src/appliers/windsurf.py b/src/appliers/windsurf.py index a25fdbe..75c7caa 100644 --- a/src/appliers/windsurf.py +++ b/src/appliers/windsurf.py @@ -7,10 +7,6 @@ from appliers.base import BaseApplier from appliers.manifest import ToolManifest -WINDSURF_DIR = Path.home() / ".codeium" / "windsurf" -WINDSURF_MCP_CONFIG = WINDSURF_DIR / "mcp_config.json" -WINDSURF_MEMORIES_DIR = WINDSURF_DIR / "memories" -WINDSURF_GLOBAL_RULES = WINDSURF_MEMORIES_DIR / "global_rules.md" WINDSURF_RULES_DIR = Path(".windsurf") / "rules" WINDSURF_MEMORY_SCHEMA = """ @@ -75,6 +71,22 @@ """ +def _windsurf_dir() -> Path: + return Path.home() / ".codeium/windsurf" + + +def _windsurf_mcp_config() -> Path: + return Path.home() / ".codeium/windsurf/mcp_config.json" + + +def _windsurf_memories_dir() -> Path: + return Path.home() / ".codeium/windsurf/memories" + + +def _windsurf_global_rules() -> Path: + return Path.home() / ".codeium/windsurf/memories/global_rules.md" + + class WindsurfApplier(BaseApplier): TOOL_NAME = "windsurf" MEMORY_SCHEMA = WINDSURF_MEMORY_SCHEMA @@ -89,9 +101,9 @@ def apply_mcp_servers( manifest: ToolManifest, override: bool = False, ) -> int: - if WINDSURF_MCP_CONFIG.exists(): + if _windsurf_mcp_config().exists(): try: - data = json.loads(WINDSURF_MCP_CONFIG.read_text(encoding="utf-8")) + data = json.loads(_windsurf_mcp_config().read_text(encoding="utf-8")) except json.JSONDecodeError: data = {} else: @@ -131,17 +143,17 @@ def apply_mcp_servers( count += 1 data["mcpServers"] = mcp_servers - WINDSURF_DIR.mkdir(parents=True, exist_ok=True) - WINDSURF_MCP_CONFIG.write_text(json.dumps(data, indent=2), encoding="utf-8") + _windsurf_dir().mkdir(parents=True, exist_ok=True) + _windsurf_mcp_config().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 Windsurf's rule/memory files.""" result = {} # Global rules - if WINDSURF_GLOBAL_RULES.exists(): + if _windsurf_global_rules().exists(): try: - result[str(WINDSURF_GLOBAL_RULES)] = WINDSURF_GLOBAL_RULES.read_text( + result[str(_windsurf_global_rules())] = _windsurf_global_rules().read_text( encoding="utf-8" ) except IOError: diff --git a/src/ui.py b/src/ui.py index d61572e..00b93c0 100644 --- a/src/ui.py +++ b/src/ui.py @@ -123,6 +123,8 @@ def tools_status_table(tools: List[Dict[str, str]]) -> None: st = tool.get("status", "detected") if st == "synced": badge = Text("● synced", style="bold green") + elif st == "out of sync": + badge = Text("⚠ out of sync", style="bold yellow") elif st == "not synced": badge = Text("○ not synced", style="yellow") else: diff --git a/tests/test_appliers.py b/tests/test_appliers.py index 9eda72b..59f37d8 100644 --- a/tests/test_appliers.py +++ b/tests/test_appliers.py @@ -38,7 +38,7 @@ def test_apply_skills(self): ] manifest = self._manifest() - with patch("appliers.claude.CLAUDE_COMMANDS_DIR", self.commands_dir): + with patch("appliers.claude._claude_commands_dir", return_value=self.commands_dir): from appliers.claude import ClaudeApplier applier = ClaudeApplier() @@ -66,7 +66,7 @@ def test_apply_mcp_servers(self): secrets = {"TOKEN": "actual_value"} manifest = self._manifest() - with patch("appliers.claude.CLAUDE_JSON", self.claude_json): + with patch("appliers.claude._claude_json", return_value=self.claude_json): from appliers.claude import ClaudeApplier applier = ClaudeApplier() @@ -96,7 +96,7 @@ def test_apply_mcp_servers_merges_existing(self): ] manifest = self._manifest() - with patch("appliers.claude.CLAUDE_JSON", self.claude_json): + with patch("appliers.claude._claude_json", return_value=self.claude_json): from appliers.claude import ClaudeApplier applier = ClaudeApplier() @@ -130,8 +130,8 @@ def test_apply_memory_via_llm(self): ) with ( - patch("appliers.claude.CLAUDE_MD", self.claude_md), - patch("appliers.claude.CLAUDE_DIR", self.claude_dir), + patch("appliers.claude._claude_md", return_value=self.claude_md), + patch("appliers.claude._claude_dir", return_value=self.claude_dir), patch("llm_client.call_llm", return_value=llm_response), ): from appliers.claude import ClaudeApplier @@ -151,7 +151,7 @@ def test_apply_memory_via_llm_returns_zero_on_failure(self): manifest = self._manifest() with ( - patch("appliers.claude.CLAUDE_MD", self.claude_md), + patch("appliers.claude._claude_md", return_value=self.claude_md), patch("llm_client.call_llm", side_effect=Exception("No LLM")), ): from appliers.claude import ClaudeApplier @@ -180,8 +180,8 @@ def test_apply_memory_via_llm_handles_markdown_fencing(self): ) with ( - patch("appliers.claude.CLAUDE_MD", self.claude_md), - patch("appliers.claude.CLAUDE_DIR", self.claude_dir), + patch("appliers.claude._claude_md", return_value=self.claude_md), + patch("appliers.claude._claude_dir", return_value=self.claude_dir), patch("llm_client.call_llm", return_value=llm_response), ): from appliers.claude import ClaudeApplier @@ -240,7 +240,7 @@ def test_apply_mcp_prunes_orphaned_server(self): }, ] - with patch("appliers.claude.CLAUDE_JSON", self.claude_json): + with patch("appliers.claude._claude_json", return_value=self.claude_json): from appliers.claude import ClaudeApplier applier = ClaudeApplier() @@ -260,7 +260,7 @@ def test_apply_mcp_prunes_orphaned_server(self): }, ] - with patch("appliers.claude.CLAUDE_JSON", self.claude_json): + with patch("appliers.claude._claude_json", return_value=self.claude_json): applier = ClaudeApplier() applier.apply_mcp_servers(servers_v2, {}, manifest2) @@ -278,7 +278,7 @@ def test_prune_removes_orphaned_skill(self): manifest = self._manifest() manifest.record_skill("old-skill", file_path=str(skill_file), content=skill_content) - with patch("appliers.claude.CLAUDE_COMMANDS_DIR", self.commands_dir): + with patch("appliers.claude._claude_commands_dir", return_value=self.commands_dir): from appliers.claude import ClaudeApplier applier = ClaudeApplier() @@ -300,7 +300,7 @@ def test_prune_skips_modified_skill(self): # User edits the file skill_file.write_text("# User modified this!", encoding="utf-8") - with patch("appliers.claude.CLAUDE_COMMANDS_DIR", self.commands_dir): + with patch("appliers.claude._claude_commands_dir", return_value=self.commands_dir): from appliers.claude import ClaudeApplier applier = ClaudeApplier() @@ -377,7 +377,7 @@ def test_claude_reads_existing_memory(self): claude_md = Path(self.tmpdir) / "CLAUDE.md" claude_md.write_text("# My context\n- test", encoding="utf-8") - with patch("appliers.claude.CLAUDE_MD", claude_md): + with patch("appliers.claude._claude_md", return_value=claude_md): from appliers.claude import ClaudeApplier applier = ClaudeApplier() @@ -387,7 +387,7 @@ def test_claude_reads_existing_memory(self): self.assertIn("My context", result[str(claude_md)]) def test_claude_no_existing_files(self): - with patch("appliers.claude.CLAUDE_MD", Path(self.tmpdir) / "nonexistent.md"): + with patch("appliers.claude._claude_md", return_value=Path(self.tmpdir) / "nonexistent.md"): from appliers.claude import ClaudeApplier applier = ClaudeApplier() @@ -408,11 +408,11 @@ def test_openclaw_reads_existing_memory(self): tools_md.write_text("# Tools", encoding="utf-8") with ( - patch("appliers.openclaw.OPENCLAW_USER_MD", user_md), - patch("appliers.openclaw.OPENCLAW_MEMORY_MD", memory_md), - patch("appliers.openclaw.OPENCLAW_IDENTITY_MD", identity_md), - patch("appliers.openclaw.OPENCLAW_SOUL_MD", soul_md), - patch("appliers.openclaw.OPENCLAW_TOOLS_MD", tools_md), + patch("appliers.openclaw._openclaw_user_md", return_value=user_md), + patch("appliers.openclaw._openclaw_memory_md", return_value=memory_md), + patch("appliers.openclaw._openclaw_identity_md", return_value=identity_md), + patch("appliers.openclaw._openclaw_soul_md", return_value=soul_md), + patch("appliers.openclaw._openclaw_tools_md", return_value=tools_md), ): from appliers.openclaw import OpenClawApplier diff --git a/tests/test_docker_integration.py b/tests/test_docker_integration.py index ccb15b5..65397be 100644 --- a/tests/test_docker_integration.py +++ b/tests/test_docker_integration.py @@ -213,6 +213,246 @@ def test_detects_openclaw(self, runner, cli): assert "openclaw" in result.output.lower() assert (HOME / ".openclaw").is_dir() + # ------------------------------------------------------------------ + # Parametrized sync-status tests — cover every supported tool so + # TOOL_NAME mismatches are caught (TOOL_NAME must equal the detected name). + # ------------------------------------------------------------------ + + # (detected_name, seed_dirs, applier_tool_name, mcp_file, mcp_key, supports_skills) + # seed_dirs: list of relative paths to mkdir under tmp_path + # mcp_file: relative path to the MCP JSON file for this tool (or None) + # mcp_key: top-level JSON key for MCP servers in that file + # supports_skills: whether apply_skills() writes files (vs returning 0) + _TOOL_PARAMS = [ + pytest.param( + "cursor", + [".cursor"], + "cursor", + ".cursor/mcp.json", + "mcpServers", + True, + id="cursor", + ), + pytest.param( + "claude-code", + [".claude"], # .claude.json is a file, not a dir — created by apply_mcp_servers + "claude-code", + ".claude.json", + "mcpServers", + True, + id="claude-code", + ), + pytest.param( + "gemini-cli", + [".gemini"], + "gemini-cli", + ".gemini/settings.json", + "mcpServers", + False, + id="gemini-cli", + ), + pytest.param( + "windsurf", + [".codeium/windsurf"], + "windsurf", + ".codeium/windsurf/mcp_config.json", + "mcpServers", + False, + id="windsurf", + ), + pytest.param( + "openclaw", + [".openclaw/skills"], + "openclaw", + None, + None, + True, + id="openclaw", + ), + ] + + @pytest.mark.parametrize( + "detected_name,seed_dirs,applier_tool_name,mcp_file,mcp_key,supports_skills", + _TOOL_PARAMS, + ) + def test_not_synced_before_any_sync( + self, + runner, + cli, + tmp_path, + monkeypatch, + detected_name, + seed_dirs, + applier_tool_name, + mcp_file, + mcp_key, + supports_skills, + ): + """Every tool shows 'not synced' when no manifest exists yet.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + for d in seed_dirs: + (tmp_path / d).mkdir(parents=True, exist_ok=True) + + result = runner.invoke(cli, ["status"]) + assert result.exit_code == 0, result.output + assert "not synced" in result.output.lower(), ( + f"{detected_name} should show 'not synced' before any sync, got:\n{result.output}" + ) + # Manifest must not exist yet + manifest_path = tmp_path / ".apc" / "manifests" / f"{applier_tool_name}.json" + assert not manifest_path.exists(), f"manifest should not exist before sync: {manifest_path}" + + @pytest.mark.parametrize( + "detected_name,seed_dirs,applier_tool_name,mcp_file,mcp_key,supports_skills", + _TOOL_PARAMS, + ) + def test_synced_after_sync( + self, + runner, + cli, + tmp_path, + monkeypatch, + detected_name, + seed_dirs, + applier_tool_name, + mcp_file, + mcp_key, + supports_skills, + ): + """Every tool shows 'synced' in status after apc sync runs against it. + + All tools: seed cache directly (avoids blanket collect touching real home + dirs) then run sync — appliers are lazy so writes go to tmp_path. + + Skill-supporting tools (cursor, claude-code, openclaw) get one skill in + the cache so the manifest records a file_path the consistency check can use. + MCP-only tools (gemini-cli, windsurf) get one MCP server entry. + """ + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + for d in seed_dirs: + (tmp_path / d).mkdir(parents=True, exist_ok=True) + + # Seed an isolated cache — never call collect (would touch real home tool dirs) + cache_dir = tmp_path / ".apc" / "cache" + cache_dir.mkdir(parents=True, exist_ok=True) + + if supports_skills: + (cache_dir / "skills.json").write_text( + json.dumps( + [ + { + "name": "test-skill", + "description": "A test skill", + "body": "Test skill body.", + "tags": ["test"], + "version": "1.0.0", + } + ] + ), + encoding="utf-8", + ) + else: + (cache_dir / "skills.json").write_text("[]", encoding="utf-8") + + (cache_dir / "mcp_servers.json").write_text( + json.dumps( + [ + { + "name": "test-server", + "command": "echo", + "args": [], + "transport": "stdio", + "source_tool": detected_name, + "targets": [], + "env": {}, + } + ] + ), + encoding="utf-8", + ) + (cache_dir / "memory.json").write_text("[]", encoding="utf-8") + runner.invoke(cli, ["sync", "--tools", detected_name, "--yes", "--no-memory"]) + + result = runner.invoke(cli, ["status"]) + assert result.exit_code == 0, result.output + assert "synced" in result.output.lower(), ( + f"{detected_name} should show 'synced' after sync, got:\n{result.output}" + ) + # Manifest must exist with last_sync_at set (keyed by applier TOOL_NAME) + manifest_path = tmp_path / ".apc" / "manifests" / f"{applier_tool_name}.json" + assert manifest_path.exists(), ( + f"manifest missing at {manifest_path} — " + f"TOOL_NAME mismatch? detected={detected_name!r} applier={applier_tool_name!r}" + ) + data = json.loads(manifest_path.read_text()) + assert data.get("last_sync_at") is not None, "last_sync_at not set in manifest" + + @pytest.mark.parametrize( + "detected_name,seed_dirs,applier_tool_name,mcp_file,mcp_key,supports_skills", + [p for p in _TOOL_PARAMS if p.values[5]], # only tools that write skill files + ) + def test_out_of_sync_when_skill_deleted( + self, + runner, + cli, + tmp_path, + monkeypatch, + detected_name, + seed_dirs, + applier_tool_name, + mcp_file, + mcp_key, + supports_skills, + ): + """Deleting a synced skill file causes 'out of sync' for that tool.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + for d in seed_dirs: + (tmp_path / d).mkdir(parents=True, exist_ok=True) + + # Seed isolated cache then sync — appliers are lazy so all writes go to tmp_path + cache_dir = tmp_path / ".apc" / "cache" + cache_dir.mkdir(parents=True, exist_ok=True) + (cache_dir / "skills.json").write_text( + json.dumps( + [ + { + "name": "test-skill", + "description": "A test skill", + "body": "Test skill body.", + "tags": ["test"], + "version": "1.0.0", + } + ] + ), + encoding="utf-8", + ) + (cache_dir / "mcp_servers.json").write_text("[]", encoding="utf-8") + (cache_dir / "memory.json").write_text("[]", encoding="utf-8") + runner.invoke(cli, ["sync", "--tools", detected_name, "--yes", "--no-memory"]) + + # Confirm synced first + r1 = runner.invoke(cli, ["status"]) + assert "synced" in r1.output.lower(), ( + f"{detected_name}: expected synced before delete, got:\n{r1.output}" + ) + + # Find and delete a skill file recorded by the manifest + manifest_path = tmp_path / ".apc" / "manifests" / f"{applier_tool_name}.json" + manifest_data = json.loads(manifest_path.read_text()) + skill_paths = [ + v["file_path"] for v in manifest_data.get("skills", {}).values() if "file_path" in v + ] + assert skill_paths, f"no skill file_paths in manifest for {detected_name}" + Path(skill_paths[0]).unlink() + + r2 = runner.invoke(cli, ["status"]) + assert "out of sync" in r2.output.lower(), ( + f"{detected_name}: expected 'out of sync' after delete, got:\n{r2.output}" + ) + # --------------------------------------------------------------------------- # Phase 3: apc collect diff --git a/tests/test_e2e.py b/tests/test_e2e.py index c82380f..5755118 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -43,8 +43,8 @@ def test_llm_memory_sync_writes_claude_md(self): ) with ( - patch("appliers.claude.CLAUDE_MD", claude_md), - patch("appliers.claude.CLAUDE_DIR", claude_dir), + patch("appliers.claude._claude_md", return_value=claude_md), + patch("appliers.claude._claude_dir", return_value=claude_dir), patch("appliers.base.call_llm", return_value=llm_response, create=True), patch("llm_client.call_llm", return_value=llm_response), ): diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 29f6877..2e7e341 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -3,6 +3,7 @@ import json import tempfile import unittest +import unittest.mock from pathlib import Path from appliers.manifest import ToolManifest, _sha256 @@ -145,3 +146,108 @@ def test_is_first_sync_false_after_save(self): if __name__ == "__main__": unittest.main() + + +class TestToolSyncStatus(unittest.TestCase): + """Unit tests for status._tool_sync_status — file-system consistency check.""" + + def setUp(self): + self.tmpdir = Path(tempfile.mkdtemp()) + self.manifest_path = self.tmpdir / "cursor.json" + + def _make_manifest(self) -> ToolManifest: + return ToolManifest("cursor", path=self.manifest_path) + + def test_not_synced_when_no_manifest(self): + from status import _tool_sync_status + + with unittest.mock.patch( + "status.ToolManifest", + side_effect=lambda name: ToolManifest(name, path=self.manifest_path), + ): + assert _tool_sync_status("cursor") == "not synced" + + def test_synced_when_all_files_present(self): + from status import _tool_sync_status + + skill_file = self.tmpdir / "pdf.mdc" + skill_file.write_text("# pdf skill") + + m = self._make_manifest() + m.record_skill("pdf", file_path=str(skill_file), content="# pdf skill") + m.save() + + with unittest.mock.patch( + "status.ToolManifest", + side_effect=lambda name: ToolManifest(name, path=self.manifest_path), + ): + assert _tool_sync_status("cursor") == "synced" + + def test_out_of_sync_when_file_deleted(self): + from status import _tool_sync_status + + skill_file = self.tmpdir / "pdf.mdc" + skill_file.write_text("# pdf skill") + + m = self._make_manifest() + m.record_skill("pdf", file_path=str(skill_file), content="# pdf skill") + m.save() + + skill_file.unlink() # simulate deletion + + with unittest.mock.patch( + "status.ToolManifest", + side_effect=lambda name: ToolManifest(name, path=self.manifest_path), + ): + assert _tool_sync_status("cursor") == "out of sync" + + def test_synced_when_only_mcp_synced(self): + """If only MCP servers were synced (no skill files recorded), trust the timestamp.""" + from status import _tool_sync_status + + m = self._make_manifest() + m.record_mcp_server("filesystem") + m.save() + + with unittest.mock.patch( + "status.ToolManifest", + side_effect=lambda name: ToolManifest(name, path=self.manifest_path), + ): + assert _tool_sync_status("cursor") == "synced" + + def test_out_of_sync_when_linked_skill_missing(self): + """Linked skill symlink removed → out of sync.""" + from status import _tool_sync_status + + link_path = self.tmpdir / "pdf.mdc" + # Don't create the symlink — it's missing + + m = self._make_manifest() + m.record_linked_skill("pdf", link_path=str(link_path), target="/source/SKILL.md") + m.save() + + with unittest.mock.patch( + "status.ToolManifest", + side_effect=lambda name: ToolManifest(name, path=self.manifest_path), + ): + assert _tool_sync_status("cursor") == "out of sync" + + def test_partial_files_missing_is_out_of_sync(self): + """One skill file present, one missing → out of sync.""" + from status import _tool_sync_status + + present = self.tmpdir / "skill-a.mdc" + present.write_text("# skill a") + missing = self.tmpdir / "skill-b.mdc" + # skill-b not created + + m = self._make_manifest() + m.record_skill("skill-a", file_path=str(present), content="# skill a") + m.record_skill("skill-b", file_path=str(missing), content="# skill b") + m.save() + + with unittest.mock.patch( + "status.ToolManifest", + side_effect=lambda name: ToolManifest(name, path=self.manifest_path), + ): + assert _tool_sync_status("cursor") == "out of sync"