diff --git a/harnesses/claude-code/skills/hivemind-memory/SKILL.md b/harnesses/claude-code/skills/hivemind-memory/SKILL.md index 4396aafc9..56670443f 100644 --- a/harnesses/claude-code/skills/hivemind-memory/SKILL.md +++ b/harnesses/claude-code/skills/hivemind-memory/SKILL.md @@ -57,7 +57,7 @@ Hivemind can mine reusable skills from agent session logs and share them across - `hivemind skillify pull --user ` — only skills authored by that user - `hivemind skillify pull --users ` — multiple authors (CSV) - `hivemind skillify pull --all-users` — explicit "no author filter" (default) -- `hivemind skillify pull --to ` — install location (project=cwd/.claude/skills, global=~/.claude/skills) +- `hivemind skillify pull --to ` — install location (project=cwd/.claude/skills, global=~/.claude/skills, linked for Codex/Hermes/pi) - `hivemind skillify pull --dry-run` — preview without touching disk - `hivemind skillify pull --force` — overwrite local files even if up-to-date (creates .bak) - `hivemind skillify pull ` — pull only that one skill (combines with --user) diff --git a/harnesses/codex/skills/deeplake-memory/SKILL.md b/harnesses/codex/skills/deeplake-memory/SKILL.md index cd13f5630..88910a100 100644 --- a/harnesses/codex/skills/deeplake-memory/SKILL.md +++ b/harnesses/codex/skills/deeplake-memory/SKILL.md @@ -54,7 +54,7 @@ Hivemind can mine reusable skills from agent session logs and share them across - `hivemind skillify pull --user ` — only skills authored by that user - `hivemind skillify pull --users ` — multiple authors (CSV) - `hivemind skillify pull --all-users` — explicit "no author filter" (default) -- `hivemind skillify pull --to ` — install location (project=cwd/.claude/skills, global=~/.claude/skills) +- `hivemind skillify pull --to ` — install location (project=cwd/.claude/skills, global=~/.claude/skills, linked for Codex/Hermes/pi) - `hivemind skillify pull --dry-run` — preview without touching disk - `hivemind skillify pull --force` — overwrite local files even if up-to-date (creates .bak) - `hivemind skillify pull ` — pull only that one skill (combines with --user) diff --git a/harnesses/pi/extension-source/hivemind.ts b/harnesses/pi/extension-source/hivemind.ts index 20594980f..30be0ee2a 100644 --- a/harnesses/pi/extension-source/hivemind.ts +++ b/harnesses/pi/extension-source/hivemind.ts @@ -1341,7 +1341,7 @@ const PI_SKILLIFY_COMMANDS: { cmd: string; desc: string }[] = [ { cmd: "hivemind skillify pull --user ", desc: "only skills authored by that user" }, { cmd: "hivemind skillify pull --users ", desc: "only skills from those authors" }, { cmd: "hivemind skillify pull --all-users", desc: 'explicit "no author filter" (default)' }, - { cmd: "hivemind skillify pull --to ", desc: "install location (project=cwd/.claude/skills, global=~/.claude/skills)" }, + { cmd: "hivemind skillify pull --to ", desc: "install location (project=cwd/.claude/skills, global=~/.claude/skills, linked for Codex/Hermes/pi)" }, { cmd: "hivemind skillify pull --dry-run", desc: "preview without touching disk" }, { cmd: "hivemind skillify pull --force", desc: "overwrite local files even if up-to-date (creates .bak)" }, { cmd: "hivemind skillify pull ", desc: "pull only that one skill (combines with --user)" }, diff --git a/src/cli/skillify-spec.ts b/src/cli/skillify-spec.ts index e16fa0c8c..564f67162 100644 --- a/src/cli/skillify-spec.ts +++ b/src/cli/skillify-spec.ts @@ -42,7 +42,7 @@ export const SKILLIFY_COMMANDS: SkillifyCommand[] = [ { cmd: "hivemind skillify pull --user ", desc: "only skills authored by that user" }, { cmd: "hivemind skillify pull --users ", desc: "only skills from those authors" }, { cmd: "hivemind skillify pull --all-users", desc: 'explicit "no author filter" (default)' }, - { cmd: "hivemind skillify pull --to ", desc: "install location (project=cwd/.claude/skills, global=~/.claude/skills)" }, + { cmd: "hivemind skillify pull --to ", desc: "install location (project=cwd/.claude/skills, global=~/.claude/skills, linked for Codex/Hermes/pi)" }, { cmd: "hivemind skillify pull --dry-run", desc: "preview without touching disk" }, { cmd: "hivemind skillify pull --force", desc: "overwrite local files even if up-to-date (creates .bak)" }, { cmd: "hivemind skillify pull ", desc: "pull only that one skill (combines with --user)" }, @@ -100,7 +100,7 @@ export const SKILLIFY_SPEC: SkillifySubcommand[] = [ { flag: "--user ", desc: "only skills authored by that user" }, { flag: "--users ", desc: "only skills from those authors" }, { flag: "--all-users", desc: 'explicit "no author filter" (default)' }, - { flag: "--to ", desc: "install location (project=cwd/.claude/skills, global=~/.claude/skills)" }, + { flag: "--to ", desc: "install location (project=cwd/.claude/skills, global=~/.claude/skills, linked for Codex/Hermes/pi)" }, { flag: "--dry-run", desc: "preview without touching disk" }, { flag: "--force", desc: "overwrite local files even if up-to-date (creates .bak)" }, { flag: "", desc: "pull only that one skill (combines with --user)" }, diff --git a/src/commands/skillify.ts b/src/commands/skillify.ts index e7f5fc9fe..f75ba777b 100644 --- a/src/commands/skillify.ts +++ b/src/commands/skillify.ts @@ -27,6 +27,7 @@ import { dirname, join } from "node:path"; import { loadScopeConfig, saveScopeConfig, type Scope, type InstallLocation } from "../skillify/scope-config.js"; import { getStateDir } from "../skillify/state-dir.js"; import { runPull, type PullSummary } from "../skillify/pull.js"; +import { describeAgentSkillsRoots } from "../skillify/agent-roots.js"; import { runPush } from "../skillify/push.js"; import { runUnpull } from "../skillify/unpull.js"; import { loadConfig } from "../config.js"; @@ -268,6 +269,16 @@ async function pullSkills(args: string[]): Promise { } } console.log(`Result: ${summary.wrote} written, ${summary.dryrun} dry-run, ${summary.skipped} skipped.`); + if (toRaw === "global") console.log(formatFanOutLine(dest, dryRun)); +} + +// Global pulls also symlink each skill into the skills roots of the other +// installed agents (see fanOutSymlinks). Say so, or users copy by hand. +function formatFanOutLine(dest: string, dryRun: boolean): string { + const label = dryRun ? "Would link:" : "Linked:"; + const roots = describeAgentSkillsRoots(dest); + if (roots.length === 0) return `${label} none (no Codex, Hermes or pi install found)`; + return `${label} ${roots.map(r => `${r.root} (${r.agents.join(", ")})`).join(", ")}`; } async function pushSkills(args: string[]): Promise { diff --git a/src/skillify/agent-roots.ts b/src/skillify/agent-roots.ts index e1efc0edb..ab1ebf591 100644 --- a/src/skillify/agent-roots.ts +++ b/src/skillify/agent-roots.ts @@ -45,27 +45,45 @@ import { join } from "node:path"; * `fanOutSymlinks` upstream calls `mkdirSync(dirname(link), { recursive })` * before each symlink, so the directory is created on first fan-out. */ -function resolveDetected(home: string): string[] { - const out: string[] = []; +function resolveDetected(home: string): AgentSkillsRoot[] { + const out: AgentSkillsRoot[] = []; const codexInstalled = existsSync(join(home, ".codex")); const piInstalled = existsSync(join(home, ".pi", "agent")); const hermesInstalled = existsSync(join(home, ".hermes")); // agentskills.io shared root — codex creates it, pi co-consumes it. if (codexInstalled || piInstalled) { - out.push(join(home, ".agents", "skills")); + const agents = [codexInstalled && "Codex", piInstalled && "pi"].filter(Boolean) as string[]; + out.push({ root: join(home, ".agents", "skills"), agents }); } // Hermes-specific root, agentskills.io-compatible layout. if (hermesInstalled) { - out.push(join(home, ".hermes", "skills")); + out.push({ root: join(home, ".hermes", "skills"), agents: ["Hermes"] }); } // Pi's primary root (pi reads from this AND ~/.agents/skills/). if (piInstalled) { - out.push(join(home, ".pi", "agent", "skills")); + out.push({ root: join(home, ".pi", "agent", "skills"), agents: ["pi"] }); } return out; } +export interface AgentSkillsRoot { + root: string; + /** Display names of the installed agents that read `root`. */ + agents: string[]; +} + +/** + * Same detection as `detectAgentSkillsRoots`, with the agents that read + * each root. Used by the CLI to show where a global pull links skills. + */ +export function describeAgentSkillsRoots( + canonicalRoot: string, + home: string = homedir(), +): AgentSkillsRoot[] { + return resolveDetected(home).filter(r => r.root !== canonicalRoot); +} + /** * Return absolute paths of installed non-Claude agent skill roots. * Filters out the canonical Claude root so we never try to symlink a @@ -80,5 +98,5 @@ export function detectAgentSkillsRoots( canonicalRoot: string, home: string = homedir(), ): string[] { - return resolveDetected(home).filter(p => p !== canonicalRoot); + return describeAgentSkillsRoots(canonicalRoot, home).map(r => r.root); } diff --git a/tests/claude-code/skillify-cli.test.ts b/tests/claude-code/skillify-cli.test.ts index 6ffa86b77..ead613ffc 100644 --- a/tests/claude-code/skillify-cli.test.ts +++ b/tests/claude-code/skillify-cli.test.ts @@ -285,6 +285,52 @@ describe("pull", () => { expect(logged.join("\n")).toMatch(/skill='fake-skill'/); }); + describe("agent fan-out line", () => { + let fanHome: string; + beforeEach(() => { + fanHome = mkdtempSync(join(tmpdir(), "skillify-cli-fanout-home-")); + setFakeHome(fanHome); + }); + afterEach(() => { + clearFakeHome(); + try { rmSync(fanHome, { recursive: true, force: true }); } catch { /* nothing */ } + }); + + it("global --dry-run names the Codex skills root when codex is installed", async () => { + mkdirSync(join(fanHome, ".codex"), { recursive: true }); + runSkillifyCommand(["pull", "--all-users", "--to", "global", "--dry-run"]); + await new Promise(r => setImmediate(r)); + expect(logged.join("\n")).toContain(`Would link: ${join(fanHome, ".agents", "skills")} (Codex)`); + }); + + it("global pull names every detected agent root", async () => { + mkdirSync(join(fanHome, ".codex"), { recursive: true }); + mkdirSync(join(fanHome, ".hermes"), { recursive: true }); + runSkillifyCommand(["pull", "--dry-run"]); + await new Promise(r => setImmediate(r)); + const out = logged.join("\n"); + expect(out).toContain(`${join(fanHome, ".agents", "skills")} (Codex)`); + expect(out).toContain(`${join(fanHome, ".hermes", "skills")} (Hermes)`); + }); + + it("global pull says no other agent was found", async () => { + runSkillifyCommand(["pull", "--dry-run"]); + await new Promise(r => setImmediate(r)); + expect(logged.join("\n")).toMatch(/Would link: none \(no Codex, Hermes or pi install found\)/); + }); + + it("project pull prints no fan-out line", async () => { + mkdirSync(join(fanHome, ".codex"), { recursive: true }); + const dir = mkdtempSync(join(tmpdir(), "skillify-cli-fanout-proj-")); + process.chdir(dir); + runSkillifyCommand(["pull", "--to", "project", "--dry-run"]); + await new Promise(r => setImmediate(r)); + expect(logged.join("\n")).not.toMatch(/^(Linked|Would link):/m); + process.chdir(originalCwd); + rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + }); + }); + // Note: validation errors inside pullSkills (e.g. --to weird, --user // without value) call process.exit() asynchronously inside a fire- // and-forget promise, so they can't be caught from a sync test.