From 4dc197dfb4b69d1aeba6d6b2dafdb011bcd887a0 Mon Sep 17 00:00:00 2001 From: Santhi Prakash Date: Tue, 18 Aug 2026 12:37:47 +0000 Subject: [PATCH] fix(skills): skip mirror fan-out to agents that read the universal store Pi discovers both ~/.pi/agent/skills and the universal ~/.agents/skills globally, so the per-agent mirror copy collided with the universal copy and Pi skipped the universal entry on name conflict (#3294). Skip agents that natively consume the universal store instead of fanning out to them. Fixes #3294 --- packages/cli/src/utils/skillsMirror.test.ts | 24 +++++++++++++++++++++ packages/cli/src/utils/skillsMirror.ts | 22 ++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) 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 }); }