Skip to content

Commit befcc8d

Browse files
committed
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 1579d5c commit befcc8d

File tree

13 files changed

+1808
-101
lines changed

13 files changed

+1808
-101
lines changed

src/appliers/base.py

Lines changed: 35 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
@@ -175,6 +176,40 @@ def sync_skills_dir(self) -> bool:
175176
os.symlink(skills_source, skill_dir)
176177
return True
177178

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+
178213
@abstractmethod
179214
def apply_mcp_servers(
180215
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/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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,22 @@ def record_dir_sync(self, skill_dir: str, target: str) -> None:
6767
self._data["dir_sync"] = {
6868
"skill_dir": skill_dir,
6969
"target": target,
70+
"sync_method": "dir-symlink",
7071
"synced_at": _now_iso(),
7172
}
7273

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+
7386
@property
7487
def is_first_sync(self) -> bool:
7588
"""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

0 commit comments

Comments
 (0)