Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion harnesses/claude-code/skills/hivemind-memory/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Hivemind can mine reusable skills from agent session logs and share them across
- `hivemind skillify pull --user <email>` — only skills authored by that user
- `hivemind skillify pull --users <a,b,c>` — multiple authors (CSV)
- `hivemind skillify pull --all-users` — explicit "no author filter" (default)
- `hivemind skillify pull --to <project|global>` — install location (project=cwd/.claude/skills, global=~/.claude/skills)
- `hivemind skillify pull --to <project|global>` — 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 <skill-name>` — pull only that one skill (combines with --user)
Expand Down
2 changes: 1 addition & 1 deletion harnesses/codex/skills/deeplake-memory/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Hivemind can mine reusable skills from agent session logs and share them across
- `hivemind skillify pull --user <email>` — only skills authored by that user
- `hivemind skillify pull --users <a,b,c>` — multiple authors (CSV)
- `hivemind skillify pull --all-users` — explicit "no author filter" (default)
- `hivemind skillify pull --to <project|global>` — install location (project=cwd/.claude/skills, global=~/.claude/skills)
- `hivemind skillify pull --to <project|global>` — 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 <skill-name>` — pull only that one skill (combines with --user)
Expand Down
2 changes: 1 addition & 1 deletion harnesses/pi/extension-source/hivemind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1341,7 +1341,7 @@ const PI_SKILLIFY_COMMANDS: { cmd: string; desc: string }[] = [
{ cmd: "hivemind skillify pull --user <email>", desc: "only skills authored by that user" },
{ cmd: "hivemind skillify pull --users <a,b,c>", desc: "only skills from those authors" },
{ cmd: "hivemind skillify pull --all-users", desc: 'explicit "no author filter" (default)' },
{ cmd: "hivemind skillify pull --to <project|global>", desc: "install location (project=cwd/.claude/skills, global=~/.claude/skills)" },
{ cmd: "hivemind skillify pull --to <project|global>", 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 <skill-name>", desc: "pull only that one skill (combines with --user)" },
Expand Down
4 changes: 2 additions & 2 deletions src/cli/skillify-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const SKILLIFY_COMMANDS: SkillifyCommand[] = [
{ cmd: "hivemind skillify pull --user <email>", desc: "only skills authored by that user" },
{ cmd: "hivemind skillify pull --users <a,b,c>", desc: "only skills from those authors" },
{ cmd: "hivemind skillify pull --all-users", desc: 'explicit "no author filter" (default)' },
{ cmd: "hivemind skillify pull --to <project|global>", desc: "install location (project=cwd/.claude/skills, global=~/.claude/skills)" },
{ cmd: "hivemind skillify pull --to <project|global>", 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 <skill-name>", desc: "pull only that one skill (combines with --user)" },
Expand Down Expand Up @@ -100,7 +100,7 @@ export const SKILLIFY_SPEC: SkillifySubcommand[] = [
{ flag: "--user <email>", desc: "only skills authored by that user" },
{ flag: "--users <a,b,c>", desc: "only skills from those authors" },
{ flag: "--all-users", desc: 'explicit "no author filter" (default)' },
{ flag: "--to <project|global>", desc: "install location (project=cwd/.claude/skills, global=~/.claude/skills)" },
{ flag: "--to <project|global>", 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: "<skill-name>", desc: "pull only that one skill (combines with --user)" },
Expand Down
11 changes: 11 additions & 0 deletions src/commands/skillify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -268,6 +269,16 @@ async function pullSkills(args: string[]): Promise<void> {
}
}
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<void> {
Expand Down
30 changes: 24 additions & 6 deletions src/skillify/agent-roots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
46 changes: 46 additions & 0 deletions tests/claude-code/skillify-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading