diff --git a/packages/cli/src/utils/skillsMirror.test.ts b/packages/cli/src/utils/skillsMirror.test.ts index b98d02efc7..daf4b1b99e 100644 --- a/packages/cli/src/utils/skillsMirror.test.ts +++ b/packages/cli/src/utils/skillsMirror.test.ts @@ -177,6 +177,30 @@ describe("mirrorGlobalSkills", () => { const link = join(home, ".cursor", "skills", "hyperframes"); expect(realpathSync(link)).toBe(realpathSync(join(home, ".claude", "skills", "hyperframes"))); }); + + // Pi natively discovers BOTH ~/.pi/agent/skills and the universal + // ~/.agents/skills (pi's packages/coding-agent/docs/skills.md#locations). + // A mirrored per-agent copy collides with the universal one and Pi skips + // the universal entry on name conflict (#3294), so the mirror must not fan + // out to it. + it("skips agents that natively read the universal store (pi, #3294)", () => { + const home = makeHome(); + seedStore(home, ["hyperframes"]); + installMarker(home, ".pi/agent"); // Pi present + installMarker(home, ".cursor"); // a regular per-dir agent, for contrast + + const { mirrored } = mirrorGlobalSkills({ + skills: ["hyperframes"], + home, + platform: "linux", + env: ENV, + }); + const agents = mirrored.map((m) => m.agent); + expect(agents).not.toContain("pi"); + expect(agents).toContain("cursor"); + // no per-agent copy created where the universal store already serves Pi + expect(existsSync(join(home, ".pi", "agent", "skills", "hyperframes"))).toBe(false); + }); }); describe("AGENT_GLOBAL_DIRS (generated table)", () => { diff --git a/packages/cli/src/utils/skillsMirror.ts b/packages/cli/src/utils/skillsMirror.ts index e44cef472b..1f3c5927ff 100644 --- a/packages/cli/src/utils/skillsMirror.ts +++ b/packages/cli/src/utils/skillsMirror.ts @@ -9,7 +9,10 @@ // populate. // // So we mirror the canonical Claude store into each of those per-agent dirs, but -// only for agents the machine actually has (their marker dir exists). On Unix +// only for agents the machine actually has (their marker dir exists). Agents +// that already consume the universal ~/.agents/skills store globally (Pi) are +// skipped: their universal copy is authoritative and a per-agent copy would +// collide with it (#3294). On Unix // each skill is a relative symlink back into the store (one source of truth, // near-zero size, auto-fresh on update); on Windows it's a copy, because // symlinks there need admin / Developer Mode and otherwise silently dangle — @@ -24,6 +27,22 @@ import { homedir } from "node:os"; import { dirname, isAbsolute, join, relative } from "node:path"; import { AGENT_GLOBAL_DIRS, type AgentDirBase } from "./agentDirs.generated.js"; +/** + * Agents that natively discover the universal `~/.agents/skills` store globally + * in ADDITION to their own agent-specific directory. Mirroring into their own + * dir makes every skill discoverable twice. + * + * Pi is the known case (earendil-works/pi): it reads both `~/.pi/agent/skills/` + * and `~/.agents/skills/` as global locations (pi's packages/coding-agent/docs/ + * skills.md#locations), so a mirrored entry collides with the universal copy + * and Pi skips the universal one on name conflict (#3294). + * + * The generated table cannot carry this capability — it is a plain + * (agent, base, sub) list synced from vercel-labs/skills — so the set lives + * here next to the mirror logic that needs it. + */ +const UNIVERSAL_STORE_READERS = new Set(["pi"]); + export interface MirrorResult { /** The store mirrored from, or null when no global Claude store was found. */ source: string | null; @@ -128,6 +147,7 @@ export function mirrorGlobalSkills(opts: { for (const { agent, base, sub } of AGENT_GLOBAL_DIRS) { const targetDir = join(bases[base], ...sub.split("/").filter(Boolean)); if (targetDir === source || targetDir === universalStore) continue; // install-owned + if (UNIVERSAL_STORE_READERS.has(agent)) continue; // already reads the universal store (#3294) if (!existsSync(dirname(targetDir))) continue; // agent not installed (no marker) if (mirrorInto(targetDir, source, skills, platform)) mirrored.push({ agent, dir: targetDir }); }