Merged

feat: add ability to customize `paths` in `skills.sh.json`

Take a look2 majorsSecurity asks for a look

  1. ReviewReview, major: Failed skills.sh.json download silently drops declared skillsblob.ts:489–490
  2. ReviewReview, major: GitHub fast path keeps duplicate skill namesblob.ts:648–670
paoloricciutiskills-sh-path-option→main+395 −10 · 5 filesGitHub
What

What

What does this change do?

skills.sh.json adds skill dirs

A repo can now list extra skill folders in the paths field of a root skills.sh.json. Both the clone-and-walk path and the GitHub blob fast path search them like skills/, up to three levels deep.

virpo ran /show-me-pr@prehistoriqafter mergeoutside the team

What changed. parseSkillsShPaths (src/skills-sh-config.ts) reads {"paths": ["./tools/skills"]}. It only accepts entries that start with ./ and stay inside the repo. Entries with backslashes, .. escapes or the repo root are dropped, and invalid JSON is ignored.

  • On disk: discoverSkills adds the folders to its priority list before the deep-container set is built, so they are walked up to 3 levels deep. Plugin-manifest folders stay at depth 1.
  • GitHub fast path: tryBlobInstall downloads skills.sh.json only if GitHub's file listing includes it, then passes the folders to findSkillMdPaths as extra prefixes. A SKILL.md sitting directly in a declared folder is skipped, which matches the disk walk.

Why it matters. Before, a skill outside the standard folders was found only by the fallback search, which runs only when nothing else is found. A repo with skills in skills/ and also in tools/skills/ lost the second set without any message. When names clash, the standard folders still win.

raw.githubusercontent.com
skills.sh.json pathsnew
tryBlobInstall / findSkillMdPathschanged
parseSkillsShPathsnew
discoverSkills (clone or local)changed
Skills offered to install
skills.sh.json paths read parseSkillsShPaths. parseSkillsShPaths extra container dirs discoverSkills (clone or local). raw.githubusercontent.com config, only if listed in the tree tryBlobInstall / findSkillMdPaths. tryBlobInstall / findSkillMdPaths config text parseSkillsShPaths. discoverSkills (clone or local) to Skills offered to install. tryBlobInstall / findSkillMdPaths or null, then clone Skills offered to install
Both discovery paths now read the same declared folders.
  • minorFast path can silently drop declared skills if the config fetch failssrc/blob.tsL480–491ReviewAlso in Review
  • nitPath containment is lexical; symlinked folders are not resolvedsrc/skills-sh-config.tsL34–41

    This is the same gap the existing skills/ folder and the plugin-manifest isContainedIn check already have, so it is not a new exposure.

Files changed
5
Lines
+395 / -10
New tests
16
Review

Review

Is it built right?

2 fast-path bugs, 2 doc gaps

Local discovery of skills.sh.json paths works. The GitHub fast path can silently drop declared skills when the config download fails, and it keeps duplicate skill names. Docs name the wrong config location.

virpo ran /review-anvil-readonly@mrshuClaude Code, Opus 5.5after mergeoutside the team
Major
2
Minor
2
Out of scope
3
Reviewers
2 codex + 1 claude

2 major2 minor

  • majorFailed skills.sh.json download silently drops declared skillssrc/blob.tsL489–490

    fetchSkillsShPaths checks that skills.sh.json is in the tree, then maps a failed raw download (timeout or HTTP error) to []. That is the same result as having no config. findSkillMdPaths still finds the standard skills, so tryBlobInstall succeeds and the caller never falls back to git clone. The install reports success but leaves out every skill in the declared folders. All three reviewers raised this.

    Fix

    When the tree has skills.sh.json but fetchRawContent returns null, return null from tryBlobInstall so the caller uses clone discovery. Keep treating config that downloads but does not parse as an empty paths list. Add a test where the config download fails next to an installable standard skill.

  • majorGitHub fast path keeps duplicate skill namessrc/blob.tsL648–670

    The docs and the new disk test say only the first skill with a given name is kept. tryBlobInstall adds every parsed SKILL.md to parsedSkills without checking names. A declared folder whose skill shares a name with one under skills/ yields two BlobSkills. Both are installed into the same folder. getSkillRepoPaths (src/add.ts:1356-1359) then records the later, declared path in the lock, so updates track the lower-priority copy. All three reviewers raised this.

    Fix

    In tryBlobInstall, drop later parsedSkills entries whose name was already seen, before downloading snapshots, so the first match and its repoPath are kept. Add a blob test that mirrors the existing disk test for standard versus declared duplicate names.

  • minorDocs say repository root, but the config is read from the subpathdocs/creating-skills.mdL142

    Both discoverSkills (searchPath = basePath + subpath) and fetchSkillsShPaths read skills.sh.json from the folder being searched, and resolve ./ entries from there. The plugin manifest lookup works the same way. When a subpath is given, a root skills.sh.json is ignored. No test covers a subpath.

    Fix

    Change the docs to say skills.sh.json is read from the folder being searched (the repository root, or the subpath when one is given), with entries relative to that folder. Consider adding one subpath test for each discovery path.

  • minorAGENTS.md architecture list misses the new filesAGENTS.mdL42

    AGENTS.md lists plugin-manifest.ts and its test, but not src/skills-sh-config.ts or tests/skills-sh-paths.test.ts.

    Fix

    Consider adding both files to the architecture list.

Out of scope (behavior that existed before this change):

  • Symlink containment: a declared container that is a symlink can point outside the repository, and readdir follows it. Two reviewers raised this. The same escape already works through a symlinked skills/ or .claude/skills/ folder, because walkSkillDirs is unchanged and plugin-manifest paths are also checked only lexically. Fixing it means adding one realpath check that covers every container, not just this feature.
  • Raw URL escaping: fetchRawContent does not encode #, ? or % in repository paths, so those skills are skipped on the fast path. This already applied to any skill folder.
  • Case-folded ancestor check: findSkillMdPaths lowercases ancestor directories, so Foo/SKILL.md can hide foo/nested/SKILL.md. This already applied to the standard containers.
1 of 1 passed · 5 skipped
  • Repository rulesNo .review-anvil-rules.md at base 699d35b
  • Reviewers3 of 3 returned structured findings
  • ReproductionNo candidates: both in-scope medium findings came from all 3 reviewers; single-reviewer items were pre-existing and moved out of scope
  • Adversarial checkOff (default for local read-only)
  • Clarity passNot run; local inline report with no report_path
  • Test suiteNot run; no node_modules and read-only mode
Show me

Show me

What does it look like?

skills.sh.json paths now found

Same local fixture at base vs head: base finds only skills/ (1 skill) and fails --skill db-migrate; head reads skills.sh.json paths, finds 3 skills and installs db-migrate.

virpo ran /run@anthropicsClaude Code, Opus 5.5after mergeoutside the team
Skills found (base)
1
Skills found (head)
3+2
--skill db-migrate exit
1 → 0

Fixture (local dir, no network):

skills.sh.json                              {"paths": ["./tools/skills"]}
skills/release-notes/SKILL.md               standard location
tools/skills/db-migrate/SKILL.md            declared path
tools/skills/infra/terraform-plan/SKILL.md  declared path, nested

Base ran from a detached worktree at 699d35b; head from this checkout; both via node src/cli.ts. Install runs used a throwaway HOME and project dir. Blank spacer lines trimmed from output; nothing else differed.

3 of 3 passed · 1 skipped
  • Local discovery (discoverSkills) honors skills.sh.json paths
  • Nested skill under declared container found (depth 2)terraform-plan
  • Install by name from declared pathbase exit 1, head exit 0
  • GitHub blob path (src/blob.ts) via tree APIneeds network; not exercised
Proof

Proof

Does it actually work?

skills.sh.json paths work

Built CLI finds skills in declared paths on disk and on the GitHub blob path (stubbed network); invalid entries are ignored. Test failures match base (environmental).

virpo ran /verify@anthropicsClaude Code, Opus 5.5after mergeoutside the team
PR tests
16/16
Full suite
1029/1039
Failures vs base
same 10
Typecheck
clean
9 of 9 passed · 1 with a warning
  • Docs claim: duplicate names keep firsttrue on disk; blob path lists both (pre-existing)
8 more passed
  • Local add --list finds depth-3 skill under declared ./tools/skillshead 3 skills vs base 2
  • Install declared skills with -ycopied to .claude/skills, lock written
  • Invalid entries ignored (no ./, ../, ./, backslash, /abs, traversal, number)
  • Malformed JSON / paths as string / top-level arrayfalls back to standard skills
  • Blob path (stubbed GitHub) matches disk walk4 skills, one extra raw fetch
  • No skills.sh.json → no extra request
  • Subpath reads <subpath>/skills.sh.json
  • Blob install of depth-3 skilllock skillPath tools/skills/cat/deep/beta/SKILL.md
skills add vercel-labs/fixture --list   # fetch stubbed
[stub] tree HEAD
[stub] raw skills.sh.json
[stub] raw tools/skills/alpha/SKILL.md
[stub] raw tools/skills/cat/deep/beta/SKILL.md
◇  Found 4 skills   (base: Found 2 skills)
exit 0
  • minorDuplicate-name docs claim doesn't hold on GitHub blob pathdocs/creating-skills.mdL151

    Blob path listed 'alpha' twice when skills/ and a declared path both define it; disk walk keeps first. Pre-existing for skills/ duplicates, but paths makes it easier to hit.

  • nitSymlinked declared path is followed outside the reposrc/skills-sh-config.tsSecurityAlso in Security
Undo

Undo

How hard is it to take back?

Revert

Code-only change: skills.sh.json `paths` adds extra skill folders to discovery. No data, migrations or outbound writes; a git revert plus the next npm release undoes it.

virpo ran /undo@prehistoriqClaude Code, Opus 5.5after mergeoutside the team
  1. One click
  2. Revert
  3. Revert and redeploy
  4. Needs a data fix
  5. One-way door

Nothing persisted or sent; only a documented config field that repos may start relying on.

2 minor1 nit

  • minorPublished CLI documents a new paths fielddocs/creating-skills.mdL140–151

    Once released, repo authors may move skills under declared folders. After a revert, add/use/update stop finding them there (files untouched). Users pinned to the release keep the feature until they upgrade.

  • minorLock entries pointing into declared folderssrc/update.tsL720

    Skills installed from a declared folder keep a skillPath there. After a revert, update checks still work via the saved path, but reinstalls re-run discovery (update.ts:720, :991), which won't find them. Those updates fail until reinstalled by hand; nothing is corrupted.

  • nitExtra fetch only when skills.sh.json existssrc/blob.tsL476–491

    Raw fetch happens only if the repo tree lists the file; nothing cached or stored.

Reach: 3 source files changed (blob.ts, skills.ts, new skills-sh-config.ts) plus docs and a test. About 10 files import skills.ts/blob.ts; discoverSkills/tryBlobInstall are called from add.ts (5 sites), use.ts (6) and update.ts (2), so every add, use and update goes through the new lookup.

Security

Security

Does it open a door?

No regressions; 3 low follow-ups

skills.sh.json paths are validated soundly and can't override built-in skills. Three low-severity hardening gaps remain: symlinked folders, unbounded paths lists, and a silent skip on the blob path.

virpo ran /differential-review@trailofbitsClaude Code, Opus 5.5after mergeoutside the team
Files reviewed
5/5
Exploitable
0
Low findings
3
Related tests
69/69 pass

3 minor

  • minorSymlinked declared folder can reach outside the reposrc/skills-sh-config.tsL39–45

    Paths are checked as text only. A repo with tools/skills -> /some/local/dir and paths ['./tools/skills'] makes discovery read that directory (reproduced). Not new: a symlink named skills/ already does the same without any config.

    Fix

    Resolve each search folder with realpath and require it to stay under realpath(searchPath).

  • minorUnbounded paths list with quadratic de-duplicationsrc/skills-sh-config.tsL44

    There's no cap on entries, and result.includes makes de-duplication O(n^2). 50,000 entries (539 KB) took about 3 s to parse; a few MB would stall add/update for minutes.

    Fix

    De-duplicate with a Set and ignore entries after the first ~100.

  • minorBlob path silently drops declared folders when the config fetch failssrc/blob.tsL480–490ReviewAlso in Review
7 of 7 passed · 2 with a warning
  • Symlink containmentExisting weakness, not a regression
  • Tests: symlink, blob+subpath, failed config fetch, huge paths listNot covered
5 more passed
  • Traversal (.., ./a/../.., absolute, backslashes) rejected
  • Windows drive-letter and null-byte entries stay containedWindows checked with path.win32 only, not run on Windows
  • Declared folders can't override built-in skills
  • Raw fetch only runs when the tree lists the exact config path
  • No security code removed (only a function rename)