Skip to content

pi-skill-importer finds 0 skills when skill directories are symlinks/junctions (globSync defaults to followSymlinks: false) #583

Description

@pring-nt

Summary

scan_skill_directory() uses globSync('**/SKILL.md', { cwd: dir }). Node's fs.globSync defaults to followSymlinks: false, so skill directories that are symlinks (or Windows junctions) are never entered and their SKILL.md is never found.

@spences10/pi-skills therefore reports 0 managed skills and the /skills UI shows 0 installed • 0 enabled / "No managed skills found", making enable/disable/delete unusable — while upstream pi loads those same skills without complaint.

Environment

  • pi: @earendil-works/pi-coding-agent 0.86.0 (vanilla pi, not the my-pi distribution)
  • @spences10/pi-skills 0.0.36
  • @spences10/pi-skill-importer 0.0.13
  • Node v24.21.0, Windows 11 (also affects Linux/macOS — see below)
  • Skill layout: ~/.pi/agent/skills/<name> are symlinks into a shared source directory (~/.pi/agent/skill-sources/...)

Steps to reproduce

Minimal, self-contained, no elevation required on Windows (uses a junction instead of a symlink):

import {
  mkdtempSync, mkdirSync, writeFileSync, symlinkSync, globSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const root = mkdtempSync(join(tmpdir(), 'skills-repro-'));
const realSource = join(root, 'skill-sources', 'demo');
mkdirSync(realSource, { recursive: true });
writeFileSync(
  join(realSource, 'SKILL.md'),
  '---\nname: demo\ndescription: A demo skill used to reproduce the scanner bug.\n---\n\n# Demo\n',
);

// Common layout: ~/.pi/agent/skills/<name> links to a shared/cached source.
const skillsDir = join(root, 'skills');
mkdirSync(skillsDir, { recursive: true });
symlinkSync(
  realSource,
  join(skillsDir, 'demo'),
  process.platform === 'win32' ? 'junction' : 'dir',
);

console.log('node', process.version);
console.log('globSync("**/SKILL.md", { cwd })                       =>',
  globSync('**/SKILL.md', { cwd: skillsDir }).length, 'match(es)');
console.log('globSync("**/SKILL.md", { cwd, followSymlinks: true }) =>',
  globSync('**/SKILL.md', { cwd: skillsDir, followSymlinks: true }).length, 'match(es)');

Output:

node v24.21.0
globSync("**/SKILL.md", { cwd })                       => 0 match(es)
globSync("**/SKILL.md", { cwd, followSymlinks: true }) => 1 match(es)

End-to-end through the package, with 6 real skills installed as symlinks in ~/.pi/agent/skills/:

getAgentDir()             = C:\Users\<user>\.pi\agent   # correct
scan_managed_skills()     = 0
mgr.discover()            = 0 installed, 0 enabled
get_enabled_skill_paths() = []

Expected

scan_managed_skills() returns the 6 skills, matching both packages/pi-skills/README.md ("discovers Pi-native skills in $PI_CODING_AGENT_DIR/skills") and upstream pi's own behaviour.

Why I believe this is unintended rather than out of scope

  1. No mention of symlink/junction/followSymlinks in the source, docs, or tests of pi-skill-importer or pi-skills.
  2. git log -S followSymlinks --all finds no commit ever adding it, so it was never deliberately removed.
  3. Upstream pi deliberately follows these links — dist/core/skills.js (0.86.0) comments "For symlinks, check if they point to a directory and follow them". Splitting behaviour between loader and management UI is surprising: pi activates the skill, /skills says it does not exist.

Happy to be told this is deliberate — in that case the package READMEs should state that symlinked skill directories are excluded.

Root cause

packages/pi-skill-importer/src/scanner-primitives.ts:71

const matches = globSync('**/SKILL.md', { cwd: dir });

Per the Node docs, followSymlinks — "When true, symbolic links to directories are followed while expanding ** patterns" — defaults to false and was only added in Node v24.16.0.

Suggested fix

-const matches = globSync('**/SKILL.md', { cwd: dir });
+const matches = globSync('**/SKILL.md', {
+	cwd: dir,
+	followSymlinks: true,
+});

Two things to decide:

  1. Node floor. followSymlinks requires Node >= 24.16.0, but both packages declare "engines": { "node": ">=24.15.0" }. Either bump the floor to >=24.16.0, or feature-detect / walk manually (below).
  2. Scope. The neighbouring globSync('*.md', { cwd: dir }) for root Markdown skills appears unaffected, because *.md matches the link itself rather than descending into it. Only the ** descent needs the option. FWIW the project-skill scans share this helper, so symlinked .agents/skills/<name> entries are affected too.

A more compatible alternative is to stop using globSync for descent and follow symlinks explicitly with statSync, mirroring upstream pi's loadSkillsFromDirInternal — same behaviour on every supported Node.

Impact

Any user whose skills are symlinked or junctioned — a normal layout for dotfiles repos, shared skill caches, or ~/.claude/skills linked into ~/.pi/agent/skills — sees "0 installed" and cannot manage skills at all, even though pi loads and uses them.

Locally patched with the one-line followSymlinks: true change and verified: scan_managed_skills() 0 -> 6. I'm carrying that patch manually until this is fixed upstream.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions