Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
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
  • Loading branch information
forge-fz2000 committed Mar 8, 2026
commit c65d33453a179df004fcaed4cbfe5665586f1a73
9 changes: 5 additions & 4 deletions src/appliers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,11 @@ class BaseApplier(ABC):
SKILL_DIR: Optional[Path] = None
TOOL_NAME: str = ""

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

# Subclasses that support LLM-based memory sync should override this
# with a description of how the tool expects its memory files.
Expand Down
1 change: 0 additions & 1 deletion src/appliers/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ def SKILL_DIR(self, value):
self._skill_dir_override = value

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

@property # type: ignore[override]
Expand Down
3 changes: 3 additions & 0 deletions src/appliers/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ def _vscode_mcp_json() -> Path:

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

@property # type: ignore[override]
Expand Down
3 changes: 3 additions & 0 deletions src/appliers/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ def _cursor_mcp_json() -> Path:

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

@property # type: ignore[override]
Expand Down
1 change: 0 additions & 1 deletion src/appliers/openclaw.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ def SKILL_DIR(self, value):
self._skill_dir_override = value

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

@property # type: ignore[override]
Expand Down
29 changes: 7 additions & 22 deletions src/sync_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,36 +79,26 @@ def sync_skills(tool_list: List[str]) -> Tuple[int, int]:
the tool's mixed dir. Re-run sync after new installs to pick them up.
"""
skills_dir = get_skills_dir()
installed_skills = _discover_installed_skills()
all_skill_names = [s.get("name", "unnamed") for s in installed_skills]

total_dir = 0
total_link = 0

for tool_name in tool_list:
try:
applier = get_applier(tool_name)
manifest = applier.get_manifest()

if applier.sync_skills_dir():
# Dir-level symlink established — entire ~/.apc/skills/ is live
manifest.record_dir_sync(str(applier.SKILL_DIR), str(skills_dir))
manifest.save()
total_dir += 1
success(f"{tool_name}: skills dir symlinked → ~/.apc/skills/")
elif installed_skills:
# Per-skill symlinks for mixed dirs
tool_link = applier.link_skills(installed_skills, skills_dir, manifest)
total_link += tool_link
applier.prune(all_skill_names, [], manifest)
manifest.save()
success(f"{tool_name}: {tool_link} skill(s) linked")
else:
success(f"{tool_name}: no skills to link")
success(f"{tool_name}: no skills dir to sync (SKILL_DIR_EXCLUSIVE=False)")

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

return total_dir, total_link
return total_dir, 0


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

skills_dir = get_skills_dir()
installed_skills = _discover_installed_skills()
all_skill_names = [s.get("name", "unnamed") for s in installed_skills]
current_mcp_names = [s.get("name", "unnamed") for s in mcp_servers]

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

# Establish skill link (dir-level or per-skill depending on tool)
# Establish dir-level symlink: SKILL_DIR → ~/.apc/skills/
if applier.sync_skills_dir():
manifest.record_dir_sync(str(applier.SKILL_DIR), str(skills_dir))
s, lk = 1, 0 # dir symlink counts as 1
else:
s = 0
lk = applier.link_skills(installed_skills, skills_dir, manifest)
s, lk = (1, 0) if applier.SKILL_DIR_EXCLUSIVE and applier.SKILL_DIR else (0, 0)

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

# Prune orphans
applier.prune(all_skill_names, current_mcp_names, manifest)
# Prune MCP orphans (skills are managed via dir symlink — no pruning needed)
applier.prune([], current_mcp_names, manifest)
manifest.save()

total_skills += s + lk
Expand Down
18 changes: 12 additions & 6 deletions tests/test_docker_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -725,8 +725,8 @@ def test_sync_to_cursor_exits_zero(self, runner, cli):
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"
# rules_dir is now a symlink → ~/.apc/skills/ (dir-level sync)
assert rules_dir.is_symlink() or rules_dir.is_dir(), f"rules dir missing: {rules_dir}"

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

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

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

# rules dir is a symlink → ~/.apc/skills/; each skill is a subdir with SKILL.md
rules_dir = tmp_path / ".cursor" / "rules"
assert rules_dir.is_symlink(), f"rules dir should be a symlink after sync: {rules_dir}"
for name in skills:
assert (rules_dir / f"{name}.mdc").exists(), (
f"Skill {name} missing from cursor after sync"
assert (rules_dir / name / "SKILL.md").exists(), (
f"Skill {name}/SKILL.md missing from cursor after sync"
)

def test_install_all_then_sync_dry_run(self, runner, cli, tmp_path, monkeypatch):
Expand Down
13 changes: 6 additions & 7 deletions tests/test_sync_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ def _mock_applier(tmpdir: Path, tool: str = "cursor"):
"""Return a MagicMock that satisfies the applier interface."""
applier = MagicMock()
applier.get_manifest.return_value = _make_manifest(tmpdir, tool)
applier.SKILL_DIR_EXCLUSIVE = False # default: per-skill symlinks
applier.SKILL_DIR_EXCLUSIVE = True # all tools use dir-level symlink by default
applier.SKILL_DIR = tmpdir / "skills"
applier.sync_skills_dir.return_value = False # per-skill tool by default
applier.sync_skills_dir.return_value = True # dir-level symlink succeeds
applier.apply_skills.return_value = 3
applier.link_skills.return_value = 1
applier.apply_mcp_servers.return_value = 2
Expand Down Expand Up @@ -114,7 +114,7 @@ def factory(tmpdir, name):
self._run_sync_all(["cursor"], factory)

appliers["cursor"].sync_skills_dir.assert_called_once()
appliers["cursor"].link_skills.assert_called_once()
appliers["cursor"].link_skills.assert_not_called()
appliers["cursor"].apply_skills.assert_not_called()
appliers["cursor"].apply_mcp_servers.assert_called_once()
appliers["cursor"].apply_memory_via_llm.assert_called_once()
Expand Down Expand Up @@ -175,8 +175,7 @@ def test_per_tool_count_not_cumulative(self):

def factory(tmpdir_inner, name):
a = _mock_applier(tmpdir_inner, name)
a.sync_skills_dir.return_value = False # per-skill tool
a.link_skills.return_value = 3
a.sync_skills_dir.return_value = True # dir-level sync
return a

with (
Expand All @@ -193,9 +192,9 @@ def factory(tmpdir_inner, name):

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

# Each message should say 3 skill(s) linked, not cumulative 6
# Each message should say symlinked, not cumulative counts
for msg in success_messages:
self.assertIn("3 skill(s) linked", msg, f"Expected '3 skill(s) linked' in: {msg}")
self.assertIn("symlinked", msg, f"Expected 'symlinked' in: {msg}")


if __name__ == "__main__":
Expand Down