Skip to content
35 changes: 35 additions & 0 deletions src/appliers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class BaseApplier(ABC):
# Subclasses that support skills should set this to their skill directory
# and the target name used in frontmatter filtering.
SKILL_DIR: Optional[Path] = None
SYNC_METHOD: str = "dir-symlink" # override in injection/per-file tools
TOOL_NAME: str = ""

# Subclasses that support LLM-based memory sync should override this
Expand Down Expand Up @@ -175,6 +176,40 @@ def sync_skills_dir(self) -> bool:
os.symlink(skills_source, skill_dir)
return True

def apply_installed_skill(self, name: str) -> bool:
"""Propagate a newly installed skill to this tool (called by apc install).

Dir-symlink tools: no-op — the symlink already makes the skill live.
Override in tools that need per-skill injection (Windsurf, Copilot).
Returns True if an action was taken, False if no-op.
"""
return False # dir-symlink tools need no action

def remove_installed_skill(self, name: str) -> bool:
"""Clean up after a skill is uninstalled from ~/.apc/skills/.

Dir-symlink tools: no-op — the skill dir vanishes automatically.
Override in tools that maintain per-skill state (Windsurf, Copilot).
Returns True if an action was taken, False if no-op.
"""
return False # dir-symlink tools need no cleanup

def unsync_skills(self) -> bool:
"""Undo the skill sync for this tool.

Dir-symlink tools: remove the symlink, recreate an empty dir.
Override in tools that use injection or per-file symlinks.
Returns True if anything was undone.
"""
skill_dir = self.SKILL_DIR
if skill_dir is None:
return False
if skill_dir.is_symlink():
skill_dir.unlink()
skill_dir.mkdir(parents=True, exist_ok=True)
return True
return False

@abstractmethod
def apply_mcp_servers(
self,
Expand Down
73 changes: 73 additions & 0 deletions src/appliers/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ def _copilot_instructions_dir() -> Path:
return Path.cwd() / ".github" / "instructions"


def _copilot_global_instructions_dir() -> Path:
"""User-global instructions dir — applies across all projects via VS Code."""
return Path.home() / ".github" / "instructions"


def _vscode_mcp_json() -> Path:
return Path.cwd() / ".vscode" / "mcp.json"

Expand Down Expand Up @@ -87,6 +92,7 @@ def _vscode_mcp_json() -> Path:

class CopilotApplier(BaseApplier):
TOOL_NAME = "github-copilot"
SYNC_METHOD = "per-file-symlink"
MEMORY_SCHEMA = COPILOT_MEMORY_SCHEMA

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

def _global_instructions_dir(self) -> Path:
return _copilot_global_instructions_dir()

def sync_skills_dir(self) -> bool: # type: ignore[override]
"""Create per-skill .instructions.md symlinks in ~/.github/instructions/.

Copilot reads each <name>.instructions.md in the instructions dir.
We symlink: ~/.github/instructions/<name>.instructions.md →
~/.apc/skills/<name>/SKILL.md
so each skill's content is served as a Copilot instruction.
"""
from skills import get_skills_dir

instr_dir = self._global_instructions_dir()
instr_dir.mkdir(parents=True, exist_ok=True)
skills_dir = get_skills_dir()

if not skills_dir.exists():
return True # nothing to link yet; will populate on first apc install

for skill_path in skills_dir.iterdir():
skill_md = skill_path / "SKILL.md"
if not skill_md.exists():
continue
self._link_skill(skill_path.name, skill_md, instr_dir)

return True

def apply_installed_skill(self, name: str) -> bool: # type: ignore[override]
"""Create a symlink for a newly installed skill."""
from skills import get_skills_dir

skill_md = get_skills_dir() / name / "SKILL.md"
if not skill_md.exists():
return False
instr_dir = self._global_instructions_dir()
instr_dir.mkdir(parents=True, exist_ok=True)
self._link_skill(name, skill_md, instr_dir)
return True

def remove_installed_skill(self, name: str) -> bool: # type: ignore[override]
"""Remove the dangling .instructions.md symlink for an uninstalled skill."""
link = self._global_instructions_dir() / f"{name}.instructions.md"
if link.is_symlink():
link.unlink()
return True
return False

def unsync_skills(self) -> bool: # type: ignore[override]
"""Remove all apc-managed .instructions.md symlinks from ~/.github/instructions/."""
instr_dir = self._global_instructions_dir()
if not instr_dir.exists():
return False
removed = 0
for link in instr_dir.glob("*.instructions.md"):
if link.is_symlink():
link.unlink()
removed += 1
return removed > 0

@staticmethod
def _link_skill(name: str, skill_md: Path, instr_dir: Path) -> None:
link_path = instr_dir / f"{name}.instructions.md"
if link_path.is_symlink() or link_path.exists():
link_path.unlink()
os.symlink(skill_md.resolve(), link_path)

def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
count = 0
instructions = _copilot_instructions()
Expand Down
9 changes: 9 additions & 0 deletions src/appliers/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ def _gemini_dir() -> Path:
return Path.home() / ".gemini"


def _gemini_skills_dir() -> Path:
return Path.home() / ".gemini" / "skills"


def _gemini_settings() -> Path:
return Path.home() / ".gemini/settings.json"

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

class GeminiApplier(BaseApplier):
TOOL_NAME = "gemini-cli"

@property
def SKILL_DIR(self) -> Path: # type: ignore[override]
return _gemini_skills_dir()

MEMORY_SCHEMA = GEMINI_MEMORY_SCHEMA

@property # type: ignore[override]
Expand Down
13 changes: 13 additions & 0 deletions src/appliers/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,22 @@ def record_dir_sync(self, skill_dir: str, target: str) -> None:
self._data["dir_sync"] = {
"skill_dir": skill_dir,
"target": target,
"sync_method": "dir-symlink",
"synced_at": _now_iso(),
}

def record_tool_sync(self, sync_method: str) -> None:
"""Record a tool-specific sync (injection or per-file symlinks)."""
self._data["dir_sync"] = {
"sync_method": sync_method,
"synced_at": _now_iso(),
}

@property
def sync_method(self) -> str | None:
"""Return the sync method recorded for this tool, or None if never synced."""
return (self._data.get("dir_sync") or {}).get("sync_method")

@property
def is_first_sync(self) -> bool:
"""True when no manifest existed on disk before this run."""
Expand Down
78 changes: 78 additions & 0 deletions src/appliers/windsurf.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,90 @@ def _windsurf_global_rules() -> Path:

class WindsurfApplier(BaseApplier):
TOOL_NAME = "windsurf"
SYNC_METHOD = "injection"
MEMORY_SCHEMA = WINDSURF_MEMORY_SCHEMA

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

_APC_SKILLS_HEADER = "<!-- apc-skills-start -->"
_APC_SKILLS_FOOTER = "<!-- apc-skills-end -->"

def _skills_section(self) -> str:
"""Build the APC-managed skills block for global_rules.md."""
from skills import get_skills_dir

skills_dir = get_skills_dir()
lines = [
self._APC_SKILLS_HEADER,
"",
"## APC Skills",
"",
"The following skills are managed by apc. Each provides specialised",
"instructions — refer to the skill name when you need that capability.",
"",
]
if skills_dir.exists():
for skill_path in sorted(skills_dir.iterdir()):
skill_md = skill_path / "SKILL.md"
if skill_md.exists():
lines.append(f"- **{skill_path.name}**: {skill_md}")
lines += ["", self._APC_SKILLS_FOOTER]
return "\n".join(lines)

def _write_skills_to_global_rules(self) -> None:
"""Inject (or update) the APC skills block in global_rules.md."""
rules_path = _windsurf_global_rules()
rules_path.parent.mkdir(parents=True, exist_ok=True)
existing = rules_path.read_text(encoding="utf-8") if rules_path.exists() else ""

block = self._skills_section()

if self._APC_SKILLS_HEADER in existing:
# Replace existing block
start = existing.index(self._APC_SKILLS_HEADER)
end = existing.index(self._APC_SKILLS_FOOTER) + len(self._APC_SKILLS_FOOTER)
updated = existing[:start] + block + existing[end:]
else:
# Append block
updated = existing.rstrip("\n") + "\n\n" + block + "\n"

rules_path.write_text(updated, encoding="utf-8")

def sync_skills_dir(self) -> bool: # type: ignore[override]
"""Inject the APC skills section into global_rules.md (no dir symlink)."""
self._write_skills_to_global_rules()
return True

def apply_installed_skill(self, name: str) -> bool: # type: ignore[override]
"""Regenerate the APC skills block when a new skill is installed."""
self._write_skills_to_global_rules()
return True

def remove_installed_skill(self, name: str) -> bool: # type: ignore[override]
"""Regenerate the APC skills block after a skill is uninstalled.

The deleted skill is already gone from ~/.apc/skills/ at this point,
so _write_skills_to_global_rules() will naturally omit it.
"""
self._write_skills_to_global_rules()
return True

def unsync_skills(self) -> bool: # type: ignore[override]
"""Remove the APC skills section from global_rules.md."""
rules_path = _windsurf_global_rules()
if not rules_path.exists():
return False
content = rules_path.read_text(encoding="utf-8")
if self._APC_SKILLS_HEADER not in content:
return False
start = content.index(self._APC_SKILLS_HEADER)
end = content.index(self._APC_SKILLS_FOOTER) + len(self._APC_SKILLS_FOOTER)
updated = (content[:start] + content[end:]).strip("\n") + "\n"
rules_path.write_text(updated, encoding="utf-8")
return True

def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
return 0

Expand Down
Loading