Skip to content

feat(skills): follow symlinked skill directories - #1003

Open
vernonstinebaker wants to merge 2 commits into
nullclaw:mainfrom
vernonstinebaker:feat/skills-follow-symlinks
Open

vernonstinebaker wants to merge 2 commits into
nullclaw:mainfrom
vernonstinebaker:feat/skills-follow-symlinks

Conversation

@vernonstinebaker

Copy link
Copy Markdown
Contributor

Summary

  • nullclaw skills list and the category scan now follow a symlink when it points at a skill directory, and skip broken or non-directory targets.
  • Archive installs are unchanged: symlink entries inside a downloaded archive are still rejected.
  • Adds English and Chinese skills pages, including how to share one canonical skill repo across workspaces.

Fixes #995

Test plan

  • zig build test --summary all passed on push (pre-push hook)
  • nullclaw skills list shows a skill directory that is a symlink to a real skill.json / SKILL.md
  • A broken symlink in skills/ is omitted and does not fail the listing

Made with Cursor

`nullclaw skills list` skipped any skills/ entry whose kind was a symlink, so a shared canonical skill repo linked into each workspace was invisible. Directory walks now follow a symlink when the target is a directory and skip broken or non-directory targets. Archive installs still reject symlink entries inside downloaded archives.

Fixes nullclaw#995

@DonPrus DonPrus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The top-level and nested directory discovery changes are useful, and the archive audit still rejects archive symlinks. I read the full diff, #995 discussion, CLI/agent loading and removal paths, and checked current main (clean merge). One related path needs to be made safe before approval:

[P1] Removing a skill discovered through a symlinked category deletes the canonical copy. With workspace/skills/category -> ../canonical and canonical/shared/SKILL.md, this PR discovers shared. removeSkill("shared") then calls deleteTreeAbsolute on workspace/skills/category/shared. That helper opens the parent directory, follows the category link and recursively deletes canonical/shared. This destroys the shared repository's skill, potentially affecting every workspace using it. A direct skill symlink has different unlink behavior and does not cover this case. Please track/reject removal through a symlink ancestor (or define a safe unlink-only operation) and cover both direct and category links, preserving the canonical target.

I reproduced the deletion in a temporary fixture: 194/195 targeted tests passed, with only the added canonical-preservation regression failing; no reported leaks. The probe was retained outside the PR branch. Real skills/configuration were protected by a filesystem sandbox.

Two documentation corrections would also be useful: loadSkill prefers SKILL.toml, then skill.json, then SKILL.md, whereas the new guide omits TOML and calls Markdown preferred; and the top-level skillforge config example is not parsed by Config or scheduled by the runtime (the library module exists, but those settings do not enable automatic discovery). Please label that library capability accurately rather than presenting the JSON as an active configuration recipe.

Leaving this without approval because the removal behavior can cause data loss.

`removeSkill` built `<workspace>/skills/<name>` and called
`deleteTreeAbsolute` on it. When the skill was only reachable through a
symlinked category -- `skills/category -> ../canonical`, with
`canonical/shared/SKILL.md` -- the direct path missed, so removeSkill fell
through to the `listSkills` result and deleted
`skills/category/shared`. That path follows the category link, so the
recursive delete destroyed `canonical/shared`: the copy every other
workspace sharing that repo depends on. A leaf symlink behaved
differently, which is why the direct case was not the reported symptom.

Removal is now split by what the path actually is:

- A symlinked *ancestor* between `skills/` and the target is refused with
  `error.SkillRemovalThroughSymlink`. There is no safe way to delete a
  file under a symlinked category, and silently skipping the request
  would leave the caller thinking it succeeded.
- A *leaf* symlink is unlinked with `deleteFileAbsolute` rather than
  deleted recursively, so the skill leaves this workspace and the
  canonical target survives.
- A plain directory is deleted as before.

The ancestor walk checks one component at a time via directory iteration,
reusing the same entry-kind check this branch already added for
discovery, so a nested category is caught too.

Tests: refusal through a symlinked category with the canonical copy
asserted intact; unlink of a directly symlinked skill with the canonical
copy asserted intact; and a control that a plain local skill still
deletes. The first two skip on Windows, which needs privileges to create
symlinks.

Docs, both languages, per AGENTS.md 7.6:

- loadSkill prefers SKILL.toml, then skill.json, then SKILL.md. The
  guide omitted TOML and called the Markdown form preferred.
- The top-level `skillforge` block is not parsed by Config and nothing
  in the runtime schedules it. It is now labelled a library capability
  rather than an active configuration recipe.
@vernonstinebaker

Copy link
Copy Markdown
Contributor Author

All three findings addressed in 6598c50c. Pushed with a re-review request.

The P1 — deletion through a symlinked category

Your reproduction was correct and I confirmed the mechanism in the current head. removeSkill built <workspace>/skills/<name>; with skills/category -> ../canonical that path misses, so it fell through to the listSkills result and called deleteTreeAbsolute on skills/category/shared — which follows the category link and recursively deletes canonical/shared. The leaf-symlink case behaves differently, exactly as you noted, which is why the direct path wasn't the reported symptom.

Removal is now decided by what the path actually is:

Case Behaviour
Symlinked ancestor between skills/ and target Refused with error.SkillRemovalThroughSymlink
Leaf symlink deleteFileAbsolute — unlinked, target survives
Plain directory deleteTreeAbsolute, unchanged

On the refusal: I chose an explicit error rather than silently skipping. There is no safe way to delete a file under a symlinked category, and returning success would leave the caller believing the skill was removed when it still exists. If you'd rather it be a no-op, that's a one-line change.

The ancestor walk checks one component at a time via directory iteration, reusing the entry-kind check this branch already added for discovery, so nested categories are caught too.

Tests

  • removeSkill refuses removal through a symlinked category — asserts error.SkillRemovalThroughSymlink and that canonical-repo/shared/skill.json survives
  • removeSkill unlinks a directly symlinked skill and keeps the canonical copy — asserts the link is gone from the workspace and the canonical file survives
  • removeSkill still deletes a plain local skill — control, so the guard can't silently disable removal

The first two skip on Windows, which needs privileges to create symlinks.

Documentation, both languages

  • loadSkill precedence — SKILL.toml (preferred) → skill.json (legacy) → SKILL.md (fallback, and the instructions file when no manifest exists). The guide omitted TOML and called the Markdown form preferred.
  • skillforge config block — confirmed it appears in neither config_types.zig nor config_parse.zig, and nothing in the runtime schedules it. Now labelled a library capability that is not read by Config, with a note that wiring it up would be a schema change.

Validation

  • Branch: zig fmt --check src/ exit 0 · zig build -Doptimize=ReleaseSmall exit 0 · zig build test --summary all 13/13 steps, 7382/7391 passed, 9 skipped, 0 failures, 0 leaks
  • Merge result validated separately, since this branch is 96 commits behind main: merged clean, and on that merge result zig fmt exit 0, ReleaseSmall exit 0, zig build test --summary all 13/13 steps, 7494/7503 passed, 9 skipped, 0 failures, 0 leaks. The guard is present in the merge result.

Note

This unblocks #1008, which deliberately deferred its skills page to this PR.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support Skills Symlinks

2 participants