diff --git a/README.md b/README.md index 6a242030..a6f6cf45 100644 --- a/README.md +++ b/README.md @@ -456,6 +456,59 @@ filters by your own username, `scope=team` filters by `author IN Config persists at `~/.deeplake/state/skilify/config.json` (one global file shared across projects). +### `pull` / `unpull` — sharing skills across the org + +Once a teammate's skills are mined into the Deeplake `skills` table, you +can install them locally with `pull`. Layout written to disk: + +```text +/--/SKILL.md ← pulled skills (e.g. deploy--alice/) +//SKILL.md ← your locally-mined skills (flat, no suffix) +``` + +The `--` suffix keeps cross-author entries with the same name +disjoint and lets Claude Code's single-depth skill loader find pulled +skills without any symlink trickery. `` is `~/.claude/skills` for +`--to global` and `/.claude/skills` for `--to project`. + +```bash +hivemind skilify pull # all authors, install globally +hivemind skilify pull --user alice@example.com # only this author +hivemind skilify pull --users a@x.com,b@y.com # multiple authors (CSV) +hivemind skilify pull --all-users # explicit "no author filter" (default) +hivemind skilify pull --to project # install under /.claude/skills +hivemind skilify pull --dry-run # preview, no disk writes +hivemind skilify pull --force # overwrite even when local version >= remote +hivemind skilify pull # pull only that skill (combinable with --user) +``` + +Every successful pull records an entry in +`~/.deeplake/state/skilify/pulled.json`. That manifest is the source of +truth for `unpull` — anything not in the manifest is **never** touched +by default, even if its directory follows the `--` shape +(this protects user-authored variant skills like `deploy--blue-green`). + +```bash +hivemind skilify unpull # remove every pulled entry under the install scope +hivemind skilify unpull --user alice@example.com # remove only this author's pulls +hivemind skilify unpull --users a@x.com,b@y.com # multiple authors +hivemind skilify unpull --not-mine # remove all pulls except your own +hivemind skilify unpull --dry-run # preview, no disk writes +hivemind skilify unpull --to project # operate on /.claude/skills instead of global +hivemind skilify unpull --all # ALSO remove flat-layout (locally-mined) skills — destructive +hivemind skilify unpull --legacy-cleanup # ALSO remove pre-`--author`-layout `/` dirs from older skilify versions +``` + +Drift handling: if a manifest entry's directory was deleted out-of-band +(e.g. `rm -rf` by hand), the next `unpull` reports it as `manifest-orphan` +and prunes the entry from the manifest without errors. + +Cross-project caveat: same `(name, author)` from two different projects +collides on disk under the new flat layout — the more recently pulled +row wins, and the prior `SKILL.md` is preserved as `SKILL.md.bak`. The +underlying row stays in the Deeplake `skills` table, so re-pulling from +the other project recovers it. + ### Configuration | Env var | Default | Effect | diff --git a/bundle/cli.js b/bundle/cli.js index d4412d5d..0e994289 100755 --- a/bundle/cli.js +++ b/bundle/cli.js @@ -4710,9 +4710,9 @@ if (process.argv[1] && process.argv[1].endsWith("auth-login.js")) { } // dist/src/commands/skilify.js -import { readdirSync as readdirSync3, existsSync as existsSync15, readFileSync as readFileSync12, mkdirSync as mkdirSync7, renameSync as renameSync2 } from "node:fs"; -import { homedir as homedir8 } from "node:os"; -import { dirname as dirname2, join as join18 } from "node:path"; +import { readdirSync as readdirSync4, existsSync as existsSync17, readFileSync as readFileSync13, mkdirSync as mkdirSync8, renameSync as renameSync3 } from "node:fs"; +import { homedir as homedir10 } from "node:os"; +import { dirname as dirname3, join as join20 } from "node:path"; // dist/src/skilify/scope-config.js import { existsSync as existsSync12, mkdirSync as mkdirSync4, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "node:fs"; @@ -4740,9 +4740,9 @@ function saveScopeConfig(cfg) { } // dist/src/skilify/pull.js -import { existsSync as existsSync14, readFileSync as readFileSync11, writeFileSync as writeFileSync8, mkdirSync as mkdirSync6, renameSync } from "node:fs"; -import { homedir as homedir7 } from "node:os"; -import { join as join17 } from "node:path"; +import { existsSync as existsSync15, readFileSync as readFileSync12, writeFileSync as writeFileSync9, mkdirSync as mkdirSync7, renameSync as renameSync2 } from "node:fs"; +import { homedir as homedir8 } from "node:os"; +import { join as join18 } from "node:path"; // dist/src/skilify/skill-writer.js import { existsSync as existsSync13, mkdirSync as mkdirSync5, readFileSync as readFileSync10, readdirSync as readdirSync2, statSync as statSync2, writeFileSync as writeFileSync7 } from "node:fs"; @@ -4805,7 +4805,99 @@ function parseFrontmatter(text) { return { fm, body }; } +// dist/src/skilify/manifest.js +import { existsSync as existsSync14, mkdirSync as mkdirSync6, readFileSync as readFileSync11, renameSync, writeFileSync as writeFileSync8 } from "node:fs"; +import { homedir as homedir7 } from "node:os"; +import { dirname as dirname2, join as join17 } from "node:path"; +function emptyManifest() { + return { version: 1, entries: [] }; +} +function manifestPath() { + return join17(homedir7(), ".deeplake", "state", "skilify", "pulled.json"); +} +function loadManifest(path = manifestPath()) { + if (!existsSync14(path)) + return emptyManifest(); + let raw; + try { + raw = readFileSync11(path, "utf-8"); + } catch { + return emptyManifest(); + } + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") + return emptyManifest(); + if (parsed.version !== 1 || !Array.isArray(parsed.entries)) + return emptyManifest(); + const entries = []; + for (const e of parsed.entries) { + if (!e || typeof e !== "object") + continue; + if (typeof e.dirName !== "string" || !e.dirName) + continue; + if (e.dirName.includes("/") || e.dirName.includes("\\") || e.dirName.includes("..")) + continue; + if (typeof e.name !== "string" || !e.name) + continue; + if (typeof e.author !== "string") + continue; + if (typeof e.installRoot !== "string" || !e.installRoot) + continue; + if (e.install !== "global" && e.install !== "project") + continue; + entries.push({ + dirName: e.dirName, + name: e.name, + author: e.author, + projectKey: typeof e.projectKey === "string" ? e.projectKey : "", + remoteVersion: typeof e.remoteVersion === "number" ? e.remoteVersion : 1, + install: e.install, + installRoot: e.installRoot, + pulledAt: typeof e.pulledAt === "string" ? e.pulledAt : (/* @__PURE__ */ new Date()).toISOString() + }); + } + return { version: 1, entries }; + } catch { + return emptyManifest(); + } +} +function saveManifest(m, path = manifestPath()) { + mkdirSync6(dirname2(path), { recursive: true }); + const tmp = `${path}.tmp`; + writeFileSync8(tmp, JSON.stringify(m, null, 2) + "\n", { mode: 384 }); + renameSync(tmp, path); +} +function recordPull(entry, path = manifestPath()) { + const m = loadManifest(path); + const idx = m.entries.findIndex((e) => e.install === entry.install && e.installRoot === entry.installRoot && e.dirName === entry.dirName); + if (idx >= 0) + m.entries[idx] = entry; + else + m.entries.push(entry); + saveManifest(m, path); +} +function removePullEntry(install, installRoot, dirName, path = manifestPath()) { + const m = loadManifest(path); + const before = m.entries.length; + m.entries = m.entries.filter((e) => !(e.install === install && e.installRoot === installRoot && e.dirName === dirName)); + if (m.entries.length !== before) + saveManifest(m, path); +} +function entriesForRoot(m, install, installRoot) { + return m.entries.filter((e) => e.install === install && e.installRoot === installRoot); +} + // dist/src/skilify/pull.js +function assertValidAuthor(author) { + if (!author) + throw new Error("author is empty"); + if (author.length > 64) + throw new Error(`author too long (${author.length}): ${author.slice(0, 32)}\u2026`); + if (!/^[A-Za-z0-9_.\-@]+$/.test(author)) { + throw new Error(`author contains invalid characters: ${author}`); + } +} function esc(s) { return s.replace(/\\/g, "\\\\").replace(/'/g, "''").replace(/[\x01-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ""); } @@ -4828,10 +4920,10 @@ function isMissingTableError(message) { } function resolvePullDestination(install, cwd) { if (install === "global") - return join17(homedir7(), ".claude", "skills"); + return join18(homedir8(), ".claude", "skills"); if (!cwd) throw new Error("install=project requires a cwd"); - return join17(cwd, ".claude", "skills"); + return join18(cwd, ".claude", "skills"); } function selectLatestPerName(rows) { const seen = /* @__PURE__ */ new Set(); @@ -4897,10 +4989,10 @@ function renderFrontmatter(fm) { return lines.join("\n"); } function readLocalVersion(path) { - if (!existsSync14(path)) + if (!existsSync15(path)) return null; try { - const text = readFileSync11(path, "utf-8"); + const text = readFileSync12(path, "utf-8"); const parsed = parseFrontmatter(text); if (!parsed) return null; @@ -4953,9 +5045,39 @@ async function runPull(opts) { summary.skipped++; continue; } - const projectKey = String(row.project_key ?? ""); - const skillDir = projectKey ? join17(root, projectKey, name) : join17(root, name); - const skillFile = join17(skillDir, "SKILL.md"); + const author = String(row.author ?? ""); + if (!author) { + summary.entries.push({ + name, + remoteVersion: Number(row.version ?? 1), + localVersion: null, + action: "skipped", + destination: "(empty author \u2014 skipped)", + author: "", + sourceAgent: String(row.source_agent ?? "") + }); + summary.skipped++; + continue; + } + let dirName; + try { + assertValidAuthor(author); + dirName = `${name}--${author}`; + } catch (e) { + summary.entries.push({ + name, + remoteVersion: Number(row.version ?? 1), + localVersion: null, + action: "skipped", + destination: `(invalid author '${author}' \u2014 skipped)`, + author, + sourceAgent: String(row.source_agent ?? "") + }); + summary.skipped++; + continue; + } + const skillDir = join18(root, dirName); + const skillFile = join18(skillDir, "SKILL.md"); const remoteVersion = Number(row.version ?? 1); const localVersion = readLocalVersion(skillFile); const action = decideAction({ @@ -4964,15 +5086,30 @@ async function runPull(opts) { force: opts.force ?? false, dryRun: opts.dryRun ?? false }); + let manifestError; if (action === "wrote") { - mkdirSync6(skillDir, { recursive: true }); - if (existsSync14(skillFile)) { + mkdirSync7(skillDir, { recursive: true }); + if (existsSync15(skillFile)) { try { - renameSync(skillFile, `${skillFile}.bak`); + renameSync2(skillFile, `${skillFile}.bak`); } catch { } } - writeFileSync8(skillFile, renderSkillFile(row)); + writeFileSync9(skillFile, renderSkillFile(row)); + try { + recordPull({ + dirName, + name, + author, + projectKey: String(row.project_key ?? ""), + remoteVersion, + install: opts.install, + installRoot: root, + pulledAt: (/* @__PURE__ */ new Date()).toISOString() + }); + } catch (e) { + manifestError = e?.message ?? String(e); + } } summary.entries.push({ name, @@ -4981,7 +5118,8 @@ async function runPull(opts) { action, destination: skillFile, author: String(row.author ?? ""), - sourceAgent: String(row.source_agent ?? "") + sourceAgent: String(row.source_agent ?? ""), + manifestError }); if (action === "wrote") summary.wrote++; @@ -4993,18 +5131,186 @@ async function runPull(opts) { return summary; } +// dist/src/skilify/unpull.js +import { existsSync as existsSync16, readdirSync as readdirSync3, rmSync as rmSync5, statSync as statSync3 } from "node:fs"; +import { homedir as homedir9 } from "node:os"; +import { join as join19 } from "node:path"; +function resolveUnpullRoot(install, cwd) { + if (install === "global") + return join19(homedir9(), ".claude", "skills"); + if (!cwd) + throw new Error("cwd required when install === 'project'"); + return join19(cwd, ".claude", "skills"); +} +function runUnpull(opts) { + const root = resolveUnpullRoot(opts.install, opts.cwd); + const summary = { + scanned: 0, + removed: 0, + wouldRemove: 0, + kept: 0, + manifestPruned: 0, + entries: [] + }; + const userFilter = new Set(opts.users.filter((u) => u.length > 0)); + const haveUserFilter = userFilter.size > 0; + if ((opts.all || opts.legacyCleanup) && (haveUserFilter || opts.notMine)) { + const flags = [opts.all && "--all", opts.legacyCleanup && "--legacy-cleanup"].filter(Boolean).join(" / "); + const filters = [haveUserFilter && "--user/--users", opts.notMine && "--not-mine"].filter(Boolean).join(" / "); + throw new Error(`${flags} cannot be combined with ${filters}: entries removed by ${flags} are not in the manifest and have no author metadata, so the filter would silently fail to apply. Run the filtered unpull first, then ${flags} as a separate invocation.`); + } + const manifest = loadManifest(); + const entries = entriesForRoot(manifest, opts.install, root); + for (const entry of entries) { + summary.scanned++; + const path = join19(root, entry.dirName); + if (!existsSync16(path)) { + if (!opts.dryRun) + removePullEntry(opts.install, entry.installRoot, entry.dirName); + summary.entries.push({ + dirName: entry.dirName, + kind: "manifest-orphan", + author: entry.author, + name: entry.name, + action: opts.dryRun ? "kept-policy" : "manifest-pruned", + reason: opts.dryRun ? "would-prune (orphan, dir missing)" : "directory was already missing", + path: "" + }); + if (!opts.dryRun) + summary.manifestPruned++; + else + summary.kept++; + continue; + } + const decision = decideTargetForManifestEntry(entry, opts, userFilter, haveUserFilter); + const result = { + dirName: entry.dirName, + kind: "pulled-manifest", + author: entry.author, + name: entry.name, + action: "kept-policy", + path + }; + if (!decision.shouldRemove) { + result.reason = decision.reason; + summary.kept++; + summary.entries.push(result); + continue; + } + if (opts.dryRun) { + result.action = "would-remove"; + summary.wouldRemove++; + } else { + try { + rmSync5(path, { recursive: true, force: true }); + removePullEntry(opts.install, entry.installRoot, entry.dirName); + result.action = "removed"; + summary.removed++; + } catch (e) { + result.action = "kept-policy"; + result.reason = `rm failed: ${e?.message ?? e}`; + summary.kept++; + } + } + summary.entries.push(result); + } + if (existsSync16(root) && (opts.all || opts.legacyCleanup)) { + const manifestDirNames = new Set(entries.map((e) => e.dirName)); + for (const dirName of readdirSync3(root)) { + if (manifestDirNames.has(dirName)) + continue; + const path = join19(root, dirName); + let st; + try { + st = statSync3(path); + } catch { + continue; + } + if (!st.isDirectory()) + continue; + const isLegacyProjectKey = /^[0-9a-f]{16}$/.test(dirName); + const isLocallyMined = !isLegacyProjectKey && /^[A-Za-z0-9_.-]+$/.test(dirName) && !dirName.includes("--"); + let kind; + let shouldRemove = false; + let reason; + if (isLegacyProjectKey) { + kind = "legacy-projectkey"; + if (opts.legacyCleanup) + shouldRemove = true; + else + reason = "legacy project_key dir (use --legacy-cleanup)"; + } else if (isLocallyMined) { + kind = "locally-mined"; + if (opts.all) + shouldRemove = true; + else + reason = "locally-mined (use --all to remove)"; + } else { + continue; + } + summary.scanned++; + const result = { + dirName, + kind, + author: null, + name: kind === "locally-mined" ? dirName : null, + action: "kept-policy", + path, + reason + }; + if (!shouldRemove) { + summary.kept++; + summary.entries.push(result); + continue; + } + if (opts.dryRun) { + result.action = "would-remove"; + summary.wouldRemove++; + } else { + try { + rmSync5(path, { recursive: true, force: true }); + result.action = "removed"; + summary.removed++; + } catch (e) { + result.action = "kept-policy"; + result.reason = `rm failed: ${e?.message ?? e}`; + summary.kept++; + } + } + summary.entries.push(result); + } + } + return summary; +} +function decideTargetForManifestEntry(entry, opts, userFilter, haveUserFilter) { + if (haveUserFilter && !userFilter.has(entry.author)) { + return { shouldRemove: false, reason: `author '${entry.author}' not in filter` }; + } + if (opts.notMine) { + if (!opts.myUsername) + return { shouldRemove: false, reason: "--not-mine requires myUsername" }; + if (entry.author === opts.myUsername) { + return { shouldRemove: false, reason: "your own pull (--not-mine excludes self)" }; + } + } + return { shouldRemove: true }; +} + // dist/src/commands/skilify.js -var STATE_DIR2 = join18(homedir8(), ".deeplake", "state", "skilify"); +function stateDir() { + return join20(homedir10(), ".deeplake", "state", "skilify"); +} function showStatus() { const cfg = loadScopeConfig(); console.log(`scope: ${cfg.scope}`); console.log(`team: ${cfg.team.length === 0 ? "(empty)" : cfg.team.join(", ")}`); console.log(`install: ${cfg.install} (${cfg.install === "global" ? "~/.claude/skills/" : "/.claude/skills/"})`); - if (!existsSync15(STATE_DIR2)) { + const dir = stateDir(); + if (!existsSync17(dir)) { console.log(`state: (no projects tracked yet)`); return; } - const files = readdirSync3(STATE_DIR2).filter((f) => f.endsWith(".json") && f !== "config.json"); + const files = readdirSync4(dir).filter((f) => f.endsWith(".json") && f !== "config.json" && f !== "pulled.json"); if (files.length === 0) { console.log(`state: (no projects tracked yet)`); return; @@ -5012,7 +5318,7 @@ function showStatus() { console.log(`state: ${files.length} project(s) tracked`); for (const f of files) { try { - const s = JSON.parse(readFileSync12(join18(STATE_DIR2, f), "utf-8")); + const s = JSON.parse(readFileSync13(join20(dir, f), "utf-8")); const skills = s.skillsGenerated.length === 0 ? "none" : s.skillsGenerated.join(", "); console.log(` - ${s.project} (counter=${s.counter}, last=${s.lastDate ?? "never"}, skills=${skills})`); } catch { @@ -5038,7 +5344,7 @@ function setInstall(loc) { } const cfg = loadScopeConfig(); saveScopeConfig({ ...cfg, install: loc }); - const path = loc === "global" ? join18(homedir8(), ".claude", "skills") : "/.claude/skills"; + const path = loc === "global" ? join20(homedir10(), ".claude", "skills") : "/.claude/skills"; console.log(`Install location set to '${loc}'. New skills will be written to ${path}//SKILL.md.`); } function promoteSkill(name, cwd) { @@ -5046,18 +5352,18 @@ function promoteSkill(name, cwd) { console.error("Usage: hivemind skilify promote "); process.exit(1); } - const projectPath = join18(cwd, ".claude", "skills", name); - const globalPath = join18(homedir8(), ".claude", "skills", name); - if (!existsSync15(join18(projectPath, "SKILL.md"))) { + const projectPath = join20(cwd, ".claude", "skills", name); + const globalPath = join20(homedir10(), ".claude", "skills", name); + if (!existsSync17(join20(projectPath, "SKILL.md"))) { console.error(`Skill '${name}' not found at ${projectPath}/SKILL.md`); process.exit(1); } - if (existsSync15(join18(globalPath, "SKILL.md"))) { + if (existsSync17(join20(globalPath, "SKILL.md"))) { console.error(`Skill '${name}' already exists at ${globalPath}/SKILL.md \u2014 refusing to overwrite. Remove it first or rename the project skill.`); process.exit(1); } - mkdirSync7(dirname2(globalPath), { recursive: true }); - renameSync2(projectPath, globalPath); + mkdirSync8(dirname3(globalPath), { recursive: true }); + renameSync3(projectPath, globalPath); console.log(`Promoted '${name}' from ${projectPath} \u2192 ${globalPath}.`); } function teamAdd(name) { @@ -5114,6 +5420,15 @@ function usage() { console.log(" --all-users all authors (default \u2014 equivalent to no filter)"); console.log(" --dry-run show what would be written, don't touch disk"); console.log(" --force overwrite even when local version >= remote"); + console.log(" hivemind skilify unpull [opts] remove skills previously installed by pull"); + console.log(" Options for unpull:"); + console.log(" --to where to scan (default: global)"); + console.log(" --user only entries authored by this user"); + console.log(" --users only entries authored by these users"); + console.log(" --not-mine remove all pulled entries except your own"); + console.log(" --dry-run show what would be removed"); + console.log(" --all also remove flat-layout (locally-mined) entries"); + console.log(" --legacy-cleanup also remove pre-`--author`-layout legacy `/` dirs"); console.log(" hivemind skilify status show per-project state"); } function takeFlagValue(args, flag) { @@ -5178,7 +5493,7 @@ async function pullSkills(args) { console.error(`pull failed: ${e?.message ?? e}`); process.exit(1); } - const dest = toRaw === "global" ? join18(homedir8(), ".claude", "skills") : `${process.cwd()}/.claude/skills`; + const dest = toRaw === "global" ? join20(homedir10(), ".claude", "skills") : `${process.cwd()}/.claude/skills`; const filterDesc = users.length === 0 ? "all users" : users.join(", "); console.log(`Destination: ${dest}`); console.log(`Filter: ${filterDesc}${skillName ? ` \xB7 skill='${skillName}'` : ""}${dryRun ? " \xB7 dry-run" : ""}${force ? " \xB7 force" : ""}`); @@ -5187,9 +5502,72 @@ async function pullSkills(args) { const tag = e.action === "wrote" ? "\u2713 wrote" : e.action === "dryrun" ? "\u2192 would write" : "\xB7 skipped"; const ver = e.localVersion === null ? `v${e.remoteVersion} (new)` : `v${e.localVersion} \u2192 v${e.remoteVersion}`; console.log(` ${tag.padEnd(15)} ${e.name.padEnd(40)} ${ver.padEnd(20)} (${e.author}/${e.sourceAgent})`); + if (e.manifestError) { + console.warn(` \u26A0 manifest not updated: ${e.manifestError} \u2014 \`unpull\` will not see this entry until a successful repull.`); + } } console.log(`Result: ${summary.wrote} written, ${summary.dryrun} dry-run, ${summary.skipped} skipped.`); } +async function unpullSkills(args) { + const work = [...args]; + const toRaw = takeFlagValue(work, "--to") ?? "global"; + const userOne = takeFlagValue(work, "--user"); + const usersMany = takeFlagValue(work, "--users"); + const notMine = takeBooleanFlag(work, "--not-mine"); + const dryRun = takeBooleanFlag(work, "--dry-run"); + const all = takeBooleanFlag(work, "--all"); + const legacyCleanup = takeBooleanFlag(work, "--legacy-cleanup"); + if (toRaw !== "project" && toRaw !== "global") { + throw new Error(`Invalid --to '${toRaw}'. Use 'project' or 'global'.`); + } + let users = []; + if (userOne) + users = [userOne]; + else if (usersMany) + users = usersMany.split(",").map((s) => s.trim()).filter(Boolean); + let myUsername; + if (notMine) { + const config = loadConfig(); + if (!config) { + throw new Error("--not-mine requires a logged-in user. Run: hivemind login"); + } + myUsername = config.userName; + } + const summary = runUnpull({ + install: toRaw, + cwd: toRaw === "project" ? process.cwd() : void 0, + users, + myUsername, + notMine, + dryRun, + all, + legacyCleanup + }); + const dest = toRaw === "global" ? join20(homedir10(), ".claude", "skills") : `${process.cwd()}/.claude/skills`; + const filterParts = []; + if (users.length > 0) + filterParts.push(`users=${users.join(",")}`); + if (notMine) + filterParts.push("not-mine"); + if (all) + filterParts.push("all"); + if (legacyCleanup) + filterParts.push("legacy-cleanup"); + if (dryRun) + filterParts.push("dry-run"); + const filterDesc = filterParts.length ? filterParts.join(" \xB7 ") : "(no filter \u2014 all pulled)"; + console.log(`Scanning: ${dest}`); + console.log(`Filter: ${filterDesc}`); + console.log(`Scanned ${summary.scanned} dir(s).`); + for (const e of summary.entries) { + const tag = e.action === "removed" ? "\u2713 removed" : e.action === "would-remove" ? "\u2192 would remove" : e.action === "manifest-pruned" ? "\u26A0 pruned (orphan)" : "\xB7 kept"; + const id = e.dirName; + const note = e.reason ? ` (${e.reason})` : ""; + console.log(` ${tag.padEnd(20)} ${id.padEnd(50)} [${e.kind}]${note}`); + } + const prunedNote = summary.manifestPruned > 0 ? `, ${summary.manifestPruned} manifest-pruned` : ""; + console.log(`Result: ${summary.removed} removed, ${summary.wouldRemove} dry-run, ${summary.kept} kept${prunedNote}.`); +} function runSkilifyCommand(args) { const sub = args[0]; if (!sub || sub === "status") { @@ -5215,6 +5593,14 @@ function runSkilifyCommand(args) { }); return; } + if (sub === "unpull") { + unpullSkills(args.slice(1)).catch((e) => { + console.error(`unpull error: ${e?.message ?? e}`); + process.exit(1); + }).catch(() => { + }); + return; + } if (sub === "team") { const action = args[1]; if (action === "add") { @@ -5246,13 +5632,13 @@ if (process.argv[1] && process.argv[1].endsWith("skilify.js")) { // dist/src/cli/update.js import { execFileSync as execFileSync4 } from "node:child_process"; -import { existsSync as existsSync16, readFileSync as readFileSync14, realpathSync } from "node:fs"; -import { dirname as dirname4, sep } from "node:path"; +import { existsSync as existsSync18, readFileSync as readFileSync15, realpathSync } from "node:fs"; +import { dirname as dirname5, sep } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; // dist/src/utils/version-check.js -import { readFileSync as readFileSync13 } from "node:fs"; -import { dirname as dirname3, join as join19 } from "node:path"; +import { readFileSync as readFileSync14 } from "node:fs"; +import { dirname as dirname4, join as join21 } from "node:path"; function isNewer(latest, current) { const parse = (v) => v.split(".").map(Number); const [la, lb, lc] = parse(latest); @@ -5271,24 +5657,24 @@ function detectInstallKind(argv1) { return argv1 ?? process.argv[1] ?? fileURLToPath2(import.meta.url); } })(); - let dir = dirname4(realArgv1); + let dir = dirname5(realArgv1); let installDir = null; for (let i = 0; i < 10; i++) { const pkgPath = `${dir}${sep}package.json`; try { - const pkg = JSON.parse(readFileSync14(pkgPath, "utf-8")); + const pkg = JSON.parse(readFileSync15(pkgPath, "utf-8")); if (pkg.name === PKG_NAME || pkg.name === "hivemind") { installDir = dir; break; } } catch { } - const parent = dirname4(dir); + const parent = dirname5(dir); if (parent === dir) break; dir = parent; } - installDir ??= dirname4(realArgv1); + installDir ??= dirname5(realArgv1); if (realArgv1.includes(`${sep}_npx${sep}`) || realArgv1.includes(`${sep}.npx${sep}`)) { return { kind: "npx", installDir }; } @@ -5297,10 +5683,10 @@ function detectInstallKind(argv1) { } let gitDir = installDir; for (let i = 0; i < 6; i++) { - if (existsSync16(`${gitDir}${sep}.git`)) { + if (existsSync18(`${gitDir}${sep}.git`)) { return { kind: "local-dev", installDir }; } - const parent = dirname4(gitDir); + const parent = dirname5(gitDir); if (parent === gitDir) break; gitDir = parent; @@ -5459,6 +5845,11 @@ Skill management (mine + share reusable Claude skills across the org): Options: --user , --users a,b,c, --all-users, --to , --dry-run, --force. + hivemind skilify unpull Remove skills previously installed by pull. + Options: --user, --users, --not-mine, + --to , --dry-run, + --all (also locally-mined), + --legacy-cleanup (pre-suffix-author dirs). hivemind skilify scope Set the sharing scope for newly mined skills. hivemind skilify install Set where new skills are written. hivemind skilify promote Move a project skill to the global location. diff --git a/claude-code/bundle/session-start.js b/claude-code/bundle/session-start.js index cf53c7bb..c6047031 100755 --- a/claude-code/bundle/session-start.js +++ b/claude-code/bundle/session-start.js @@ -741,6 +741,10 @@ Skill management (mine + share reusable Claude skills across the org): - hivemind skilify pull --dry-run \u2014 preview without touching disk - hivemind skilify pull --force \u2014 overwrite local files even if up-to-date (creates .bak) - hivemind skilify pull \u2014 pull only that one skill (combines with --user) +- hivemind skilify unpull \u2014 remove every skill previously installed by pull +- hivemind skilify unpull --user \u2014 remove only that author's pulls +- hivemind skilify unpull --not-mine \u2014 remove all pulls except your own +- hivemind skilify unpull --dry-run \u2014 preview without touching disk - hivemind skilify scope \u2014 sharing scope for newly mined skills - hivemind skilify install \u2014 default install location for new skills - hivemind skilify promote \u2014 move a project skill to the global location diff --git a/claude-code/tests/skilify-cli.test.ts b/claude-code/tests/skilify-cli.test.ts index 9751ecb0..31008a3d 100644 --- a/claude-code/tests/skilify-cli.test.ts +++ b/claude-code/tests/skilify-cli.test.ts @@ -5,13 +5,10 @@ import { join } from "node:path"; // Mock the loadConfig + DeeplakeApi so the pull subcommand can run without // hitting the network. The mock returns a fake row from the skills table. +// loadConfig is a vi.fn so individual tests can swap in null (unauthenticated) +// to exercise the unpull login-gating path. vi.mock("../../src/config.js", () => ({ - loadConfig: () => ({ - token: "tok", apiUrl: "x", orgId: "org", workspaceId: "ws", - userName: "tester", skillsTableName: "skills", - tableName: "memory", sessionsTableName: "sessions", memoryPath: "/m", - orgName: "org", - }), + loadConfig: vi.fn(), })); vi.mock("../../src/deeplake-api.js", () => ({ DeeplakeApi: class { @@ -28,6 +25,15 @@ vi.mock("../../src/deeplake-api.js", () => ({ })); import { runSkilifyCommand } from "../../src/commands/skilify.js"; +import { loadConfig } from "../../src/config.js"; +const loadConfigMock = loadConfig as unknown as ReturnType; + +const VALID_CONFIG = { + token: "tok", apiUrl: "x", orgId: "org", workspaceId: "ws", + userName: "tester", skillsTableName: "skills", + tableName: "memory", sessionsTableName: "sessions", memoryPath: "/m", + orgName: "org", +}; const STATE_DIR = join(homedir(), ".deeplake", "state", "skilify"); const CONFIG_PATH = join(STATE_DIR, "config.json"); @@ -45,6 +51,11 @@ beforeEach(() => { else configBackup = null; try { rmSync(CONFIG_PATH); } catch { /* nothing */ } logged = []; erred = []; + // Default: logged in. Individual tests can `loadConfigMock.mockReturnValueOnce(null)` + // to exercise the unauthenticated path of unpull (no login needed) vs --not-mine + // (which still requires myUsername). + loadConfigMock.mockReset(); + loadConfigMock.mockReturnValue(VALID_CONFIG); logSpy = vi.spyOn(console, "log").mockImplementation((...args: any[]) => { logged.push(args.join(" ")); }); errSpy = vi.spyOn(console, "error").mockImplementation((...args: any[]) => { erred.push(args.join(" ")); }); exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`__EXIT_${code ?? 0}__`); }) as any); @@ -79,6 +90,30 @@ describe("status (default subcommand)", () => { runSkilifyCommand(["status"]); expect(logged.join("\n")).toMatch(/scope:/); }); + + it("does NOT count config.json or pulled.json as tracked projects", () => { + // Both files live in the same STATE_DIR but are skilify's own bookkeeping; + // counting them would inflate "N project(s) tracked" and the parse loop + // below would JSON.parse the wrong shape and silently swallow the error. + const stateHome = mkdtempSync(join(tmpdir(), "skilify-cli-status-")); + const prevHome = process.env.HOME; + process.env.HOME = stateHome; + try { + const stateDir = join(stateHome, ".deeplake", "state", "skilify"); + mkdirSync(stateDir, { recursive: true }); + writeFileSync(join(stateDir, "config.json"), JSON.stringify({ scope: "me", team: [], install: "global" })); + writeFileSync(join(stateDir, "pulled.json"), JSON.stringify({ version: 1, entries: [] })); + logged = []; + runSkilifyCommand([]); + const out = logged.join("\n"); + expect(out).toMatch(/state: \(no projects tracked yet\)/); + expect(out).not.toMatch(/project\(s\) tracked/); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(stateHome, { recursive: true, force: true }); + } + }); }); // ── scope ───────────────────────────────────────────────────────────────── @@ -245,6 +280,154 @@ describe("pull", () => { // (buildPullSql, resolvePullDestination). }); +// ── unpull ──────────────────────────────────────────────────────────────── + +describe("unpull", () => { + // Each test runs under a fresh HOME so the manifest writes by + // pull/unpull don't pollute the developer's real ~/.deeplake state. + let unpullHome: string; + let originalHome: string | undefined; + beforeEach(() => { + unpullHome = mkdtempSync(join(tmpdir(), "skilify-cli-unpull-home-")); + originalHome = process.env.HOME; + process.env.HOME = unpullHome; + }); + afterEach(() => { + try { rmSync(unpullHome, { recursive: true, force: true }); } catch { /* nothing */ } + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + }); + + it("--dry-run on empty manifest reports zero work", () => { + runSkilifyCommand(["unpull", "--dry-run"]); + const out = logged.join("\n"); + expect(out).toMatch(/Scanning:/); + expect(out).toMatch(/Filter:\s+dry-run/); + expect(out).toMatch(/Result: 0 removed, 0 dry-run, 0 kept\./); + }); + + it("default filter description is 'no filter — all pulled'", () => { + runSkilifyCommand(["unpull"]); + expect(logged.join("\n")).toMatch(/Filter:\s+\(no filter — all pulled\)/); + }); + + it("composes manifest-only filter flags into the filter description", () => { + // --all and --legacy-cleanup are mutually exclusive with --user/--users + // /--not-mine (see filter+all conflict guard), so the manifest-only + // path is the right surface to assert flag composition on. + runSkilifyCommand(["unpull", "--user", "alice", "--not-mine", "--dry-run"]); + const out = logged.join("\n"); + expect(out).toMatch(/users=alice/); + expect(out).toMatch(/not-mine/); + expect(out).toMatch(/dry-run/); + }); + + it("composes disk-walk flags into the filter description", () => { + runSkilifyCommand(["unpull", "--all", "--legacy-cleanup", "--dry-run"]); + const out = logged.join("\n"); + expect(out).toMatch(/all/); + expect(out).toMatch(/legacy-cleanup/); + expect(out).toMatch(/dry-run/); + }); + + it("--users a,b,c parses CSV into the filter", () => { + runSkilifyCommand(["unpull", "--users", "alice,bob,carol", "--dry-run"]); + expect(logged.join("\n")).toMatch(/users=alice,bob,carol/); + }); + + it("--to project scopes the scanning root to cwd", () => { + const dir = mkdtempSync(join(tmpdir(), "skilify-cli-unpull-proj-")); + process.chdir(dir); + runSkilifyCommand(["unpull", "--to", "project", "--dry-run"]); + expect(logged.join("\n")).toMatch(new RegExp(`Scanning:\\s+${dir}/.claude/skills`)); + rmSync(dir, { recursive: true, force: true }); + }); + + it("--to with invalid value reports error", async () => { + // unpullSkills throws on bad input; the dispatcher's `.catch` logs + // the message via console.error and exits 1. + runSkilifyCommand(["unpull", "--to", "weird"]); + await new Promise(r => setImmediate(r)); + expect(erred.join("\n")).toMatch(/Invalid --to/); + }); + + it("integrates with pull: round-trip clears manifest + disk", async () => { + // 1. pull populates manifest + disk + runSkilifyCommand(["pull", "--user", "alice", "--to", "global"]); + await new Promise(r => setImmediate(r)); + const out1 = logged.join("\n"); + expect(out1).toMatch(/1 written/); + logged = []; + + // 2. unpull clears it + runSkilifyCommand(["unpull", "--user", "alice"]); + const out2 = logged.join("\n"); + expect(out2).toMatch(/1 removed/); + expect(out2).toMatch(/fake-skill--alice/); + + // 3. re-running unpull is idempotent (no entries, no errors) + logged = []; + runSkilifyCommand(["unpull"]); + expect(logged.join("\n")).toMatch(/Scanned 0 dir\(s\)/); + }); + + it("emits 'manifest-pruned' tag when an entry's directory is missing on disk", async () => { + // pull installs a skill, then we delete its dir out-of-band so the + // manifest entry becomes an orphan + runSkilifyCommand(["pull", "--user", "alice", "--to", "global"]); + await new Promise(r => setImmediate(r)); + rmSync(join(unpullHome, ".claude", "skills"), { recursive: true, force: true }); + logged = []; + + runSkilifyCommand(["unpull"]); + const out = logged.join("\n"); + expect(out).toMatch(/pruned \(orphan\)/); + expect(out).toMatch(/manifest-pruned/); + }); + + // ── login gating ────────────────────────────────────────────────────────── + // Unpull is a local FS-only operation in the default path; only --not-mine + // needs a username to compare against. Don't force the user back through + // `hivemind login` just to clean up disk state when their cred is gone. + + it("default unpull works when not logged in (no Deeplake call required)", async () => { + loadConfigMock.mockReturnValue(null); + runSkilifyCommand(["unpull", "--dry-run"]); + await new Promise(r => setImmediate(r)); + expect(erred.join("\n")).not.toMatch(/login/i); + expect(logged.join("\n")).toMatch(/Result: 0 removed/); + }); + + it("--user X works when not logged in (filter is local, not a server query)", async () => { + loadConfigMock.mockReturnValue(null); + runSkilifyCommand(["unpull", "--user", "alice", "--dry-run"]); + await new Promise(r => setImmediate(r)); + expect(erred.join("\n")).not.toMatch(/login/i); + expect(logged.join("\n")).toMatch(/users=alice/); + }); + + it("--not-mine still requires login (needs myUsername to exclude self)", async () => { + loadConfigMock.mockReturnValue(null); + runSkilifyCommand(["unpull", "--not-mine", "--dry-run"]); + await new Promise(r => setImmediate(r)); + expect(erred.join("\n")).toMatch(/--not-mine requires a logged-in user/); + }); + + // ── filter+all conflict surfacing ───────────────────────────────────────── + + it("--all combined with --user surfaces a clear error message", async () => { + runSkilifyCommand(["unpull", "--all", "--user", "alice"]); + await new Promise(r => setImmediate(r)); + expect(erred.join("\n")).toMatch(/--all.*--user/); + }); + + it("--legacy-cleanup combined with --not-mine surfaces a clear error message", async () => { + runSkilifyCommand(["unpull", "--legacy-cleanup", "--not-mine"]); + await new Promise(r => setImmediate(r)); + expect(erred.join("\n")).toMatch(/--legacy-cleanup.*--not-mine/); + }); +}); + // ── usage / unknown ─────────────────────────────────────────────────────── describe("usage", () => { diff --git a/claude-code/tests/skilify-manifest.test.ts b/claude-code/tests/skilify-manifest.test.ts new file mode 100644 index 00000000..75297f6d --- /dev/null +++ b/claude-code/tests/skilify-manifest.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + loadManifest, + saveManifest, + recordPull, + removePullEntry, + entriesForRoot, + manifestPath, + type PulledEntry, +} from "../../src/skilify/manifest.js"; + +let fakeHome: string; +let originalHome: string | undefined; + +beforeEach(() => { + fakeHome = mkdtempSync(join(tmpdir(), "skilify-manifest-")); + originalHome = process.env.HOME; + process.env.HOME = fakeHome; +}); + +afterEach(() => { + try { rmSync(fakeHome, { recursive: true, force: true }); } catch { /* nothing */ } + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; +}); + +const sampleEntry = (over: Partial = {}): PulledEntry => ({ + dirName: "deploy--alice", + name: "deploy", + author: "alice", + projectKey: "abcd1234abcd1234", + remoteVersion: 1, + install: "global", + installRoot: "/home/test/.claude/skills", + pulledAt: "2026-05-07T00:00:00.000Z", + ...over, +}); + +describe("manifestPath", () => { + it("resolves to ~/.deeplake/state/skilify/pulled.json under HOME", () => { + expect(manifestPath()).toBe(join(fakeHome, ".deeplake", "state", "skilify", "pulled.json")); + }); +}); + +describe("loadManifest", () => { + it("returns empty manifest when file missing", () => { + expect(loadManifest()).toEqual({ version: 1, entries: [] }); + }); + + it("parses a well-formed manifest", () => { + const m = { version: 1 as const, entries: [sampleEntry()] }; + saveManifest(m); + expect(loadManifest()).toEqual(m); + }); + + it("treats unparseable JSON as empty (fail-safe)", () => { + const path = manifestPath(); + saveManifest({ version: 1, entries: [] }); // ensure parent dir exists + writeFileSync(path, "not valid json {{{"); + expect(loadManifest()).toEqual({ version: 1, entries: [] }); + }); + + it("treats wrong-version manifests as empty", () => { + saveManifest({ version: 1, entries: [] }); + writeFileSync(manifestPath(), JSON.stringify({ version: 2, entries: [sampleEntry()] })); + expect(loadManifest()).toEqual({ version: 1, entries: [] }); + }); + + it("drops malformed entries while keeping good ones", () => { + saveManifest({ version: 1, entries: [] }); + writeFileSync(manifestPath(), JSON.stringify({ + version: 1, + entries: [ + sampleEntry({ dirName: "good--alice" }), + { dirName: "" }, // empty dirName — drop + { dirName: "bad-no-name", name: "", author: "x", install: "global", installRoot: "/x" }, // empty name + { dirName: "bad-bad-install", name: "x", author: "y", install: "weird", installRoot: "/x" }, // wrong install enum + sampleEntry({ dirName: "good2--bob" }), + ], + })); + const m = loadManifest(); + expect(m.entries.map(e => e.dirName)).toEqual(["good--alice", "good2--bob"]); + }); + + it("rejects entries whose dirName contains path separators or `..`", () => { + // A corrupted (or hand-edited) manifest could otherwise convince + // unpull's `rmSync(join(installRoot, dirName))` to delete data outside + // the install root. The pull writer never produces a path-segmented + // dirName, so dropping these entries is safe. + saveManifest({ version: 1, entries: [] }); + writeFileSync(manifestPath(), JSON.stringify({ + version: 1, + entries: [ + sampleEntry({ dirName: "good--alice" }), + sampleEntry({ dirName: "../escape" }), + sampleEntry({ dirName: "..\\escape" }), + sampleEntry({ dirName: "evil/../../../etc" }), + sampleEntry({ dirName: "with/slash" }), + sampleEntry({ dirName: "with\\backslash" }), + sampleEntry({ dirName: "another--bob" }), + ], + })); + const m = loadManifest(); + expect(m.entries.map(e => e.dirName)).toEqual(["good--alice", "another--bob"]); + }); +}); + +describe("saveManifest", () => { + it("writes JSON with trailing newline + 0o600 perms via atomic rename", () => { + saveManifest({ version: 1, entries: [sampleEntry()] }); + const path = manifestPath(); + expect(existsSync(path)).toBe(true); + const raw = readFileSync(path, "utf-8"); + expect(raw.endsWith("\n")).toBe(true); + const parsed = JSON.parse(raw); + expect(parsed.entries[0].dirName).toBe("deploy--alice"); + // No leftover .tmp file from the atomic rename + expect(existsSync(`${path}.tmp`)).toBe(false); + // Permissions hardened (file contains the install root path which leaks + // some local layout info; not secret but tightens the default). + const mode = statSync(path).mode & 0o777; + expect(mode & 0o077).toBe(0); // no group/other perms + }); + + it("creates parent directories on first write", () => { + expect(existsSync(join(fakeHome, ".deeplake"))).toBe(false); + saveManifest({ version: 1, entries: [] }); + expect(existsSync(join(fakeHome, ".deeplake", "state", "skilify"))).toBe(true); + }); +}); + +describe("recordPull", () => { + it("appends a new entry when none exists", () => { + recordPull(sampleEntry()); + expect(loadManifest().entries).toHaveLength(1); + }); + + it("replaces an existing entry on the same (install, installRoot, dirName)", () => { + recordPull(sampleEntry({ remoteVersion: 1, pulledAt: "2026-01-01T00:00:00Z" })); + recordPull(sampleEntry({ remoteVersion: 2, pulledAt: "2026-05-01T00:00:00Z" })); + const m = loadManifest(); + expect(m.entries).toHaveLength(1); + expect(m.entries[0].remoteVersion).toBe(2); + expect(m.entries[0].pulledAt).toBe("2026-05-01T00:00:00Z"); + }); + + it("keeps cross-install entries separate (global vs project, same dirName)", () => { + recordPull(sampleEntry({ install: "global", installRoot: "/g" })); + recordPull(sampleEntry({ install: "project", installRoot: "/p" })); + const m = loadManifest(); + expect(m.entries).toHaveLength(2); + expect(m.entries.map(e => e.install).sort()).toEqual(["global", "project"]); + }); + + it("keeps cross-installRoot entries separate within the same install kind", () => { + // Two `project` pulls of the same skill into two different cwds must + // produce TWO manifest rows. Without installRoot in the key, the + // second pull would silently overwrite the first row and the first + // project's dir would become a manifest orphan that unpull can't see. + recordPull(sampleEntry({ install: "project", installRoot: "/projA/.claude/skills" })); + recordPull(sampleEntry({ install: "project", installRoot: "/projB/.claude/skills" })); + const m = loadManifest(); + expect(m.entries).toHaveLength(2); + expect(m.entries.map(e => e.installRoot).sort()).toEqual([ + "/projA/.claude/skills", + "/projB/.claude/skills", + ]); + }); + + it("upserts within the same (install, installRoot, dirName) — both projects keep their own latest version", () => { + recordPull(sampleEntry({ install: "project", installRoot: "/projA", remoteVersion: 1 })); + recordPull(sampleEntry({ install: "project", installRoot: "/projB", remoteVersion: 1 })); + recordPull(sampleEntry({ install: "project", installRoot: "/projA", remoteVersion: 5 })); + const m = loadManifest(); + expect(m.entries).toHaveLength(2); + const a = m.entries.find(e => e.installRoot === "/projA"); + const b = m.entries.find(e => e.installRoot === "/projB"); + expect(a?.remoteVersion).toBe(5); + expect(b?.remoteVersion).toBe(1); + }); +}); + +describe("removePullEntry", () => { + it("removes a matching entry", () => { + recordPull(sampleEntry({ dirName: "a--alice" })); + recordPull(sampleEntry({ dirName: "b--bob" })); + removePullEntry("global", "/home/test/.claude/skills", "a--alice"); + expect(loadManifest().entries.map(e => e.dirName)).toEqual(["b--bob"]); + }); + + it("is idempotent when the entry doesn't exist", () => { + recordPull(sampleEntry({ dirName: "x--alice" })); + removePullEntry("global", "/home/test/.claude/skills", "nonexistent"); + expect(loadManifest().entries).toHaveLength(1); + }); + + it("keys removal by (install, installRoot, dirName) — sibling install untouched", () => { + recordPull(sampleEntry({ install: "global", dirName: "deploy--alice", installRoot: "/g" })); + recordPull(sampleEntry({ install: "project", dirName: "deploy--alice", installRoot: "/p" })); + removePullEntry("global", "/g", "deploy--alice"); + const m = loadManifest(); + expect(m.entries).toHaveLength(1); + expect(m.entries[0].install).toBe("project"); + }); + + it("keys removal by installRoot — sibling project root untouched", () => { + // Critical regression guard: removing one project's entry must not drop + // a same-named entry that lives in a different project root. + recordPull(sampleEntry({ install: "project", dirName: "deploy--alice", installRoot: "/projA" })); + recordPull(sampleEntry({ install: "project", dirName: "deploy--alice", installRoot: "/projB" })); + removePullEntry("project", "/projA", "deploy--alice"); + const m = loadManifest(); + expect(m.entries).toHaveLength(1); + expect(m.entries[0].installRoot).toBe("/projB"); + }); + + it("does NOT remove the entry when only install matches but installRoot differs", () => { + recordPull(sampleEntry({ install: "project", dirName: "deploy--alice", installRoot: "/projA" })); + removePullEntry("project", "/wrong-root", "deploy--alice"); + expect(loadManifest().entries).toHaveLength(1); + }); +}); + +describe("entriesForRoot", () => { + it("filters by install AND installRoot", () => { + const m = { + version: 1 as const, + entries: [ + sampleEntry({ install: "global", installRoot: "/h1/.claude/skills" }), + sampleEntry({ install: "global", installRoot: "/h2/.claude/skills", dirName: "x--alice" }), + sampleEntry({ install: "project", installRoot: "/h1/.claude/skills", dirName: "y--alice" }), + ], + }; + const filtered = entriesForRoot(m, "global", "/h1/.claude/skills"); + expect(filtered).toHaveLength(1); + expect(filtered[0].dirName).toBe("deploy--alice"); + }); +}); diff --git a/claude-code/tests/skilify-pull.test.ts b/claude-code/tests/skilify-pull.test.ts index 227b0d34..0d6708c6 100644 --- a/claude-code/tests/skilify-pull.test.ts +++ b/claude-code/tests/skilify-pull.test.ts @@ -11,19 +11,62 @@ import { decideAction, runPull, isMissingTableError, + assertValidAuthor, type QueryFn, } from "../../src/skilify/pull.js"; let projectRoot: string; let projectSkillsRoot: string; +let fakeHome: string; +let originalHome: string | undefined; beforeEach(() => { projectRoot = mkdtempSync(join(tmpdir(), "skilify-pull-")); projectSkillsRoot = join(projectRoot, ".claude", "skills"); + // Isolate HOME so the manifest written by recordPull lands in a temp + // directory instead of polluting the developer's real ~/.deeplake state. + fakeHome = mkdtempSync(join(tmpdir(), "skilify-pull-home-")); + originalHome = process.env.HOME; + process.env.HOME = fakeHome; }); afterEach(() => { try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* nothing */ } + try { rmSync(fakeHome, { recursive: true, force: true }); } catch { /* nothing */ } + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; +}); + +// ── assertValidAuthor ────────────────────────────────────────────────────── + +describe("assertValidAuthor", () => { + it("accepts emails, dotted names, and bare usernames", () => { + expect(() => assertValidAuthor("alice@example.com")).not.toThrow(); + expect(() => assertValidAuthor("emanuele.fenocchi")).not.toThrow(); + expect(() => assertValidAuthor("d")).not.toThrow(); + expect(() => assertValidAuthor("davit-buniatyan")).not.toThrow(); + expect(() => assertValidAuthor("user_42")).not.toThrow(); + }); + + it("rejects empty author", () => { + expect(() => assertValidAuthor("")).toThrow(/empty/); + }); + + it("rejects path-traversal characters", () => { + expect(() => assertValidAuthor("../escape")).toThrow(/invalid/); + expect(() => assertValidAuthor("alice/bob")).toThrow(/invalid/); + expect(() => assertValidAuthor("alice\\bob")).toThrow(/invalid/); + }); + + it("rejects whitespace and shell metacharacters", () => { + expect(() => assertValidAuthor("alice bob")).toThrow(/invalid/); + expect(() => assertValidAuthor("alice;rm")).toThrow(/invalid/); + expect(() => assertValidAuthor("alice$(whoami)")).toThrow(/invalid/); + }); + + it("rejects authors longer than 64 chars", () => { + expect(() => assertValidAuthor("a".repeat(65))).toThrow(/too long/); + }); }); // ── buildPullSql ──────────────────────────────────────────────────────────── @@ -301,16 +344,17 @@ describe("runPull", () => { ...over, }); - it("writes a new SKILL.md to /// when local missing", async () => { - const { fn } = makeMockQuery([sampleRow()]); // sampleRow uses project_key: "pk" + it("writes a new SKILL.md to /--/ when local missing", async () => { + const { fn } = makeMockQuery([sampleRow()]); // sampleRow uses author: "alice" const summary = await runPull({ query: fn, tableName: "skills", install: "project", cwd: projectRoot, users: [], dryRun: false, force: false, }); expect(summary.wrote).toBe(1); expect(summary.skipped).toBe(0); - // Path now namespaced by project_key to prevent cross-project collisions - const path = join(projectSkillsRoot, "pk", "vox-cli", "SKILL.md"); + // Flat layout suffixed by author keeps cross-author entries disjoint and + // remains visible to Claude Code's single-depth skill loader. + const path = join(projectSkillsRoot, "vox-cli--alice", "SKILL.md"); expect(existsSync(path)).toBe(true); const text = readFileSync(path, "utf-8"); expect(text).toContain("version: 2"); @@ -318,8 +362,7 @@ describe("runPull", () => { }); it("skips when local version >= remote", async () => { - // Pre-create a local v3 at the new project-keyed path - const dir = join(projectSkillsRoot, "pk", "vox-cli"); + const dir = join(projectSkillsRoot, "vox-cli--alice"); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "SKILL.md"), `---\nname: vox-cli\ndescription: "old"\nsource_sessions:\nversion: 3\ncreated_by_agent: cc\ncreated_at: t\nupdated_at: t\n---\n\nlocal body`); @@ -334,7 +377,7 @@ describe("runPull", () => { }); it("--force overrides skip and backs up the existing file", async () => { - const dir = join(projectSkillsRoot, "pk", "vox-cli"); + const dir = join(projectSkillsRoot, "vox-cli--alice"); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "SKILL.md"), `---\nname: vox-cli\ndescription: "old"\nsource_sessions:\nversion: 5\ncreated_by_agent: cc\ncreated_at: t\nupdated_at: t\n---\n\nlocal v5 body`); @@ -357,7 +400,7 @@ describe("runPull", () => { }); expect(summary.dryrun).toBe(1); expect(summary.wrote).toBe(0); - expect(existsSync(join(projectSkillsRoot, "pk", "vox-cli"))).toBe(false); + expect(existsSync(join(projectSkillsRoot, "vox-cli--alice"))).toBe(false); }); it("dedups rows by (project_key, name) keeping latest version per project", async () => { @@ -373,7 +416,7 @@ describe("runPull", () => { }); expect(summary.scanned).toBe(2); expect(summary.wrote).toBe(2); - const aText = readFileSync(join(projectSkillsRoot, "pk", "a", "SKILL.md"), "utf-8"); + const aText = readFileSync(join(projectSkillsRoot, "a--alice", "SKILL.md"), "utf-8"); expect(aText).toContain("version: 3"); }); @@ -407,18 +450,37 @@ describe("runPull", () => { })).rejects.toThrow(/Authentication failed/); }); - it("writes under // so cross-project skills don't collide", async () => { + it("keeps cross-author skills with the same name disjoint via -- suffix", async () => { const rows = [ - sampleRow({ name: "deploy", project_key: "alpha-key" }), - sampleRow({ name: "deploy", project_key: "beta-key" }), + sampleRow({ name: "deploy", author: "alice", project_key: "alpha-key" }), + sampleRow({ name: "deploy", author: "bob", project_key: "beta-key" }), ]; const { fn } = makeMockQuery(rows); await runPull({ query: fn, tableName: "skills", install: "project", cwd: projectRoot, users: [], dryRun: false, force: false, }); - expect(existsSync(join(projectSkillsRoot, "alpha-key", "deploy", "SKILL.md"))).toBe(true); - expect(existsSync(join(projectSkillsRoot, "beta-key", "deploy", "SKILL.md"))).toBe(true); + expect(existsSync(join(projectSkillsRoot, "deploy--alice", "SKILL.md"))).toBe(true); + expect(existsSync(join(projectSkillsRoot, "deploy--bob", "SKILL.md"))).toBe(true); + }); + + it("skips rows with invalid authors (path-traversal protection on the suffix segment)", async () => { + // Different project_keys so selectLatestPerName keeps both rows. + const rows = [ + sampleRow({ name: "deploy", project_key: "pk-bad", author: "../escape" }), + sampleRow({ name: "deploy", project_key: "pk-good", author: "alice" }), + ]; + const { fn } = makeMockQuery(rows); + const summary = await runPull({ + query: fn, tableName: "skills", install: "project", cwd: projectRoot, + users: [], dryRun: false, force: false, + }); + expect(summary.wrote).toBe(1); + expect(summary.skipped).toBe(1); + // The valid author landed under /--/ + expect(existsSync(join(projectSkillsRoot, "deploy--alice", "SKILL.md"))).toBe(true); + // The invalid author would have produced "deploy--../escape" — must not exist + expect(existsSync(join(projectSkillsRoot, "deploy--..", "escape"))).toBe(false); }); it("skips rows with invalid skill names instead of writing dangerous paths", async () => { @@ -434,8 +496,33 @@ describe("runPull", () => { // valid-skill written, invalid one skipped (not thrown — graceful) expect(summary.wrote).toBe(1); expect(summary.skipped).toBe(1); - expect(existsSync(join(projectSkillsRoot, "p", "valid-skill", "SKILL.md"))).toBe(true); + expect(existsSync(join(projectSkillsRoot, "valid-skill--alice", "SKILL.md"))).toBe(true); // No dangerous paths created expect(existsSync(join(projectSkillsRoot, "..", "escape"))).toBe(false); }); + + it("skips rows with empty author rather than clobbering the locally-mined slot", async () => { + // Empty author would degrade the dirName to // — exactly the + // path locally-mined skills live in. Pulling that would silently overwrite + // the user's own work, breaking the cross-author coexistence guarantee. + const rows = [ + sampleRow({ name: "deploy", project_key: "pk-empty", author: "" }), + sampleRow({ name: "deploy", project_key: "pk-valid", author: "alice" }), + ]; + const { fn } = makeMockQuery(rows); + const summary = await runPull({ + query: fn, tableName: "skills", install: "project", cwd: projectRoot, + users: [], dryRun: false, force: false, + }); + expect(summary.wrote).toBe(1); + expect(summary.skipped).toBe(1); + // The empty-author row must NOT have produced /deploy/SKILL.md + expect(existsSync(join(projectSkillsRoot, "deploy", "SKILL.md"))).toBe(false); + // The valid row landed at the suffixed path + expect(existsSync(join(projectSkillsRoot, "deploy--alice", "SKILL.md"))).toBe(true); + // The skipped entry carries a useful destination string for the dispatcher + const skipped = summary.entries.find(e => e.action === "skipped"); + expect(skipped?.destination).toMatch(/empty author/i); + expect(skipped?.author).toBe(""); + }); }); diff --git a/claude-code/tests/skilify-unpull.test.ts b/claude-code/tests/skilify-unpull.test.ts new file mode 100644 index 00000000..ce7d9869 --- /dev/null +++ b/claude-code/tests/skilify-unpull.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { recordPull, loadManifest } from "../../src/skilify/manifest.js"; +import { runUnpull } from "../../src/skilify/unpull.js"; + +let projectRoot: string; +let projectSkillsRoot: string; +let fakeHome: string; +let originalHome: string | undefined; + +beforeEach(() => { + projectRoot = mkdtempSync(join(tmpdir(), "skilify-unpull-proj-")); + projectSkillsRoot = join(projectRoot, ".claude", "skills"); + mkdirSync(projectSkillsRoot, { recursive: true }); + fakeHome = mkdtempSync(join(tmpdir(), "skilify-unpull-home-")); + originalHome = process.env.HOME; + process.env.HOME = fakeHome; +}); + +afterEach(() => { + try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* nothing */ } + try { rmSync(fakeHome, { recursive: true, force: true }); } catch { /* nothing */ } + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; +}); + +/** Create a fake skill directory + record it in the manifest. */ +function plantPulledSkill(dirName: string, author: string, name: string): void { + const dir = join(projectSkillsRoot, dirName); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "SKILL.md"), `---\nname: ${name}\nversion: 1\n---\nbody`); + recordPull({ + dirName, name, author, + projectKey: "abcd1234abcd1234", + remoteVersion: 1, + install: "project", + installRoot: projectSkillsRoot, + pulledAt: "2026-05-07T00:00:00Z", + }); +} + +/** Create a flat-layout dir on disk WITHOUT a manifest entry. */ +function plantManualSkill(dirName: string): void { + const dir = join(projectSkillsRoot, dirName); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "SKILL.md"), `---\nname: ${dirName}\nversion: 1\n---\nbody`); +} + +// ── manifest-driven removal ──────────────────────────────────────────────── + +describe("runUnpull (manifest-driven)", () => { + it("removes a manifest-tracked skill and prunes the entry", () => { + plantPulledSkill("deploy--alice", "alice", "deploy"); + const r = runUnpull({ install: "project", cwd: projectRoot, users: [] }); + expect(r.removed).toBe(1); + expect(r.kept).toBe(0); + expect(existsSync(join(projectSkillsRoot, "deploy--alice"))).toBe(false); + expect(loadManifest().entries).toHaveLength(0); + }); + + it("dry-run reports would-remove without touching disk or manifest", () => { + plantPulledSkill("deploy--alice", "alice", "deploy"); + const r = runUnpull({ install: "project", cwd: projectRoot, users: [], dryRun: true }); + expect(r.removed).toBe(0); + expect(r.wouldRemove).toBe(1); + expect(existsSync(join(projectSkillsRoot, "deploy--alice"))).toBe(true); + expect(loadManifest().entries).toHaveLength(1); + }); + + it("filters by --user, leaving non-matching authors alone", () => { + plantPulledSkill("deploy--alice", "alice", "deploy"); + plantPulledSkill("query--bob", "bob", "query"); + const r = runUnpull({ install: "project", cwd: projectRoot, users: ["alice"] }); + expect(r.removed).toBe(1); + expect(r.kept).toBe(1); + expect(existsSync(join(projectSkillsRoot, "deploy--alice"))).toBe(false); + expect(existsSync(join(projectSkillsRoot, "query--bob"))).toBe(true); + expect(loadManifest().entries.map(e => e.dirName)).toEqual(["query--bob"]); + }); + + it("--not-mine excludes the caller's own pulls", () => { + plantPulledSkill("deploy--alice", "alice", "deploy"); + plantPulledSkill("query--bob", "bob", "query"); + const r = runUnpull({ + install: "project", cwd: projectRoot, users: [], + myUsername: "alice", notMine: true, + }); + expect(r.removed).toBe(1); + expect(r.kept).toBe(1); + expect(existsSync(join(projectSkillsRoot, "deploy--alice"))).toBe(true); + expect(existsSync(join(projectSkillsRoot, "query--bob"))).toBe(false); + }); + + it("never touches a manual `--/` directory not in the manifest", () => { + plantPulledSkill("deploy--alice", "alice", "deploy"); + // User-authored variant skill — looks like the pull naming, but NOT + // recorded in the manifest. Must survive every default unpull mode. + plantManualSkill("deploy--blue-green"); + + runUnpull({ install: "project", cwd: projectRoot, users: [] }); + expect(existsSync(join(projectSkillsRoot, "deploy--blue-green"))).toBe(true); + + runUnpull({ install: "project", cwd: projectRoot, users: ["alice"] }); + expect(existsSync(join(projectSkillsRoot, "deploy--blue-green"))).toBe(true); + + runUnpull({ + install: "project", cwd: projectRoot, users: [], + myUsername: "carol", notMine: true, + }); + expect(existsSync(join(projectSkillsRoot, "deploy--blue-green"))).toBe(true); + }); + + it("re-running unpull is idempotent (manifest empty, scanned 0)", () => { + plantPulledSkill("deploy--alice", "alice", "deploy"); + runUnpull({ install: "project", cwd: projectRoot, users: [] }); + const second = runUnpull({ install: "project", cwd: projectRoot, users: [] }); + expect(second.scanned).toBe(0); + expect(second.removed).toBe(0); + expect(second.entries).toEqual([]); + }); +}); + +// ── orphan handling ──────────────────────────────────────────────────────── + +describe("runUnpull orphan detection", () => { + it("prunes a manifest entry whose directory was deleted out-of-band", () => { + plantPulledSkill("deploy--alice", "alice", "deploy"); + rmSync(join(projectSkillsRoot, "deploy--alice"), { recursive: true }); + expect(loadManifest().entries).toHaveLength(1); + const r = runUnpull({ install: "project", cwd: projectRoot, users: [] }); + expect(r.manifestPruned).toBe(1); + expect(r.removed).toBe(0); + expect(loadManifest().entries).toHaveLength(0); + }); + + it("dry-run does not prune the manifest entry", () => { + plantPulledSkill("deploy--alice", "alice", "deploy"); + rmSync(join(projectSkillsRoot, "deploy--alice"), { recursive: true }); + runUnpull({ install: "project", cwd: projectRoot, users: [], dryRun: true }); + expect(loadManifest().entries).toHaveLength(1); + }); +}); + +// ── opt-in disk walk for --all and --legacy-cleanup ──────────────────────── + +describe("runUnpull --all and --legacy-cleanup", () => { + it("--all also removes flat locally-mined skills (still ignores `--` not in manifest)", () => { + plantManualSkill("graphify"); + plantManualSkill("deploy--blue-green"); // manual variant — `--` in name, no manifest + const r = runUnpull({ install: "project", cwd: projectRoot, users: [], all: true }); + // graphify removed (locally-mined, single segment) + expect(existsSync(join(projectSkillsRoot, "graphify"))).toBe(false); + // deploy--blue-green NOT removed even with --all because it's neither + // manifest-tracked nor a single-segment dir nor a legacy hex key. + expect(existsSync(join(projectSkillsRoot, "deploy--blue-green"))).toBe(true); + expect(r.removed).toBe(1); + }); + + it("--legacy-cleanup removes 16-hex-char project_key dirs from older skilify versions", () => { + const legacy = join(projectSkillsRoot, "abcd1234abcd1234"); + mkdirSync(join(legacy, "old-skill"), { recursive: true }); + writeFileSync(join(legacy, "old-skill", "SKILL.md"), "---\nname: old-skill\n---"); + const r = runUnpull({ install: "project", cwd: projectRoot, users: [], legacyCleanup: true }); + expect(existsSync(legacy)).toBe(false); + expect(r.removed).toBe(1); + }); + + it("default run leaves locally-mined and legacy dirs alone", () => { + plantManualSkill("graphify"); + const legacy = join(projectSkillsRoot, "abcd1234abcd1234"); + mkdirSync(legacy, { recursive: true }); + const r = runUnpull({ install: "project", cwd: projectRoot, users: [] }); + expect(r.scanned).toBe(0); // no manifest entries, no opt-in disk walk + expect(existsSync(join(projectSkillsRoot, "graphify"))).toBe(true); + expect(existsSync(legacy)).toBe(true); + }); +}); + +// ── filter+all conflict guard ────────────────────────────────────────────── + +describe("runUnpull filter+all conflict guard", () => { + // `--all` and `--legacy-cleanup` walk the disk and remove entries the + // manifest doesn't know about. Those entries have no recorded author, so + // any --user / --users / --not-mine filter would be silently ignored for + // them — an over-removal footgun. Refuse the combination loudly. + + it("throws when --all is combined with --user", () => { + expect(() => runUnpull({ + install: "project", cwd: projectRoot, users: ["alice"], all: true, + })).toThrow(/--all.*--user/); + }); + + it("throws when --all is combined with --not-mine", () => { + expect(() => runUnpull({ + install: "project", cwd: projectRoot, users: [], + myUsername: "alice", notMine: true, all: true, + })).toThrow(/--all.*--not-mine/); + }); + + it("throws when --legacy-cleanup is combined with --users", () => { + expect(() => runUnpull({ + install: "project", cwd: projectRoot, users: ["alice", "bob"], + legacyCleanup: true, + })).toThrow(/--legacy-cleanup.*--user/); + }); + + it("allows --all with no author filter", () => { + plantManualSkill("graphify"); + expect(() => runUnpull({ + install: "project", cwd: projectRoot, users: [], all: true, + })).not.toThrow(); + }); + + it("allows --user without --all (manifest-only path)", () => { + plantPulledSkill("deploy--alice", "alice", "deploy"); + expect(() => runUnpull({ + install: "project", cwd: projectRoot, users: ["alice"], + })).not.toThrow(); + }); +}); diff --git a/codex/bundle/session-start.js b/codex/bundle/session-start.js index 2c4a61e1..cc167872 100755 --- a/codex/bundle/session-start.js +++ b/codex/bundle/session-start.js @@ -140,6 +140,10 @@ SKILLS (skilify) \u2014 mine + share reusable skills across the org: - hivemind skilify pull --dry-run \u2014 preview only - hivemind skilify pull --force \u2014 overwrite local (creates .bak) - hivemind skilify pull \u2014 pull only that skill (combines with --user) +- hivemind skilify unpull \u2014 remove every skill previously installed by pull +- hivemind skilify unpull --user \u2014 remove only that author's pulls +- hivemind skilify unpull --not-mine \u2014 remove all pulls except your own +- hivemind skilify unpull --dry-run \u2014 preview without touching disk - hivemind skilify scope \u2014 sharing scope for new skills - hivemind skilify install \u2014 default install location - hivemind skilify team add|remove|list \u2014 manage team list`; diff --git a/cursor/bundle/session-start.js b/cursor/bundle/session-start.js index 28000ee0..86e5fe7f 100755 --- a/cursor/bundle/session-start.js +++ b/cursor/bundle/session-start.js @@ -702,6 +702,10 @@ SKILLS (skilify) \u2014 mine + share reusable skills across the org: - hivemind skilify pull --dry-run \u2014 preview only - hivemind skilify pull --force \u2014 overwrite local (creates .bak) - hivemind skilify pull \u2014 pull only that skill (combines with --user) +- hivemind skilify unpull \u2014 remove every skill previously installed by pull +- hivemind skilify unpull --user \u2014 remove only that author's pulls +- hivemind skilify unpull --not-mine \u2014 remove all pulls except your own +- hivemind skilify unpull --dry-run \u2014 preview without touching disk - hivemind skilify scope \u2014 sharing scope for new skills - hivemind skilify install \u2014 default install location - hivemind skilify team add|remove|list \u2014 manage team list`; diff --git a/hermes/bundle/session-start.js b/hermes/bundle/session-start.js index dc778bf4..fb914ec9 100755 --- a/hermes/bundle/session-start.js +++ b/hermes/bundle/session-start.js @@ -702,6 +702,10 @@ SKILLS (skilify) \u2014 mine + share reusable skills across the org: - hivemind skilify pull --dry-run \u2014 preview only - hivemind skilify pull --force \u2014 overwrite local (creates .bak) - hivemind skilify pull \u2014 pull only that skill (combines with --user) +- hivemind skilify unpull \u2014 remove every skill previously installed by pull +- hivemind skilify unpull --user \u2014 remove only that author's pulls +- hivemind skilify unpull --not-mine \u2014 remove all pulls except your own +- hivemind skilify unpull --dry-run \u2014 preview without touching disk - hivemind skilify scope \u2014 sharing scope for new skills - hivemind skilify install \u2014 default install location - hivemind skilify team add|remove|list \u2014 manage team list`; diff --git a/openclaw/skills/SKILL.md b/openclaw/skills/SKILL.md index fb9fae9e..98eaa96a 100644 --- a/openclaw/skills/SKILL.md +++ b/openclaw/skills/SKILL.md @@ -58,6 +58,10 @@ Hivemind also mines reusable Claude skills from agent sessions and stores them i - `hivemind skilify pull --dry-run` — preview without touching disk - `hivemind skilify pull --force` — overwrite local (creates `.bak`) - `hivemind skilify pull ` — pull only that one skill (combines with `--user`) +- `hivemind skilify unpull` — remove every skill previously installed by pull +- `hivemind skilify unpull --user ` — remove only that author's pulls +- `hivemind skilify unpull --not-mine` — remove all pulls except your own +- `hivemind skilify unpull --dry-run` — preview without touching disk - `hivemind skilify scope ` — set sharing scope for new skills - `hivemind skilify install ` — default install location - `hivemind skilify team add|remove|list ` — manage team list diff --git a/pi/extension-source/hivemind.ts b/pi/extension-source/hivemind.ts index d05693dc..f4a28d0a 100644 --- a/pi/extension-source/hivemind.ts +++ b/pi/extension-source/hivemind.ts @@ -666,6 +666,10 @@ SKILLS (skilify) — mine + share reusable skills across the org. Run these in a - hivemind skilify pull --dry-run — preview only - hivemind skilify pull --force — overwrite local (creates .bak) - hivemind skilify pull — pull only that skill (combines with --user) +- hivemind skilify unpull — remove every skill previously installed by pull +- hivemind skilify unpull --user — remove only that author's pulls +- hivemind skilify unpull --not-mine — remove all pulls except your own +- hivemind skilify unpull --dry-run — preview without touching disk - hivemind skilify scope — sharing scope for new skills - hivemind skilify install — default install location - hivemind skilify team add|remove|list — manage team list`; diff --git a/src/cli/index.ts b/src/cli/index.ts index f6e298e5..c503ae12 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -69,6 +69,11 @@ Skill management (mine + share reusable Claude skills across the org): Options: --user , --users a,b,c, --all-users, --to , --dry-run, --force. + hivemind skilify unpull Remove skills previously installed by pull. + Options: --user, --users, --not-mine, + --to , --dry-run, + --all (also locally-mined), + --legacy-cleanup (pre-suffix-author dirs). hivemind skilify scope Set the sharing scope for newly mined skills. hivemind skilify install Set where new skills are written. hivemind skilify promote Move a project skill to the global location. diff --git a/src/commands/skilify.ts b/src/commands/skilify.ts index c66a0b84..7c39411d 100644 --- a/src/commands/skilify.ts +++ b/src/commands/skilify.ts @@ -24,10 +24,17 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { loadScopeConfig, saveScopeConfig, type Scope, type InstallLocation } from "../skilify/scope-config.js"; import { runPull, type PullSummary } from "../skilify/pull.js"; +import { runUnpull } from "../skilify/unpull.js"; import { loadConfig } from "../config.js"; import { DeeplakeApi } from "../deeplake-api.js"; -const STATE_DIR = join(homedir(), ".deeplake", "state", "skilify"); +// Compute lazily so tests that swap `process.env.HOME` actually affect the +// path. A module-level `const STATE_DIR = join(homedir(), ...)` would +// capture the developer's real home at import time and bypass HOME +// isolation, causing test runs to read & pollute ~/.deeplake/state/skilify. +function stateDir(): string { + return join(homedir(), ".deeplake", "state", "skilify"); +} function showStatus(): void { const cfg = loadScopeConfig(); @@ -35,11 +42,19 @@ function showStatus(): void { console.log(`team: ${cfg.team.length === 0 ? "(empty)" : cfg.team.join(", ")}`); console.log(`install: ${cfg.install} (${cfg.install === "global" ? "~/.claude/skills/" : "/.claude/skills/"})`); - if (!existsSync(STATE_DIR)) { + const dir = stateDir(); + if (!existsSync(dir)) { console.log(`state: (no projects tracked yet)`); return; } - const files = readdirSync(STATE_DIR).filter(f => f.endsWith(".json") && f !== "config.json"); + // Filter out skilify's own bookkeeping files. `config.json` is the + // scope/team/install settings; `pulled.json` is the unpull manifest — + // neither represents a "tracked project" and counting them inflates the + // status output (and the `for` loop below would JSON.parse them with the + // wrong shape and silently swallow the error). + const files = readdirSync(dir).filter( + f => f.endsWith(".json") && f !== "config.json" && f !== "pulled.json", + ); if (files.length === 0) { console.log(`state: (no projects tracked yet)`); return; @@ -47,7 +62,7 @@ function showStatus(): void { console.log(`state: ${files.length} project(s) tracked`); for (const f of files) { try { - const s = JSON.parse(readFileSync(join(STATE_DIR, f), "utf-8")) as { + const s = JSON.parse(readFileSync(join(dir, f), "utf-8")) as { project: string; counter: number; lastDate: string | null; skillsGenerated: string[]; }; const skills = s.skillsGenerated.length === 0 ? "none" : s.skillsGenerated.join(", "); @@ -147,6 +162,15 @@ function usage(): void { console.log(" --all-users all authors (default — equivalent to no filter)"); console.log(" --dry-run show what would be written, don't touch disk"); console.log(" --force overwrite even when local version >= remote"); + console.log(" hivemind skilify unpull [opts] remove skills previously installed by pull"); + console.log(" Options for unpull:"); + console.log(" --to where to scan (default: global)"); + console.log(" --user only entries authored by this user"); + console.log(" --users only entries authored by these users"); + console.log(" --not-mine remove all pulled entries except your own"); + console.log(" --dry-run show what would be removed"); + console.log(" --all also remove flat-layout (locally-mined) entries"); + console.log(" --legacy-cleanup also remove pre-`--author`-layout legacy `/` dirs"); console.log(" hivemind skilify status show per-project state"); } @@ -231,10 +255,89 @@ async function pullSkills(args: string[]): Promise { const tag = e.action === "wrote" ? "✓ wrote" : e.action === "dryrun" ? "→ would write" : "· skipped"; const ver = e.localVersion === null ? `v${e.remoteVersion} (new)` : `v${e.localVersion} → v${e.remoteVersion}`; console.log(` ${tag.padEnd(15)} ${e.name.padEnd(40)} ${ver.padEnd(20)} (${e.author}/${e.sourceAgent})`); + if (e.manifestError) { + // Skill is on disk but absent from pulled.json — `unpull` won't + // be able to remove it. Loud warning so the user knows to either + // delete it manually or repull (which retries the manifest write). + console.warn(` ⚠ manifest not updated: ${e.manifestError} — \`unpull\` will not see this entry until a successful repull.`); + } } console.log(`Result: ${summary.wrote} written, ${summary.dryrun} dry-run, ${summary.skipped} skipped.`); } +async function unpullSkills(args: string[]): Promise { + const work = [...args]; + const toRaw = takeFlagValue(work, "--to") ?? "global"; + const userOne = takeFlagValue(work, "--user"); + const usersMany = takeFlagValue(work, "--users"); + const notMine = takeBooleanFlag(work, "--not-mine"); + const dryRun = takeBooleanFlag(work, "--dry-run"); + const all = takeBooleanFlag(work, "--all"); + const legacyCleanup = takeBooleanFlag(work, "--legacy-cleanup"); + + // Throw rather than `process.exit(1)` so the dispatcher's `.catch` is + // the single point that surfaces the failure — avoids a second exit + // call (and a second mocked-throw in tests) that would manifest as an + // unhandled promise rejection. + if (toRaw !== "project" && toRaw !== "global") { + throw new Error(`Invalid --to '${toRaw}'. Use 'project' or 'global'.`); + } + + let users: string[] = []; + if (userOne) users = [userOne]; + else if (usersMany) users = usersMany.split(",").map(s => s.trim()).filter(Boolean); + + // Unpull is a local filesystem operation: deleting `//` and + // pruning `pulled.json`. The Deeplake API is never queried. The only + // reason we need credentials is `--not-mine`, which compares each + // entry's author to `config.userName`. Skip the login check otherwise so + // a user who's been bounced from the org can still clean up their disk. + let myUsername: string | undefined; + if (notMine) { + const config = loadConfig(); + if (!config) { + throw new Error("--not-mine requires a logged-in user. Run: hivemind login"); + } + myUsername = config.userName; + } + + const summary = runUnpull({ + install: toRaw, + cwd: toRaw === "project" ? process.cwd() : undefined, + users, + myUsername, + notMine, + dryRun, + all, + legacyCleanup, + }); + + const dest = toRaw === "global" ? join(homedir(), ".claude", "skills") : `${process.cwd()}/.claude/skills`; + const filterParts: string[] = []; + if (users.length > 0) filterParts.push(`users=${users.join(",")}`); + if (notMine) filterParts.push("not-mine"); + if (all) filterParts.push("all"); + if (legacyCleanup) filterParts.push("legacy-cleanup"); + if (dryRun) filterParts.push("dry-run"); + const filterDesc = filterParts.length ? filterParts.join(" · ") : "(no filter — all pulled)"; + + console.log(`Scanning: ${dest}`); + console.log(`Filter: ${filterDesc}`); + console.log(`Scanned ${summary.scanned} dir(s).`); + for (const e of summary.entries) { + const tag = + e.action === "removed" ? "✓ removed" : + e.action === "would-remove" ? "→ would remove" : + e.action === "manifest-pruned" ? "⚠ pruned (orphan)" : + "· kept"; + const id = e.dirName; + const note = e.reason ? ` (${e.reason})` : ""; + console.log(` ${tag.padEnd(20)} ${id.padEnd(50)} [${e.kind}]${note}`); + } + const prunedNote = summary.manifestPruned > 0 ? `, ${summary.manifestPruned} manifest-pruned` : ""; + console.log(`Result: ${summary.removed} removed, ${summary.wouldRemove} dry-run, ${summary.kept} kept${prunedNote}.`); +} + export function runSkilifyCommand(args: string[]): void { const sub = args[0]; if (!sub || sub === "status") { showStatus(); return; } @@ -248,6 +351,19 @@ export function runSkilifyCommand(args: string[]): void { }); return; } + if (sub === "unpull") { + unpullSkills(args.slice(1)) + .catch(e => { + console.error(`unpull error: ${e?.message ?? e}`); + process.exit(1); + }) + // process.exit is mocked in unit tests as a throw; swallow that + // secondary rejection so it doesn't surface as an unhandled error. + // In production the real process.exit kills the process, so this + // tail catch is unreachable. + .catch(() => { /* test-only safety net */ }); + return; + } if (sub === "team") { const action = args[1]; if (action === "add") { teamAdd(args[2] ?? ""); return; } diff --git a/src/hooks/codex/session-start.ts b/src/hooks/codex/session-start.ts index 352396f2..16cad2d7 100644 --- a/src/hooks/codex/session-start.ts +++ b/src/hooks/codex/session-start.ts @@ -62,6 +62,10 @@ SKILLS (skilify) — mine + share reusable skills across the org: - hivemind skilify pull --dry-run — preview only - hivemind skilify pull --force — overwrite local (creates .bak) - hivemind skilify pull — pull only that skill (combines with --user) +- hivemind skilify unpull — remove every skill previously installed by pull +- hivemind skilify unpull --user — remove only that author's pulls +- hivemind skilify unpull --not-mine — remove all pulls except your own +- hivemind skilify unpull --dry-run — preview without touching disk - hivemind skilify scope — sharing scope for new skills - hivemind skilify install — default install location - hivemind skilify team add|remove|list — manage team list`; diff --git a/src/hooks/cursor/session-start.ts b/src/hooks/cursor/session-start.ts index 8d78f52c..ff1d67be 100644 --- a/src/hooks/cursor/session-start.ts +++ b/src/hooks/cursor/session-start.ts @@ -62,6 +62,10 @@ SKILLS (skilify) — mine + share reusable skills across the org: - hivemind skilify pull --dry-run — preview only - hivemind skilify pull --force — overwrite local (creates .bak) - hivemind skilify pull — pull only that skill (combines with --user) +- hivemind skilify unpull — remove every skill previously installed by pull +- hivemind skilify unpull --user — remove only that author's pulls +- hivemind skilify unpull --not-mine — remove all pulls except your own +- hivemind skilify unpull --dry-run — preview without touching disk - hivemind skilify scope — sharing scope for new skills - hivemind skilify install — default install location - hivemind skilify team add|remove|list — manage team list`; diff --git a/src/hooks/hermes/session-start.ts b/src/hooks/hermes/session-start.ts index a3964fe1..9524e95a 100644 --- a/src/hooks/hermes/session-start.ts +++ b/src/hooks/hermes/session-start.ts @@ -54,6 +54,10 @@ SKILLS (skilify) — mine + share reusable skills across the org: - hivemind skilify pull --dry-run — preview only - hivemind skilify pull --force — overwrite local (creates .bak) - hivemind skilify pull — pull only that skill (combines with --user) +- hivemind skilify unpull — remove every skill previously installed by pull +- hivemind skilify unpull --user — remove only that author's pulls +- hivemind skilify unpull --not-mine — remove all pulls except your own +- hivemind skilify unpull --dry-run — preview without touching disk - hivemind skilify scope — sharing scope for new skills - hivemind skilify install — default install location - hivemind skilify team add|remove|list — manage team list`; diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index 7ffba18a..9b819694 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -67,6 +67,10 @@ Skill management (mine + share reusable Claude skills across the org): - hivemind skilify pull --dry-run — preview without touching disk - hivemind skilify pull --force — overwrite local files even if up-to-date (creates .bak) - hivemind skilify pull — pull only that one skill (combines with --user) +- hivemind skilify unpull — remove every skill previously installed by pull +- hivemind skilify unpull --user — remove only that author's pulls +- hivemind skilify unpull --not-mine — remove all pulls except your own +- hivemind skilify unpull --dry-run — preview without touching disk - hivemind skilify scope — sharing scope for newly mined skills - hivemind skilify install — default install location for new skills - hivemind skilify promote — move a project skill to the global location diff --git a/src/skilify/manifest.ts b/src/skilify/manifest.ts new file mode 100644 index 00000000..cd02560e --- /dev/null +++ b/src/skilify/manifest.ts @@ -0,0 +1,166 @@ +/** + * Manifest of skills installed via `hivemind skilify pull`. + * + * Why a manifest instead of just heuristics on directory names: + * the `--/` convention used by `pull` is a legitimate + * naming pattern that anyone can use for variant or sub-purpose skills + * (e.g. `deploy--blue-green`, `test--integration`). Inferring "this is + * a pull-managed entry" purely from the presence of `--` would let + * `unpull` accidentally remove user-authored skills with that naming + * style. The manifest gives `unpull` an authoritative list of what + * skilify actually wrote, so anything outside that list is left alone. + * + * File: ~/.deeplake/state/skilify/pulled.json + * + * Atomicity: writes go to a sibling .tmp file and rename in place, so + * a crash mid-write leaves either the pre-write state or the new state + * intact (no torn JSON). + */ + +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import type { InstallLocation } from "./scope-config.js"; + +export interface PulledEntry { + /** Directory name on disk (e.g. "meta-harness-continual-learning--d"). */ + dirName: string; + /** Skill name (without author suffix). */ + name: string; + /** Author who originally minted the skill. */ + author: string; + /** Skills-table `project_key` of the source project. */ + projectKey: string; + /** Remote version pulled (so a later pull can detect upgrade vs same). */ + remoteVersion: number; + /** "global" → ~/.claude/skills, "project" → /.claude/skills. */ + install: InstallLocation; + /** Absolute install root the dir was written under. */ + installRoot: string; + /** ISO timestamp of the pull. */ + pulledAt: string; +} + +export interface PulledManifest { + version: 1; + entries: PulledEntry[]; +} + +function emptyManifest(): PulledManifest { + return { version: 1, entries: [] }; +} + +export function manifestPath(): string { + return join(homedir(), ".deeplake", "state", "skilify", "pulled.json"); +} + +export function loadManifest(path: string = manifestPath()): PulledManifest { + if (!existsSync(path)) return emptyManifest(); + let raw: string; + try { raw = readFileSync(path, "utf-8"); } + catch { return emptyManifest(); } + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return emptyManifest(); + if (parsed.version !== 1 || !Array.isArray(parsed.entries)) return emptyManifest(); + // Validate each entry shape; drop malformed ones rather than failing. + const entries: PulledEntry[] = []; + for (const e of parsed.entries) { + if (!e || typeof e !== "object") continue; + if (typeof e.dirName !== "string" || !e.dirName) continue; + // Reject any dirName containing path separators or `..`. A corrupted + // (or maliciously edited) manifest could otherwise convince `unpull` + // to `rmSync(join(installRoot, dirName))` outside the install root — + // e.g. `dirName = "../../etc"`. The pull writer always produces a + // single-segment `--` string, so this validation only + // discards entries that someone hand-edited into pulled.json. + if (e.dirName.includes("/") || e.dirName.includes("\\") || e.dirName.includes("..")) continue; + if (typeof e.name !== "string" || !e.name) continue; + if (typeof e.author !== "string") continue; + if (typeof e.installRoot !== "string" || !e.installRoot) continue; + if (e.install !== "global" && e.install !== "project") continue; + entries.push({ + dirName: e.dirName, + name: e.name, + author: e.author, + projectKey: typeof e.projectKey === "string" ? e.projectKey : "", + remoteVersion: typeof e.remoteVersion === "number" ? e.remoteVersion : 1, + install: e.install, + installRoot: e.installRoot, + pulledAt: typeof e.pulledAt === "string" ? e.pulledAt : new Date().toISOString(), + }); + } + return { version: 1, entries }; + } catch { + // Corrupt JSON — fail safe to empty manifest. Caller should not lose data + // because the next pull will repopulate, and unpull treats missing entries + // as "not pull-managed" (no-op). + return emptyManifest(); + } +} + +export function saveManifest(m: PulledManifest, path: string = manifestPath()): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp`; + writeFileSync(tmp, JSON.stringify(m, null, 2) + "\n", { mode: 0o600 }); + renameSync(tmp, path); +} + +/** + * Insert or replace the entry for a given `(install, installRoot, dirName)` + * triple. Two pulls of the same skill update the existing row's + * `remoteVersion` + `pulledAt`. The triple is the right key because: + * - cross-install (global vs project) entries are independent records + * (same dirName can legitimately appear in both); + * - cross-installRoot entries are also independent — a user who pulls + * `deploy--alice` into `~/projA/.claude/skills` and into + * `~/projB/.claude/skills` must end up with TWO manifest rows, one + * per project root, so `unpull --to project` from each cwd only + * targets that cwd's entry. Keying on `(install, dirName)` alone + * would cause the second pull to silently overwrite the first row, + * and unpull would then leave the first project's directory orphaned + * on disk because no manifest entry pointed at it anymore. + */ +export function recordPull(entry: PulledEntry, path: string = manifestPath()): void { + const m = loadManifest(path); + const idx = m.entries.findIndex(e => + e.install === entry.install && + e.installRoot === entry.installRoot && + e.dirName === entry.dirName, + ); + if (idx >= 0) m.entries[idx] = entry; + else m.entries.push(entry); + saveManifest(m, path); +} + +/** + * Remove an entry from the manifest. Idempotent — succeeds silently when + * the entry doesn't exist (e.g. unpull called twice). Keyed by the same + * `(install, installRoot, dirName)` triple as `recordPull` so an unpull + * in one project cwd never accidentally drops the manifest row for an + * identically-named skill pulled into a different project root. + */ +export function removePullEntry( + install: InstallLocation, + installRoot: string, + dirName: string, + path: string = manifestPath(), +): void { + const m = loadManifest(path); + const before = m.entries.length; + m.entries = m.entries.filter(e => !( + e.install === install && + e.installRoot === installRoot && + e.dirName === dirName + )); + if (m.entries.length !== before) saveManifest(m, path); +} + +/** + * Filter manifest to entries matching a specific install location and root. + * Used by `unpull` so a `--to project` invocation only sees entries written + * with `install === "project"` AND under the matching `installRoot`. + */ +export function entriesForRoot(m: PulledManifest, install: InstallLocation, installRoot: string): PulledEntry[] { + return m.entries.filter(e => e.install === install && e.installRoot === installRoot); +} diff --git a/src/skilify/pull.ts b/src/skilify/pull.ts index 5496edac..ae5139fa 100644 --- a/src/skilify/pull.ts +++ b/src/skilify/pull.ts @@ -22,6 +22,21 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { assertValidSkillName, parseFrontmatter, type SkillFrontmatter } from "./skill-writer.js"; import type { InstallLocation } from "./scope-config.js"; +import { recordPull } from "./manifest.js"; + +/** + * Tighter-than-skill-name validator for the author segment that becomes a + * directory name (`/--/`). Same intent as + * `assertValidSkillName`: reject anything that could escape the install + * root or break path-handling tools. + */ +export function assertValidAuthor(author: string): void { + if (!author) throw new Error("author is empty"); + if (author.length > 64) throw new Error(`author too long (${author.length}): ${author.slice(0, 32)}…`); + if (!/^[A-Za-z0-9_.\-@]+$/.test(author)) { + throw new Error(`author contains invalid characters: ${author}`); + } +} export type QueryFn = (sql: string) => Promise[]>; @@ -55,6 +70,14 @@ export interface PullResultEntry { destination: string; author: string; sourceAgent: string; + /** + * Set when the SKILL.md was written successfully but the manifest + * recording failed afterwards — surface the underlying message so the + * caller can warn loudly. The skill exists on disk but `unpull` will + * not be able to remove it via the manifest path, so the user must + * either delete the dir manually or repull. + */ + manifestError?: string; } export interface PullSummary { @@ -274,10 +297,47 @@ export async function runPull(opts: PullOptions): Promise { summary.skipped++; continue; } - const projectKey = String(row.project_key ?? ""); - // Project subdirectory keeps cross-project skills with the same name - // disjoint. Skip it only when project_key is empty (legacy rows). - const skillDir = projectKey ? join(root, projectKey, name) : join(root, name); + const author = String(row.author ?? ""); + // Pulled skills land at `/--/SKILL.md` so: + // 1. Claude Code's skill loader (single-depth scan) sees them directly + // 2. Cross-author name collisions stay disjoint on disk + // 3. The directory name self-documents authorship at a glance + // Locally-mined skills stay at `//` (flat, no suffix), so + // a self-mined `deploy` and a pulled `deploy--alice` coexist. + // Same-author / same-name across two projects is the one regression vs + // the legacy `//` layout: the more recently pulled + // row clobbers the earlier one (with `.bak` of the prior SKILL.md). + // Acceptable trade-off — the row stays in Deeplake and is recoverable + // via re-pull from the project that authored it. + // + // Empty `author` would degrade the path to `//` (the + // locally-mined slot) and silently clobber the user's own skill of + // the same name, breaking the coexistence guarantee above. Skip the + // row instead — Deeplake should always populate `author`, and + // ignoring an empty one is safer than guessing a placeholder. + if (!author) { + summary.entries.push({ + name, remoteVersion: Number(row.version ?? 1), localVersion: null, + action: "skipped", destination: "(empty author — skipped)", + author: "", sourceAgent: String(row.source_agent ?? ""), + }); + summary.skipped++; + continue; + } + let dirName: string; + try { + assertValidAuthor(author); + dirName = `${name}--${author}`; + } catch (e: any) { + summary.entries.push({ + name, remoteVersion: Number(row.version ?? 1), localVersion: null, + action: "skipped", destination: `(invalid author '${author}' — skipped)`, + author, sourceAgent: String(row.source_agent ?? ""), + }); + summary.skipped++; + continue; + } + const skillDir = join(root, dirName); const skillFile = join(skillDir, "SKILL.md"); const remoteVersion = Number(row.version ?? 1); const localVersion = readLocalVersion(skillFile); @@ -287,6 +347,7 @@ export async function runPull(opts: PullOptions): Promise { dryRun: opts.dryRun ?? false, }); + let manifestError: string | undefined; if (action === "wrote") { mkdirSync(skillDir, { recursive: true }); // Backup any existing file before overwriting (only if it was non-null @@ -295,6 +356,26 @@ export async function runPull(opts: PullOptions): Promise { try { renameSync(skillFile, `${skillFile}.bak`); } catch { /* best effort */ } } writeFileSync(skillFile, renderSkillFile(row)); + // Record in manifest so `unpull` can identify this entry as + // pull-managed without relying on the `--` dirname heuristic. + try { + recordPull({ + dirName, + name, + author, + projectKey: String(row.project_key ?? ""), + remoteVersion, + install: opts.install, + installRoot: root, + pulledAt: new Date().toISOString(), + }); + } catch (e: any) { + // Skill is on disk but the manifest didn't record it — surface + // this in the entry so the dispatcher can warn. `unpull` will + // not be able to clean this entry via the manifest path until + // a successful re-pull populates it. + manifestError = e?.message ?? String(e); + } } summary.entries.push({ @@ -305,6 +386,7 @@ export async function runPull(opts: PullOptions): Promise { destination: skillFile, author: String(row.author ?? ""), sourceAgent: String(row.source_agent ?? ""), + manifestError, }); if (action === "wrote") summary.wrote++; diff --git a/src/skilify/unpull.ts b/src/skilify/unpull.ts new file mode 100644 index 00000000..62bf882c --- /dev/null +++ b/src/skilify/unpull.ts @@ -0,0 +1,258 @@ +/** + * Remove skills previously installed by `hivemind skilify pull`. + * + * Source of truth: `~/.deeplake/state/skilify/pulled.json` (the manifest + * written by pull.ts). Entries on disk that are NOT in the manifest are + * never touched by default — even if their directory name follows the + * `--` convention. This protects user-authored skills that + * happen to use `--` as a naming separator (e.g. `deploy--blue-green`). + * + * Filtering: + * - by users: --user X | --users a,b,c + * - by self: --not-mine (remove everyone-but-me; needs whoami) + * - default: remove every manifest entry matching the install scope + * - --all: ALSO remove flat-layout `/` entries (locally-mined + * skills); this path bypasses the manifest because + * locally-mined skills are not tracked there. Destructive, + * documented as such in usage. + * - --legacy-cleanup: scan disk for pre-`--author` dirs of the shape + * `<16-hex>/` (old project_key layout from skilify ≤ v0.7.13) + * and remove them. + * + * Drift handling: a manifest entry whose `installRoot/` no longer + * exists on disk is silently pruned from the manifest on the next unpull. + */ + +import { existsSync, readdirSync, rmSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { InstallLocation } from "./scope-config.js"; +import { entriesForRoot, loadManifest, removePullEntry, type PulledEntry } from "./manifest.js"; + +export interface UnpullOptions { + /** Where to scan. */ + install: InstallLocation; + /** Used when install === "project". */ + cwd?: string; + /** Author filter. Empty array = no filter. */ + users: string[]; + /** Username of the caller; required when notMine === true. */ + myUsername?: string; + /** Remove entries whose author is NOT myUsername. */ + notMine?: boolean; + /** Don't actually delete — just report. */ + dryRun?: boolean; + /** Also remove flat-layout `/` entries (locally-mined). Bypasses manifest. */ + all?: boolean; + /** Also remove pre-`--author`-layout legacy `/` dirs. Bypasses manifest. */ + legacyCleanup?: boolean; +} + +export interface UnpullResultEntry { + /** Directory name as found on disk. */ + dirName: string; + /** Source: "manifest" if the entry was pull-tracked, otherwise the disk-walk classification. */ + kind: "pulled-manifest" | "locally-mined" | "legacy-projectkey" | "manifest-orphan"; + author: string | null; + name: string | null; + action: "removed" | "would-remove" | "kept-filter" | "kept-policy" | "manifest-pruned"; + reason?: string; + /** Absolute path that was (or would be) deleted, or "" for manifest-only prune. */ + path: string; +} + +export interface UnpullSummary { + scanned: number; + removed: number; + wouldRemove: number; + kept: number; + manifestPruned: number; + entries: UnpullResultEntry[]; +} + +export function resolveUnpullRoot(install: InstallLocation, cwd?: string): string { + if (install === "global") return join(homedir(), ".claude", "skills"); + if (!cwd) throw new Error("cwd required when install === 'project'"); + return join(cwd, ".claude", "skills"); +} + +export function runUnpull(opts: UnpullOptions): UnpullSummary { + const root = resolveUnpullRoot(opts.install, opts.cwd); + const summary: UnpullSummary = { + scanned: 0, removed: 0, wouldRemove: 0, kept: 0, manifestPruned: 0, entries: [], + }; + + const userFilter = new Set(opts.users.filter(u => u.length > 0)); + const haveUserFilter = userFilter.size > 0; + + // `--all` and `--legacy-cleanup` walk the disk for entries that aren't in + // the manifest, so they have no author metadata to filter on. Combining + // them with an author filter would silently ignore the filter for those + // entries — an over-removal footgun. Refuse the combination loudly and + // make the user run two passes (one filtered, then one with --all). + if ((opts.all || opts.legacyCleanup) && (haveUserFilter || opts.notMine)) { + const flags = [opts.all && "--all", opts.legacyCleanup && "--legacy-cleanup"] + .filter(Boolean).join(" / "); + const filters = [haveUserFilter && "--user/--users", opts.notMine && "--not-mine"] + .filter(Boolean).join(" / "); + throw new Error( + `${flags} cannot be combined with ${filters}: entries removed by ` + + `${flags} are not in the manifest and have no author metadata, so ` + + `the filter would silently fail to apply. Run the filtered unpull ` + + `first, then ${flags} as a separate invocation.`, + ); + } + + // ── Pass 1: manifest-driven removal of pulled entries ──────────────────── + const manifest = loadManifest(); + const entries = entriesForRoot(manifest, opts.install, root); + for (const entry of entries) { + summary.scanned++; + const path = join(root, entry.dirName); + + if (!existsSync(path)) { + // Drift: manifest says it was here, disk disagrees. Prune the entry. + // Use the entry's own installRoot rather than the resolved `root` so + // we drop exactly the row that pointed here, even if a parallel pull + // wrote a same-named skill into a different installRoot. + if (!opts.dryRun) removePullEntry(opts.install, entry.installRoot, entry.dirName); + summary.entries.push({ + dirName: entry.dirName, + kind: "manifest-orphan", + author: entry.author, + name: entry.name, + action: opts.dryRun ? "kept-policy" : "manifest-pruned", + reason: opts.dryRun ? "would-prune (orphan, dir missing)" : "directory was already missing", + path: "", + }); + if (!opts.dryRun) summary.manifestPruned++; + else summary.kept++; + continue; + } + + const decision = decideTargetForManifestEntry(entry, opts, userFilter, haveUserFilter); + const result: UnpullResultEntry = { + dirName: entry.dirName, + kind: "pulled-manifest", + author: entry.author, + name: entry.name, + action: "kept-policy", + path, + }; + + if (!decision.shouldRemove) { + result.reason = decision.reason; + summary.kept++; + summary.entries.push(result); + continue; + } + + if (opts.dryRun) { + result.action = "would-remove"; + summary.wouldRemove++; + } else { + try { + rmSync(path, { recursive: true, force: true }); + removePullEntry(opts.install, entry.installRoot, entry.dirName); + result.action = "removed"; + summary.removed++; + } catch (e: any) { + result.action = "kept-policy"; + result.reason = `rm failed: ${e?.message ?? e}`; + summary.kept++; + } + } + summary.entries.push(result); + } + + // ── Pass 2: optional disk-walk for `--all` and `--legacy-cleanup` ─────── + // Only walk the disk when the user explicitly opts in to one of these, + // since any matching dir here is by definition NOT in the manifest. + if (existsSync(root) && (opts.all || opts.legacyCleanup)) { + const manifestDirNames = new Set(entries.map(e => e.dirName)); + for (const dirName of readdirSync(root)) { + // Already handled in pass 1. + if (manifestDirNames.has(dirName)) continue; + + const path = join(root, dirName); + let st; + try { st = statSync(path); } catch { continue; } + if (!st.isDirectory()) continue; + + const isLegacyProjectKey = /^[0-9a-f]{16}$/.test(dirName); + const isLocallyMined = !isLegacyProjectKey && /^[A-Za-z0-9_.-]+$/.test(dirName) && !dirName.includes("--"); + + let kind: UnpullResultEntry["kind"]; + let shouldRemove = false; + let reason: string | undefined; + + if (isLegacyProjectKey) { + kind = "legacy-projectkey"; + if (opts.legacyCleanup) shouldRemove = true; + else reason = "legacy project_key dir (use --legacy-cleanup)"; + } else if (isLocallyMined) { + kind = "locally-mined"; + if (opts.all) shouldRemove = true; + else reason = "locally-mined (use --all to remove)"; + } else { + // Has `--` but not in manifest, or unrecognized characters: NEVER + // touch. Most common case is a user's own variant skill like + // `deploy--blue-green` that happens to mimic the pull naming. + continue; + } + + summary.scanned++; + const result: UnpullResultEntry = { + dirName, + kind, + author: null, + name: kind === "locally-mined" ? dirName : null, + action: "kept-policy", + path, + reason, + }; + + if (!shouldRemove) { + summary.kept++; + summary.entries.push(result); + continue; + } + + if (opts.dryRun) { + result.action = "would-remove"; + summary.wouldRemove++; + } else { + try { + rmSync(path, { recursive: true, force: true }); + result.action = "removed"; + summary.removed++; + } catch (e: any) { + result.action = "kept-policy"; + result.reason = `rm failed: ${e?.message ?? e}`; + summary.kept++; + } + } + summary.entries.push(result); + } + } + + return summary; +} + +function decideTargetForManifestEntry( + entry: PulledEntry, + opts: UnpullOptions, + userFilter: Set, + haveUserFilter: boolean, +): { shouldRemove: boolean; reason?: string } { + if (haveUserFilter && !userFilter.has(entry.author)) { + return { shouldRemove: false, reason: `author '${entry.author}' not in filter` }; + } + if (opts.notMine) { + if (!opts.myUsername) return { shouldRemove: false, reason: "--not-mine requires myUsername" }; + if (entry.author === opts.myUsername) { + return { shouldRemove: false, reason: "your own pull (--not-mine excludes self)" }; + } + } + return { shouldRemove: true }; +}