Skip to content

Commit a6dd26d

Browse files
committed
fix(#33): apc install symlinks skills instead of copying them
- Replace _apply_skill_to_targets() (apply_skills) with _link_skill_to_targets() (link_skills) in install.py — skill is saved to ~/.apc/skills/ then symlinked into each tool's skills dir, never copied - Revert openclaw.py symlink-unlink workaround — no longer needed since apply_skills() is only called for collected skills (no pre-existing symlink) - Update test: apply_skills now asserts real dir created (collected skills path); add test confirming no symlinks from apply_skills
1 parent 5e66965 commit a6dd26d

3 files changed

Lines changed: 21 additions & 33 deletions

File tree

src/appliers/openclaw.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,7 @@ def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
8585

8686
# OpenClaw uses directory-based skills: <name>/SKILL.md
8787
skill_dir = _openclaw_skills_dir() / name
88-
89-
# If the dir exists (real or symlink → dir from apc install), skip mkdir
90-
# so we write through the existing symlink rather than replacing it.
91-
if not skill_dir.exists():
92-
skill_dir.mkdir(parents=True, exist_ok=True)
88+
skill_dir.mkdir(parents=True, exist_ok=True)
9389
path = skill_dir / "SKILL.md"
9490
path.write_text(content, encoding="utf-8")
9591
manifest.record_skill(name, file_path=str(path), content=content)

src/install.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,17 +83,22 @@ def _resolve_targets(target_args: tuple, yes: bool) -> List[str]:
8383
return targets
8484

8585

86-
def _apply_skill_to_targets(skill: dict, target_list: list) -> int:
87-
"""Write a skill directly to each target's skill directory. Returns applied count."""
86+
def _link_skill_to_targets(skill: dict, target_list: list) -> int:
87+
"""Symlink a skill from ~/.apc/skills/ into each target's skill directory.
8888
89+
Uses link_skills() so the tool's skill dir always points back to the
90+
canonical source — never a stale copy.
91+
Returns number of targets linked.
92+
"""
93+
skills_dir = get_skills_dir()
8994
count = 0
9095
for target_name in target_list:
9196
try:
9297
applier = get_applier(target_name)
9398
manifest = applier.get_manifest()
94-
applied = applier.apply_skills([skill], manifest)
99+
linked = applier.link_skills([skill], skills_dir, manifest)
95100
manifest.save()
96-
count += applied
101+
count += linked
97102
except Exception as e:
98103
click.echo(f" ! {target_name}: {e}", err=True)
99104
return count
@@ -232,7 +237,7 @@ def install(repo, skills, install_all, targets, branch, list_only, yes):
232237
save_skill_file(skill["name"], raw_content)
233238

234239
# Apply directly to each target target
235-
_apply_skill_to_targets(skill, target_list)
240+
_link_skill_to_targets(skill, target_list)
236241

237242
# Save metadata to local cache
238243
existing = load_skills()

tests/test_appliers.py

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -453,35 +453,22 @@ def test_apply_skills_clean_dir(self):
453453
self.assertTrue(skill_md.exists())
454454
self.assertIn("test-skill", manifest.managed_skill_names())
455455

456-
def test_apply_skills_replaces_symlink(self):
457-
"""apply_skills must not crash when skill dir is a pre-existing symlink (apc install)."""
458-
# Simulate what `apc install` does: create a symlink in skills_dir
459-
real_source = self.tmpdir / "source" / "test-skill"
460-
real_source.mkdir(parents=True)
461-
(real_source / "SKILL.md").write_text("# Original", encoding="utf-8")
462-
463-
symlink_path = self.skills_dir / "test-skill"
464-
symlink_path.symlink_to(real_source)
465-
self.assertTrue(symlink_path.is_symlink(), "precondition: symlink must exist")
466-
467-
# Now run apply_skills — must NOT raise FileExistsError (errno 17)
456+
def test_apply_skills_does_not_create_symlinks(self):
457+
"""apply_skills (collected skills path) always writes real directories, never symlinks.
458+
459+
Installed skills are linked via link_skills(). apply_skills() is only called
460+
for collected skills which have no source directory — so it must create a real dir.
461+
"""
468462
manifest = self._manifest()
469463
with patch("appliers.openclaw._openclaw_skills_dir", return_value=self.skills_dir):
470464
from appliers.openclaw import OpenClawApplier
471465

472466
applier = OpenClawApplier()
473-
try:
474-
count = applier.apply_skills([self._skill()], manifest)
475-
except FileExistsError as exc:
476-
self.fail(f"apply_skills raised FileExistsError on symlink: {exc}")
467+
applier.apply_skills([self._skill()], manifest)
477468

478-
self.assertEqual(count, 1)
479-
skill_md = self.skills_dir / "test-skill" / "SKILL.md"
480-
self.assertTrue(skill_md.exists(), "SKILL.md should be written through the symlink")
481-
self.assertTrue(
482-
symlink_path.is_symlink(),
483-
"apc install symlink must be preserved — apply_skills writes through it, not over it",
484-
)
469+
skill_dir = self.skills_dir / "test-skill"
470+
self.assertTrue(skill_dir.is_dir())
471+
self.assertFalse(skill_dir.is_symlink(), "apply_skills must create a real dir, not a symlink")
485472

486473
def test_apply_skills_multiple_skills(self):
487474
"""apply_skills handles multiple skills in one call."""

0 commit comments

Comments
 (0)