Skip to content

Commit 16135a6

Browse files
authored
feat(#69,#71): ~/.apc/skills/ source of truth + Windsurf/Copilot/Gemini native sync + apc skill remove + apc unsync (#74)
* feat(#69): ~/.apc/skills/ as single source of truth — dir-level symlinks, no inline skills - apc collect: writes extracted skills to ~/.apc/skills/<name>/SKILL.md via save_skill_file() instead of storing inline in cache bundle - apc sync: uses sync_skills_dir() for SKILL_DIR_EXCLUSIVE tools (OpenClaw, Claude Code) — replaces entire skills dir with one symlink → ~/.apc/skills/; future apc installs are live immediately without re-running sync - apc install: drops _apply_skill_to_targets(); saves to ~/.apc/skills/ only; warns if per-skill tools need apc sync to pick up the new skill - base.py: add SKILL_DIR_EXCLUSIVE flag + sync_skills_dir() method - openclaw.py + claude.py: SKILL_DIR_EXCLUSIVE = True - tests: updated to reflect no apply_skills() in sync path; per-skill tools assert link_skills() called; exclusive tools assert sync_skills_dir() * fix: CI failures — os.readlink crash, skills.json index, dir_sync manifest + status check - base.py: fix OSError in sync_skills_dir() — os.readlink() was called before is_symlink() check; restructured to guard correctly - collect.py: restore save_skills() for index (metadata only, no body) so apc skill list and existing tests still work - manifest.py: add record_dir_sync() + dir_sync field in schema - sync_helpers.py: call manifest.record_dir_sync() after sync_skills_dir() succeeds - status.py: check dir_sync symlink integrity for exclusive-dir tools - test_docker_integration.py: seed ~/.apc/skills/ instead of inline skills.json body; update test_out_of_sync to break dir symlink for exclusive-dir tools * 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 * refactor: remove SKILL_DIR_EXCLUSIVE flag — SKILL_DIR=None is the only signal needed If SKILL_DIR is set, sync_skills_dir() runs. If None (Copilot), it skips. No boolean flag required. * feat(#71): tool-native skill sync — Gemini, Windsurf, Copilot adapters + apc unsync (#72) * feat(#71): tool-native skill sync — Gemini dir-symlink, Windsurf injection, Copilot per-file, apc unsync - Add SKILL_DIR = ~/.gemini/skills/ — Gemini natively reads <name>/SKILL.md subdirs - Dir-symlink on apc sync; new installs appear immediately - No native skills dir; inject ## APC Skills section into global_rules.md - sync_skills_dir(): writes managed block (<!-- apc-skills-start/end --> markers) - apply_installed_skill(): regenerates block when new skill installed - unsync_skills(): removes the managed block cleanly - sync_skills_dir(): creates per-file symlinks in ~/.github/instructions/ <name>.instructions.md → ~/.apc/skills/<name>/SKILL.md - apply_installed_skill(): creates one symlink for the new skill - unsync_skills(): removes all apc-managed .instructions.md symlinks - apply_installed_skill(name): default no-op for dir-symlink tools - unsync_skills(): default removes dir symlink, recreates empty dir - record_tool_sync(sync_method): records non-dir-symlink syncs - sync_method property: returns sync method from stored dir_sync data - _propagate_to_synced_tools(): after save_skill_file(), finds all synced tools not in explicit targets and calls apply_installed_skill() - apc unsync [TOOL...] --all --yes - Calls unsync_skills() per tool, clears dir_sync from manifest * fix: remove --target/-t from apc install; propagate to ALL synced tools The --target flag was a legacy concept from when skills were copied into each tool's directory. With dir-symlink + sync registry, skills always land in ~/.apc/skills/ and propagate via sync — the target is irrelevant. Changes: - Drop --target/-t option from entirely - _propagate_to_synced_tools() no longer takes explicit_targets arg — calls apply_installed_skill() for every synced tool unconditionally - Dir-symlink tools: still a no-op (symlink already propagates) - Windsurf/Copilot: correctly get apply_installed_skill() even if they were previously the only flag passed to --target - Success message updated: 'to ~/.apc/skills/' instead of tool list - Tests: replaced -t/--target usage with plain -y; updated target-all test to test_install_saves_to_apc_skills * fix: ruff format test_docker_integration.py (lint CI) * test: 42 unit tests for tool-native sync (Gemini, Windsurf, Copilot, apc unsync) * test: 15 docker integration tests for Windsurf injection + Copilot per-file sync Also fixes sync_skills() to correctly record sync method for non-dir tools: - record_dir_sync() only called when SKILL_DIR is set - record_tool_sync(SYNC_METHOD) called for Windsurf (injection) and Copilot (per-file-symlink) - SYNC_METHOD class attr added to BaseApplier (default: dir-symlink) - WindsurfApplier.SYNC_METHOD = 'injection' - CopilotApplier.SYNC_METHOD = 'per-file-symlink' Windsurf tests (8): - sync creates injection block in global_rules.md - global_rules.md is a real file, not a symlink - existing rules preserved when block appended - apc install propagates to synced Windsurf (regenerates block) - resync replaces stale block without duplicates - unsync removes APC block cleanly - unsync preserves surrounding rule content - status reflects windsurf as synced Copilot tests (7): - sync creates .instructions.md symlinks in ~/.github/instructions/ - symlinks resolve to correct ~/.apc/skills/<name>/SKILL.md target - apc install propagates to synced Copilot (creates new symlink) - instructions dir is a real dir, not a dir-level symlink - unsync removes all .instructions.md symlinks - unsync preserves manually created (non-symlink) .instructions.md files - resync after new installs refreshes all symlinks * fix: remove unused imports call, pytest (lint) * feat: apc skill remove — propagates deletion to Windsurf + Copilot When a skill is removed from ~/.apc/skills/: - Dir-symlink tools (Claude Code, OpenClaw, Gemini, Cursor): automatic — the skill dir vanishes from the symlink with no extra action. - Windsurf: regenerates global_rules.md block; removed skill omitted. - Copilot: removes the now-dangling .instructions.md symlink. New: - skills.delete_skill_file(name): removes ~/.apc/skills/<name>/ tree - BaseApplier.remove_installed_skill(name): default no-op - WindsurfApplier.remove_installed_skill(name): regenerate block - CopilotApplier.remove_installed_skill(name): delete symlink - install.propagate_remove_to_synced_tools(name): calls remove_installed_skill for all synced tools (mirrors _propagate_to_synced_tools pattern) - apc skill remove NAME [NAME...] [--yes]: new CLI command Tests (18 new): - 10 unit tests: BaseApplier no-op, Windsurf regenerate, Copilot symlink removal, propagate_remove_to_synced_tools (all synced, skip unsynced, exception isolation) - 8 docker integration tests: Windsurf block updated after remove, block kept with remaining skills, empty block after last skill, Copilot symlink deleted, other symlinks kept, ~/.apc/skills/ deleted, unknown skill warning, no-sync path safe
1 parent 1c30488 commit 16135a6

17 files changed

Lines changed: 1963 additions & 182 deletions

src/appliers/base.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ class BaseApplier(ABC):
6464
# Subclasses that support skills should set this to their skill directory
6565
# and the target name used in frontmatter filtering.
6666
SKILL_DIR: Optional[Path] = None
67+
SYNC_METHOD: str = "dir-symlink" # override in injection/per-file tools
6768
TOOL_NAME: str = ""
6869

6970
# Subclasses that support LLM-based memory sync should override this
@@ -140,6 +141,75 @@ def link_skills(self, skills: List[Dict], source_dir: Path, manifest: ToolManife
140141

141142
return count
142143

144+
def sync_skills_dir(self) -> bool:
145+
"""Establish a dir-level symlink: SKILL_DIR → ~/.apc/skills/.
146+
147+
Only applies when SKILL_DIR is set on the applier.
148+
After this runs once, any future `apc install` is immediately live in
149+
this tool without re-running sync.
150+
151+
Returns True if the symlink was established, False if not applicable.
152+
"""
153+
from skills import get_skills_dir
154+
155+
if self.SKILL_DIR is None:
156+
return False
157+
158+
skills_source = get_skills_dir()
159+
skill_dir = self.SKILL_DIR
160+
161+
# Already correctly symlinked — nothing to do
162+
already_linked = skill_dir.is_symlink() and (
163+
Path(os.readlink(skill_dir)).resolve() == skills_source.resolve()
164+
)
165+
if already_linked:
166+
return True
167+
168+
# Remove whatever is there now
169+
if skill_dir.is_symlink():
170+
skill_dir.unlink()
171+
elif skill_dir.exists():
172+
shutil.rmtree(skill_dir)
173+
174+
# Ensure parent exists, then symlink
175+
skill_dir.parent.mkdir(parents=True, exist_ok=True)
176+
os.symlink(skills_source, skill_dir)
177+
return True
178+
179+
def apply_installed_skill(self, name: str) -> bool:
180+
"""Propagate a newly installed skill to this tool (called by apc install).
181+
182+
Dir-symlink tools: no-op — the symlink already makes the skill live.
183+
Override in tools that need per-skill injection (Windsurf, Copilot).
184+
Returns True if an action was taken, False if no-op.
185+
"""
186+
return False # dir-symlink tools need no action
187+
188+
def remove_installed_skill(self, name: str) -> bool:
189+
"""Clean up after a skill is uninstalled from ~/.apc/skills/.
190+
191+
Dir-symlink tools: no-op — the skill dir vanishes automatically.
192+
Override in tools that maintain per-skill state (Windsurf, Copilot).
193+
Returns True if an action was taken, False if no-op.
194+
"""
195+
return False # dir-symlink tools need no cleanup
196+
197+
def unsync_skills(self) -> bool:
198+
"""Undo the skill sync for this tool.
199+
200+
Dir-symlink tools: remove the symlink, recreate an empty dir.
201+
Override in tools that use injection or per-file symlinks.
202+
Returns True if anything was undone.
203+
"""
204+
skill_dir = self.SKILL_DIR
205+
if skill_dir is None:
206+
return False
207+
if skill_dir.is_symlink():
208+
skill_dir.unlink()
209+
skill_dir.mkdir(parents=True, exist_ok=True)
210+
return True
211+
return False
212+
143213
@abstractmethod
144214
def apply_mcp_servers(
145215
self,

src/appliers/copilot.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ def _copilot_instructions_dir() -> Path:
1818
return Path.cwd() / ".github" / "instructions"
1919

2020

21+
def _copilot_global_instructions_dir() -> Path:
22+
"""User-global instructions dir — applies across all projects via VS Code."""
23+
return Path.home() / ".github" / "instructions"
24+
25+
2126
def _vscode_mcp_json() -> Path:
2227
return Path.cwd() / ".vscode" / "mcp.json"
2328

@@ -87,6 +92,7 @@ def _vscode_mcp_json() -> Path:
8792

8893
class CopilotApplier(BaseApplier):
8994
TOOL_NAME = "github-copilot"
95+
SYNC_METHOD = "per-file-symlink"
9096
MEMORY_SCHEMA = COPILOT_MEMORY_SCHEMA
9197

9298
@property # type: ignore[override]
@@ -96,6 +102,73 @@ def MEMORY_ALLOWED_BASE(self) -> "Path": # noqa: N802
96102
# calling process later changes directory (#42).
97103
return Path.cwd().resolve()
98104

105+
def _global_instructions_dir(self) -> Path:
106+
return _copilot_global_instructions_dir()
107+
108+
def sync_skills_dir(self) -> bool: # type: ignore[override]
109+
"""Create per-skill .instructions.md symlinks in ~/.github/instructions/.
110+
111+
Copilot reads each <name>.instructions.md in the instructions dir.
112+
We symlink: ~/.github/instructions/<name>.instructions.md →
113+
~/.apc/skills/<name>/SKILL.md
114+
so each skill's content is served as a Copilot instruction.
115+
"""
116+
from skills import get_skills_dir
117+
118+
instr_dir = self._global_instructions_dir()
119+
instr_dir.mkdir(parents=True, exist_ok=True)
120+
skills_dir = get_skills_dir()
121+
122+
if not skills_dir.exists():
123+
return True # nothing to link yet; will populate on first apc install
124+
125+
for skill_path in skills_dir.iterdir():
126+
skill_md = skill_path / "SKILL.md"
127+
if not skill_md.exists():
128+
continue
129+
self._link_skill(skill_path.name, skill_md, instr_dir)
130+
131+
return True
132+
133+
def apply_installed_skill(self, name: str) -> bool: # type: ignore[override]
134+
"""Create a symlink for a newly installed skill."""
135+
from skills import get_skills_dir
136+
137+
skill_md = get_skills_dir() / name / "SKILL.md"
138+
if not skill_md.exists():
139+
return False
140+
instr_dir = self._global_instructions_dir()
141+
instr_dir.mkdir(parents=True, exist_ok=True)
142+
self._link_skill(name, skill_md, instr_dir)
143+
return True
144+
145+
def remove_installed_skill(self, name: str) -> bool: # type: ignore[override]
146+
"""Remove the dangling .instructions.md symlink for an uninstalled skill."""
147+
link = self._global_instructions_dir() / f"{name}.instructions.md"
148+
if link.is_symlink():
149+
link.unlink()
150+
return True
151+
return False
152+
153+
def unsync_skills(self) -> bool: # type: ignore[override]
154+
"""Remove all apc-managed .instructions.md symlinks from ~/.github/instructions/."""
155+
instr_dir = self._global_instructions_dir()
156+
if not instr_dir.exists():
157+
return False
158+
removed = 0
159+
for link in instr_dir.glob("*.instructions.md"):
160+
if link.is_symlink():
161+
link.unlink()
162+
removed += 1
163+
return removed > 0
164+
165+
@staticmethod
166+
def _link_skill(name: str, skill_md: Path, instr_dir: Path) -> None:
167+
link_path = instr_dir / f"{name}.instructions.md"
168+
if link_path.is_symlink() or link_path.exists():
169+
link_path.unlink()
170+
os.symlink(skill_md.resolve(), link_path)
171+
99172
def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
100173
count = 0
101174
instructions = _copilot_instructions()

src/appliers/cursor.py

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

7777
class CursorApplier(BaseApplier):
7878
TOOL_NAME = "cursor"
79+
# ~/.cursor/rules/ → ~/.apc/skills/ symlink (dir-level sync, same as all tools).
80+
# Skills appear as <name>/SKILL.md subdirs; .mdc per-file format superseded.
7981
MEMORY_SCHEMA = CURSOR_MEMORY_SCHEMA
8082

8183
@property # type: ignore[override]

src/appliers/gemini.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ def _gemini_dir() -> Path:
6565
return Path.home() / ".gemini"
6666

6767

68+
def _gemini_skills_dir() -> Path:
69+
return Path.home() / ".gemini" / "skills"
70+
71+
6872
def _gemini_settings() -> Path:
6973
return Path.home() / ".gemini/settings.json"
7074

@@ -75,6 +79,11 @@ def _gemini_md() -> Path:
7579

7680
class GeminiApplier(BaseApplier):
7781
TOOL_NAME = "gemini-cli"
82+
83+
@property
84+
def SKILL_DIR(self) -> Path: # type: ignore[override]
85+
return _gemini_skills_dir()
86+
7887
MEMORY_SCHEMA = GEMINI_MEMORY_SCHEMA
7988

8089
@property # type: ignore[override]

src/appliers/manifest.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,32 @@ def _empty(self) -> dict:
5757
"last_sync_at": None,
5858
"skills": {},
5959
"linked_skills": {},
60+
"dir_sync": None,
6061
"mcp_servers": {},
6162
"memory": {},
6263
}
6364

65+
def record_dir_sync(self, skill_dir: str, target: str) -> None:
66+
"""Record a dir-level symlink: skill_dir → target (~/.apc/skills/)."""
67+
self._data["dir_sync"] = {
68+
"skill_dir": skill_dir,
69+
"target": target,
70+
"sync_method": "dir-symlink",
71+
"synced_at": _now_iso(),
72+
}
73+
74+
def record_tool_sync(self, sync_method: str) -> None:
75+
"""Record a tool-specific sync (injection or per-file symlinks)."""
76+
self._data["dir_sync"] = {
77+
"sync_method": sync_method,
78+
"synced_at": _now_iso(),
79+
}
80+
81+
@property
82+
def sync_method(self) -> str | None:
83+
"""Return the sync method recorded for this tool, or None if never synced."""
84+
return (self._data.get("dir_sync") or {}).get("sync_method")
85+
6486
@property
6587
def is_first_sync(self) -> bool:
6688
"""True when no manifest existed on disk before this run."""

src/appliers/windsurf.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,90 @@ def _windsurf_global_rules() -> Path:
9191

9292
class WindsurfApplier(BaseApplier):
9393
TOOL_NAME = "windsurf"
94+
SYNC_METHOD = "injection"
9495
MEMORY_SCHEMA = WINDSURF_MEMORY_SCHEMA
9596

9697
@property # type: ignore[override]
9798
def MEMORY_ALLOWED_BASE(self) -> "Path": # noqa: N802
9899
return _windsurf_dir()
99100

101+
_APC_SKILLS_HEADER = "<!-- apc-skills-start -->"
102+
_APC_SKILLS_FOOTER = "<!-- apc-skills-end -->"
103+
104+
def _skills_section(self) -> str:
105+
"""Build the APC-managed skills block for global_rules.md."""
106+
from skills import get_skills_dir
107+
108+
skills_dir = get_skills_dir()
109+
lines = [
110+
self._APC_SKILLS_HEADER,
111+
"",
112+
"## APC Skills",
113+
"",
114+
"The following skills are managed by apc. Each provides specialised",
115+
"instructions — refer to the skill name when you need that capability.",
116+
"",
117+
]
118+
if skills_dir.exists():
119+
for skill_path in sorted(skills_dir.iterdir()):
120+
skill_md = skill_path / "SKILL.md"
121+
if skill_md.exists():
122+
lines.append(f"- **{skill_path.name}**: {skill_md}")
123+
lines += ["", self._APC_SKILLS_FOOTER]
124+
return "\n".join(lines)
125+
126+
def _write_skills_to_global_rules(self) -> None:
127+
"""Inject (or update) the APC skills block in global_rules.md."""
128+
rules_path = _windsurf_global_rules()
129+
rules_path.parent.mkdir(parents=True, exist_ok=True)
130+
existing = rules_path.read_text(encoding="utf-8") if rules_path.exists() else ""
131+
132+
block = self._skills_section()
133+
134+
if self._APC_SKILLS_HEADER in existing:
135+
# Replace existing block
136+
start = existing.index(self._APC_SKILLS_HEADER)
137+
end = existing.index(self._APC_SKILLS_FOOTER) + len(self._APC_SKILLS_FOOTER)
138+
updated = existing[:start] + block + existing[end:]
139+
else:
140+
# Append block
141+
updated = existing.rstrip("\n") + "\n\n" + block + "\n"
142+
143+
rules_path.write_text(updated, encoding="utf-8")
144+
145+
def sync_skills_dir(self) -> bool: # type: ignore[override]
146+
"""Inject the APC skills section into global_rules.md (no dir symlink)."""
147+
self._write_skills_to_global_rules()
148+
return True
149+
150+
def apply_installed_skill(self, name: str) -> bool: # type: ignore[override]
151+
"""Regenerate the APC skills block when a new skill is installed."""
152+
self._write_skills_to_global_rules()
153+
return True
154+
155+
def remove_installed_skill(self, name: str) -> bool: # type: ignore[override]
156+
"""Regenerate the APC skills block after a skill is uninstalled.
157+
158+
The deleted skill is already gone from ~/.apc/skills/ at this point,
159+
so _write_skills_to_global_rules() will naturally omit it.
160+
"""
161+
self._write_skills_to_global_rules()
162+
return True
163+
164+
def unsync_skills(self) -> bool: # type: ignore[override]
165+
"""Remove the APC skills section from global_rules.md."""
166+
rules_path = _windsurf_global_rules()
167+
if not rules_path.exists():
168+
return False
169+
content = rules_path.read_text(encoding="utf-8")
170+
if self._APC_SKILLS_HEADER not in content:
171+
return False
172+
start = content.index(self._APC_SKILLS_HEADER)
173+
end = content.index(self._APC_SKILLS_FOOTER) + len(self._APC_SKILLS_FOOTER)
174+
updated = (content[:start] + content[end:]).strip("\n") + "\n"
175+
rules_path.write_text(updated, encoding="utf-8")
176+
return True
177+
100178
def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
101179
return 0
102180

src/collect.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
save_skills,
2222
)
2323
from extractors import detect_installed_tools, get_extractor
24+
from frontmatter_parser import render_frontmatter
2425
from secrets_manager import detect_and_redact, store_secrets_batch
26+
from skills import save_skill_file
2527
from ui import (
2628
cache_summary_table,
2729
display_memory_files,
@@ -198,16 +200,33 @@ def collect(tools, no_memory, dry_run, yes):
198200
info("\n[dry-run] No files written.")
199201
return
200202

201-
# Merge into existing cache (upsert, never delete)
202-
merged_skills = merge_skills(load_skills(), new_skills)
203+
# Write collected skills to ~/.apc/skills/<name>/SKILL.md (source of truth)
204+
# Skills are never stored inline in the cache — ~/.apc/skills/ is canonical.
205+
for skill in new_skills:
206+
name = skill.get("name", "unnamed")
207+
metadata = {k: skill[k] for k in ("name", "description", "tags", "version") if skill.get(k)}
208+
raw_content = render_frontmatter(metadata, skill.get("body", ""))
209+
try:
210+
save_skill_file(name, raw_content)
211+
except ValueError as exc:
212+
warning(f"Skipping skill {name!r}: {exc}")
213+
203214
merged_mcp = merge_mcp_servers(load_mcp_servers(), new_mcp_servers)
204215
merged_memory = merge_memory(load_memory(), selected_memory)
216+
# Keep skills.json as a metadata index (name, description, tags — no body)
217+
# so `apc skill list` and other commands can enumerate skills without
218+
# reading every SKILL.md. Body lives in ~/.apc/skills/<name>/SKILL.md.
219+
skill_index = [
220+
{k: s[k] for k in ("name", "description", "tags", "version", "source_tool") if k in s}
221+
for s in new_skills
222+
]
223+
merged_index = merge_skills(load_skills(), skill_index)
205224

206-
save_skills(merged_skills)
207225
save_mcp_servers(merged_mcp)
208226
save_memory(merged_memory)
227+
save_skills(merged_index)
209228

210229
cache_summary_table(
211-
len(merged_skills), len(merged_mcp), len(merged_memory), title="Local Cache Updated"
230+
len(new_skills), len(merged_mcp), len(merged_memory), title="Local Cache Updated"
212231
)
213232
success("Collection complete.")

0 commit comments

Comments
 (0)