Skip to content

Commit c65d334

Browse files
committed
feat: all tools use dir-level symlink — no per-skill fallback
- SKILL_DIR_EXCLUSIVE defaults to True in BaseApplier: all tools with a SKILL_DIR now get a single symlink → ~/.apc/skills/ on apc sync - Remove explicit SKILL_DIR_EXCLUSIVE=True from claude.py and openclaw.py (now inherited from base) - CursorApplier: inherits True; ~/.cursor/rules/ → ~/.apc/skills/ - CopilotApplier: SKILL_DIR_EXCLUSIVE=False (no dedicated skills dir) - sync_helpers: remove link_skills() fallback entirely from sync path; prune() only called for MCP orphans, not skills - tests: update Cursor assertions from .mdc files to SKILL.md subdirs; update mock appliers to default SKILL_DIR_EXCLUSIVE=True
1 parent 8d8b759 commit c65d334

8 files changed

Lines changed: 36 additions & 41 deletions

File tree

src/appliers/base.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,11 @@ class BaseApplier(ABC):
6666
SKILL_DIR: Optional[Path] = None
6767
TOOL_NAME: str = ""
6868

69-
# Set True when SKILL_DIR is exclusively apc-managed (no user files mixed in).
70-
# apc sync will replace the entire dir with a single symlink → ~/.apc/skills/
71-
# so that any future `apc install` is immediately live without re-running sync.
72-
SKILL_DIR_EXCLUSIVE = False
69+
# When True, apc sync replaces the entire SKILL_DIR with a single symlink
70+
# → ~/.apc/skills/ so any future `apc install` is immediately live.
71+
# All tools default to True. Set False only for tools that cannot use a
72+
# dir-level symlink (e.g. Copilot, which has no dedicated skills dir).
73+
SKILL_DIR_EXCLUSIVE = True
7374

7475
# Subclasses that support LLM-based memory sync should override this
7576
# with a description of how the tool expects its memory files.

src/appliers/claude.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ def SKILL_DIR(self, value):
5050
self._skill_dir_override = value
5151

5252
TOOL_NAME = "claude-code"
53-
SKILL_DIR_EXCLUSIVE = True # ~/.claude/skills/ is entirely apc-managed
5453
MEMORY_SCHEMA = CLAUDE_MEMORY_SCHEMA
5554

5655
@property # type: ignore[override]

src/appliers/copilot.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ def _vscode_mcp_json() -> Path:
8787

8888
class CopilotApplier(BaseApplier):
8989
TOOL_NAME = "github-copilot"
90+
# Copilot has no dedicated skills dir — it uses a single instructions file.
91+
# Dir-level symlink is not applicable.
92+
SKILL_DIR_EXCLUSIVE = False
9093
MEMORY_SCHEMA = COPILOT_MEMORY_SCHEMA
9194

9295
@property # type: ignore[override]

src/appliers/cursor.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ def _cursor_mcp_json() -> Path:
7676

7777
class CursorApplier(BaseApplier):
7878
TOOL_NAME = "cursor"
79+
# ~/.cursor/rules/ is symlinked → ~/.apc/skills/ (SKILL_DIR_EXCLUSIVE=True default).
80+
# Cursor reads skill dirs directly from ~/.cursor/rules/<name>/SKILL.md.
81+
# Note: .mdc per-file format is superseded by this dir-level approach.
7982
MEMORY_SCHEMA = CURSOR_MEMORY_SCHEMA
8083

8184
@property # type: ignore[override]

src/appliers/openclaw.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ def SKILL_DIR(self, value):
6262
self._skill_dir_override = value
6363

6464
TOOL_NAME = "openclaw"
65-
SKILL_DIR_EXCLUSIVE = True # ~/.openclaw/skills/ is entirely apc-managed
6665
MEMORY_SCHEMA = OPENCLAW_MEMORY_SCHEMA
6766

6867
@property # type: ignore[override]

src/sync_helpers.py

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -79,36 +79,26 @@ def sync_skills(tool_list: List[str]) -> Tuple[int, int]:
7979
the tool's mixed dir. Re-run sync after new installs to pick them up.
8080
"""
8181
skills_dir = get_skills_dir()
82-
installed_skills = _discover_installed_skills()
83-
all_skill_names = [s.get("name", "unnamed") for s in installed_skills]
8482

8583
total_dir = 0
86-
total_link = 0
8784

8885
for tool_name in tool_list:
8986
try:
9087
applier = get_applier(tool_name)
9188
manifest = applier.get_manifest()
9289

9390
if applier.sync_skills_dir():
94-
# Dir-level symlink established — entire ~/.apc/skills/ is live
9591
manifest.record_dir_sync(str(applier.SKILL_DIR), str(skills_dir))
92+
manifest.save()
9693
total_dir += 1
9794
success(f"{tool_name}: skills dir symlinked → ~/.apc/skills/")
98-
elif installed_skills:
99-
# Per-skill symlinks for mixed dirs
100-
tool_link = applier.link_skills(installed_skills, skills_dir, manifest)
101-
total_link += tool_link
102-
applier.prune(all_skill_names, [], manifest)
103-
manifest.save()
104-
success(f"{tool_name}: {tool_link} skill(s) linked")
10595
else:
106-
success(f"{tool_name}: no skills to link")
96+
success(f"{tool_name}: no skills dir to sync (SKILL_DIR_EXCLUSIVE=False)")
10797

10898
except Exception as e:
10999
error(f"Failed to sync skills to {tool_name}: {e}")
110100

111-
return total_dir, total_link
101+
return total_dir, 0
112102

113103

114104
def sync_mcp(tool_list: List[str], override: bool = False) -> int:
@@ -187,8 +177,6 @@ def sync_all(tool_list: List[str], no_memory: bool = False, override_mcp: bool =
187177
memory_entries = bundle["memory"] if not no_memory else []
188178

189179
skills_dir = get_skills_dir()
190-
installed_skills = _discover_installed_skills()
191-
all_skill_names = [s.get("name", "unnamed") for s in installed_skills]
192180
current_mcp_names = [s.get("name", "unnamed") for s in mcp_servers]
193181

194182
total_skills = 0
@@ -201,13 +189,10 @@ def sync_all(tool_list: List[str], no_memory: bool = False, override_mcp: bool =
201189
applier = get_applier(tool_name)
202190
manifest = applier.get_manifest()
203191

204-
# Establish skill link (dir-level or per-skill depending on tool)
192+
# Establish dir-level symlink: SKILL_DIR → ~/.apc/skills/
205193
if applier.sync_skills_dir():
206194
manifest.record_dir_sync(str(applier.SKILL_DIR), str(skills_dir))
207-
s, lk = 1, 0 # dir symlink counts as 1
208-
else:
209-
s = 0
210-
lk = applier.link_skills(installed_skills, skills_dir, manifest)
195+
s, lk = (1, 0) if applier.SKILL_DIR_EXCLUSIVE and applier.SKILL_DIR else (0, 0)
211196

212197
# MCP servers
213198
secrets = _resolve_all_mcp_secrets(mcp_servers)
@@ -218,8 +203,8 @@ def sync_all(tool_list: List[str], no_memory: bool = False, override_mcp: bool =
218203
if memory_entries:
219204
mem = applier.apply_memory_via_llm(memory_entries, manifest)
220205

221-
# Prune orphans
222-
applier.prune(all_skill_names, current_mcp_names, manifest)
206+
# Prune MCP orphans (skills are managed via dir symlink — no pruning needed)
207+
applier.prune([], current_mcp_names, manifest)
223208
manifest.save()
224209

225210
total_skills += s + lk

tests/test_docker_integration.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -725,8 +725,8 @@ def test_sync_to_cursor_exits_zero(self, runner, cli):
725725
assert "mcpServers" in data
726726
assert len(data["mcpServers"]) > 0
727727
rules_dir = HOME / ".cursor" / "rules"
728-
assert rules_dir.is_dir()
729-
assert len(list(rules_dir.glob("*.mdc"))) > 0, "No .mdc skill files written to cursor"
728+
# rules_dir is now a symlink → ~/.apc/skills/ (dir-level sync)
729+
assert rules_dir.is_symlink() or rules_dir.is_dir(), f"rules dir missing: {rules_dir}"
730730

731731
def test_sync_writes_cursor_mcp(self, runner, cli):
732732
runner.invoke(cli, ["sync", "--tools", "cursor", "--yes", "--no-memory", "--override-mcp"])
@@ -1040,8 +1040,12 @@ def test_install_then_sync_symlinks_skill_to_tool(self, runner, cli, tmp_path, m
10401040
r2 = runner.invoke(cli, ["sync", "--tools", "cursor", "--yes"])
10411041
assert r2.exit_code == 0, r2.output
10421042

1043-
cursor_skill = tmp_path / ".cursor" / "rules" / f"{self.KNOWN_SKILL}.mdc"
1044-
assert cursor_skill.exists(), f"Skill not found at {cursor_skill} after sync"
1043+
# rules dir is a symlink → ~/.apc/skills/; skill appears as a subdir
1044+
rules_dir = tmp_path / ".cursor" / "rules"
1045+
assert rules_dir.is_symlink(), f"rules dir should be a symlink after sync: {rules_dir}"
1046+
skill_dir = rules_dir / self.KNOWN_SKILL
1047+
assert skill_dir.is_dir(), f"Skill dir {skill_dir} not found after sync"
1048+
assert (skill_dir / "SKILL.md").exists(), f"SKILL.md missing in {skill_dir}"
10451049

10461050
def test_installed_skill_appears_in_skill_list(self, runner, cli, tmp_path, monkeypatch):
10471051
"""Installed skill appears in apc skill list immediately after install."""
@@ -1085,10 +1089,12 @@ def test_install_multiple_then_sync_all_land_in_tool(self, runner, cli, tmp_path
10851089
r_sync = runner.invoke(cli, ["sync", "--tools", "cursor", "--yes"])
10861090
assert r_sync.exit_code == 0, r_sync.output
10871091

1092+
# rules dir is a symlink → ~/.apc/skills/; each skill is a subdir with SKILL.md
10881093
rules_dir = tmp_path / ".cursor" / "rules"
1094+
assert rules_dir.is_symlink(), f"rules dir should be a symlink after sync: {rules_dir}"
10891095
for name in skills:
1090-
assert (rules_dir / f"{name}.mdc").exists(), (
1091-
f"Skill {name} missing from cursor after sync"
1096+
assert (rules_dir / name / "SKILL.md").exists(), (
1097+
f"Skill {name}/SKILL.md missing from cursor after sync"
10921098
)
10931099

10941100
def test_install_all_then_sync_dry_run(self, runner, cli, tmp_path, monkeypatch):

tests/test_sync_helpers.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ def _mock_applier(tmpdir: Path, tool: str = "cursor"):
1717
"""Return a MagicMock that satisfies the applier interface."""
1818
applier = MagicMock()
1919
applier.get_manifest.return_value = _make_manifest(tmpdir, tool)
20-
applier.SKILL_DIR_EXCLUSIVE = False # default: per-skill symlinks
20+
applier.SKILL_DIR_EXCLUSIVE = True # all tools use dir-level symlink by default
2121
applier.SKILL_DIR = tmpdir / "skills"
22-
applier.sync_skills_dir.return_value = False # per-skill tool by default
22+
applier.sync_skills_dir.return_value = True # dir-level symlink succeeds
2323
applier.apply_skills.return_value = 3
2424
applier.link_skills.return_value = 1
2525
applier.apply_mcp_servers.return_value = 2
@@ -114,7 +114,7 @@ def factory(tmpdir, name):
114114
self._run_sync_all(["cursor"], factory)
115115

116116
appliers["cursor"].sync_skills_dir.assert_called_once()
117-
appliers["cursor"].link_skills.assert_called_once()
117+
appliers["cursor"].link_skills.assert_not_called()
118118
appliers["cursor"].apply_skills.assert_not_called()
119119
appliers["cursor"].apply_mcp_servers.assert_called_once()
120120
appliers["cursor"].apply_memory_via_llm.assert_called_once()
@@ -175,8 +175,7 @@ def test_per_tool_count_not_cumulative(self):
175175

176176
def factory(tmpdir_inner, name):
177177
a = _mock_applier(tmpdir_inner, name)
178-
a.sync_skills_dir.return_value = False # per-skill tool
179-
a.link_skills.return_value = 3
178+
a.sync_skills_dir.return_value = True # dir-level sync
180179
return a
181180

182181
with (
@@ -193,9 +192,9 @@ def factory(tmpdir_inner, name):
193192

194193
sync_skills(["cursor", "claude-code"])
195194

196-
# Each message should say 3 skill(s) linked, not cumulative 6
195+
# Each message should say symlinked, not cumulative counts
197196
for msg in success_messages:
198-
self.assertIn("3 skill(s) linked", msg, f"Expected '3 skill(s) linked' in: {msg}")
197+
self.assertIn("symlinked", msg, f"Expected 'symlinked' in: {msg}")
199198

200199

201200
if __name__ == "__main__":

0 commit comments

Comments
 (0)