skilllib 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,7 @@ function table(rows) {
29
29
  }
30
30
 
31
31
  // src/commands.ts
32
- import { basename as basename5, join as join11, resolve as resolve4, sep as sep4 } from "path";
32
+ import { basename as basename6, dirname as dirname5, join as join13, resolve as resolve4, sep as sep5 } from "path";
33
33
 
34
34
  // src/library.ts
35
35
  import { execFileSync } from "child_process";
@@ -112,9 +112,25 @@ function harness(id) {
112
112
  return HARNESSES.find((h) => h.id === id);
113
113
  }
114
114
  var ALL_PROJECT_DIRS = [...new Set(HARNESSES.flatMap((h) => h.projectDirs))];
115
- function installDirs(enabled) {
116
- const needsAgents = enabled.some((id) => !harness(id).projectDirs.includes(".claude/skills"));
117
- return needsAgents ? [".claude/skills", ".agents/skills"] : [".claude/skills"];
115
+ var PREFERRED_DIRS = [AGENTS_SKILLS_DIR, PROJECT_SKILLS_DIR];
116
+ function preference(dir) {
117
+ const i = PREFERRED_DIRS.indexOf(dir);
118
+ return i === -1 ? PREFERRED_DIRS.length : i;
119
+ }
120
+ function cost(dirs) {
121
+ return dirs.length * 100 + dirs.reduce((sum, d) => sum + preference(d), 0);
122
+ }
123
+ function installDirs(enabled, keep) {
124
+ if (enabled.length === 0) return [keep ?? PROJECT_SKILLS_DIR];
125
+ let best = [];
126
+ for (let mask = 1; mask < 1 << ALL_PROJECT_DIRS.length; mask++) {
127
+ const dirs = ALL_PROJECT_DIRS.filter((_, i) => mask & 1 << i).sort((a, b) => preference(a) - preference(b));
128
+ if (keep && !dirs.includes(keep)) continue;
129
+ if (!enabled.every((id) => harness(id).projectDirs.some((d) => dirs.includes(d)))) continue;
130
+ if (best.length === 0 || cost(dirs) < cost(best)) best = dirs;
131
+ }
132
+ const primary = keep ?? best[0];
133
+ return [primary, ...best.filter((d) => d !== primary)];
118
134
  }
119
135
 
120
136
  // src/config.ts
@@ -400,6 +416,9 @@ function readJson(path) {
400
416
  function skillsShLock() {
401
417
  return readJson(join7(resolve3(claudeDir(), ".."), ".agents", ".skill-lock.json"))?.skills ?? {};
402
418
  }
419
+ function projectSkillsLock(root) {
420
+ return readJson(join7(root, "skills-lock.json"))?.skills ?? {};
421
+ }
403
422
  function realpathOrNull(path) {
404
423
  try {
405
424
  return realpathSync2(path);
@@ -529,11 +548,11 @@ function pluginSkills() {
529
548
  }));
530
549
  }
531
550
  function machineSkills(enabled = enabledHarnesses()) {
532
- const claude = enabled.includes("claude-code");
551
+ const claude2 = enabled.includes("claude-code");
533
552
  return [
534
553
  ...globalFolderSkills(enabled),
535
- ...claude ? claudeAiSkills() : [],
536
- ...claude ? pluginSkills() : [],
554
+ ...claude2 ? claudeAiSkills() : [],
555
+ ...claude2 ? pluginSkills() : [],
537
556
  ...cursorBuiltInSkills(enabled)
538
557
  ].sort(
539
558
  (a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name)
@@ -555,7 +574,9 @@ function originFor(dir) {
555
574
  const match = machineSkills().find((s) => resolve3(s.path) === resolve3(dir));
556
575
  if (match) return match.kind === "global" ? "global folder" : `${match.kind}: ${match.origin}`;
557
576
  const project = dir.split(/[\\/]\.(?:claude|agents)[\\/]skills[\\/]/)[0];
558
- return project && project !== dir ? `project: ${basename2(project)}` : dir;
577
+ if (!project || project === dir) return dir;
578
+ const locked = projectSkillsLock(project)[basename2(dir)]?.source;
579
+ return locked ? `skills.sh: ${locked}` : `project: ${basename2(project)}`;
559
580
  }
560
581
 
561
582
  // src/library.ts
@@ -596,11 +617,11 @@ function visibilityOf(root, name, enabled = enabledHarnesses()) {
596
617
  paths: harness(id).projectDirs.filter((dir) => existsSync7(join8(root, dir, name, "SKILL.md"))).length
597
618
  }));
598
619
  }
599
- function dependencyDirs(dep) {
620
+ function dependencyDirs(root, name, dep) {
600
621
  const wanted = installDirs(enabledHarnesses());
601
- if (!dep) return wanted;
602
- const primary = dep.dir ?? PROJECT_SKILLS_DIR;
603
- return [primary, .../* @__PURE__ */ new Set([...dep.links ?? [], ...wanted.filter((d) => d !== primary)])];
622
+ const untracked = ALL_PROJECT_DIRS.find((d) => existsSync7(join8(root, d, name, "SKILL.md")) && !isLink(join8(root, d, name)));
623
+ const primary = dep ? dep.dir ?? PROJECT_SKILLS_DIR : untracked ?? wanted[0];
624
+ return [primary, .../* @__PURE__ */ new Set([...dep?.links ?? [], ...wanted.filter((d) => d !== primary)])];
604
625
  }
605
626
  function isGitTracked(root, dir) {
606
627
  try {
@@ -633,6 +654,7 @@ function projectStatus(root) {
633
654
  };
634
655
  });
635
656
  const seen = new Set(Object.keys(skills));
657
+ const lock = projectSkillsLock(root);
636
658
  const unmanaged = [];
637
659
  const byName = /* @__PURE__ */ new Map();
638
660
  for (const dir of ALL_PROJECT_DIRS) {
@@ -649,7 +671,8 @@ function projectStatus(root) {
649
671
  const local = treeHash(real);
650
672
  const sameAsLibrary = latest !== null && (latest.hash === local || versionForHash(name, local ?? "") !== null);
651
673
  const committed = location === AGENTS_SKILLS_DIR;
652
- const state = committed ? latest === null ? "repo skill" : sameAsLibrary ? "repo skill, in library" : "repo skill, differs from library" : latest === null ? "local only" : sameAsLibrary ? "untracked copy of library skill" : "untracked, differs from library";
674
+ const source = lock[name]?.source;
675
+ const state = source ? "from npx skills" : committed ? latest === null ? "repo skill" : sameAsLibrary ? "repo skill, in library" : "repo skill, differs from library" : latest === null ? "local only" : sameAsLibrary ? "untracked copy of library skill" : "untracked, differs from library";
653
676
  unmanaged.push({
654
677
  name,
655
678
  managed: false,
@@ -658,7 +681,8 @@ function projectStatus(root) {
658
681
  latest: latest?.version ?? null,
659
682
  location,
660
683
  path: real,
661
- visibility: visibilityOf(root, name, enabled)
684
+ visibility: visibilityOf(root, name, enabled),
685
+ ...source ? { source } : {}
662
686
  });
663
687
  }
664
688
  return [...managed, ...unmanaged].sort((a, b) => a.name.localeCompare(b.name));
@@ -716,7 +740,7 @@ function addSkill(root, name, { force = false, version, allowTracked = false } =
716
740
  if (!target) return { name, action: "skipped", reason: version === void 0 ? "not in the library" : `version ${version} doesn't exist` };
717
741
  const manifest = readManifest(root);
718
742
  const recorded = manifest.skills[name];
719
- const [primary = PROJECT_SKILLS_DIR, ...linkDirs] = dependencyDirs(recorded);
743
+ const [primary = PROJECT_SKILLS_DIR, ...linkDirs] = dependencyDirs(root, name, recorded);
720
744
  const to = join8(root, primary, name);
721
745
  const local = isLink(to) && !recorded ? null : treeHash(to);
722
746
  const knownContent = local !== null && (local === recorded?.hash || versionForHash(name, local) !== null);
@@ -887,7 +911,7 @@ Instructions for Claude go here.
887
911
  return { ok: true, dir };
888
912
  }
889
913
  function listBackups() {
890
- return ["trash", "global-backup"].flatMap((kind) => {
914
+ return ["trash", "global-backup", "tidy-backup"].flatMap((kind) => {
891
915
  const dir = join8(skilllibHome(), kind);
892
916
  if (!existsSync7(dir)) return [];
893
917
  const index = readBackupIndex(kind);
@@ -902,6 +926,7 @@ function listBackups() {
902
926
  }
903
927
  function restoreBackup(backup) {
904
928
  const to = backup.from;
929
+ if (backup.kind === "tidy-backup" && isLink(to)) unlinkSync(to);
905
930
  let taken = existsSync7(to);
906
931
  try {
907
932
  taken = taken || lstatSync2(to) !== null;
@@ -1020,20 +1045,394 @@ async function scanUsage(days) {
1020
1045
  function summarize(uses, projectOf = () => null) {
1021
1046
  const bySkill = /* @__PURE__ */ new Map();
1022
1047
  for (const use of uses) {
1023
- const summary = bySkill.get(use.skill) ?? { skill: use.skill, uses: 0, lastUsed: "", projects: /* @__PURE__ */ new Set() };
1048
+ const skill = use.skill.split(":").pop();
1049
+ const summary = bySkill.get(skill) ?? { skill, uses: 0, lastUsed: "", projects: /* @__PURE__ */ new Set() };
1024
1050
  summary.uses += 1;
1025
1051
  if (use.at > summary.lastUsed) summary.lastUsed = use.at;
1026
1052
  const project = projectOf(use.cwd);
1027
1053
  if (project) summary.projects.add(project);
1028
- bySkill.set(use.skill, summary);
1054
+ bySkill.set(skill, summary);
1029
1055
  }
1030
1056
  return [...bySkill.values()].sort((a, b) => b.uses - a.uses);
1031
1057
  }
1032
1058
 
1033
1059
  // src/health.ts
1034
- import { basename as basename4, join as join10 } from "path";
1060
+ import { basename as basename5 } from "path";
1061
+
1062
+ // src/git.ts
1063
+ import { execFileSync as execFileSync2 } from "child_process";
1064
+ import { existsSync as existsSync9 } from "fs";
1065
+ import { join as join10, relative as relative3 } from "path";
1066
+ function git(root, args, input) {
1067
+ try {
1068
+ return execFileSync2("git", args, { cwd: root, encoding: "utf-8", input, stdio: ["pipe", "pipe", "ignore"], maxBuffer: 16 * 1024 * 1024 });
1069
+ } catch (err) {
1070
+ const out = err.stdout;
1071
+ return typeof out === "string" ? out : null;
1072
+ }
1073
+ }
1074
+ function gitInfo(root) {
1075
+ if (!existsSync9(join10(root, ".git"))) return null;
1076
+ const dirs = [...ALL_PROJECT_DIRS, MANIFEST_FILE].filter((d) => existsSync9(join10(root, d)));
1077
+ if (dirs.length === 0) return { of: () => "new" };
1078
+ const tracked = (git(root, ["ls-files", "-z", "--", ...dirs]) ?? "").split("\0").filter(Boolean);
1079
+ const dirty = (git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ...dirs]) ?? "").split("\0").filter(Boolean).map((entry) => entry.slice(3));
1080
+ const ignoredCache = /* @__PURE__ */ new Map();
1081
+ const isIgnored = (rel) => {
1082
+ if (!ignoredCache.has(rel)) {
1083
+ const out = git(root, ["check-ignore", "--no-index", "--", rel]) ?? "";
1084
+ ignoredCache.set(rel, out.trim().length > 0);
1085
+ }
1086
+ return ignoredCache.get(rel);
1087
+ };
1088
+ const under = (list2, rel) => list2.some((p) => p === rel || p.startsWith(rel + "/"));
1089
+ return {
1090
+ of: (relPath) => {
1091
+ const rel = relPath.replace(/\/+$/, "");
1092
+ if (under(tracked, rel)) return under(dirty, rel) ? "changed" : "committed";
1093
+ return isIgnored(rel) ? "ignored" : "new";
1094
+ }
1095
+ };
1096
+ }
1097
+ function relativeTo(root, path) {
1098
+ return relative3(root, path).split("\\").join("/");
1099
+ }
1100
+
1101
+ // src/plugins.ts
1102
+ import { execFileSync as execFileSync3 } from "child_process";
1103
+ import { existsSync as existsSync10, mkdirSync as mkdirSync6, readdirSync as readdirSync6, readFileSync as readFileSync7, statSync as statSync3, writeFileSync as writeFileSync6 } from "fs";
1104
+ import { basename as basename4, dirname as dirname3, join as join11 } from "path";
1105
+ function readJson2(path) {
1106
+ try {
1107
+ return JSON.parse(readFileSync7(path, "utf-8"));
1108
+ } catch {
1109
+ return null;
1110
+ }
1111
+ }
1112
+ function nonEmptyDir(path) {
1113
+ try {
1114
+ return readdirSync6(path).some((f) => !f.startsWith("."));
1115
+ } catch {
1116
+ return false;
1117
+ }
1118
+ }
1119
+ function extrasOf(root) {
1120
+ const manifest = readJson2(join11(root, ".claude-plugin", "plugin.json")) ?? {};
1121
+ return [
1122
+ ...existsSync10(join11(root, ".mcp.json")) || manifest.mcpServers ? ["MCP servers"] : [],
1123
+ ...existsSync10(join11(root, "hooks", "hooks.json")) || manifest.hooks ? ["hooks"] : [],
1124
+ ...nonEmptyDir(join11(root, "agents")) || manifest.agents ? ["agents"] : [],
1125
+ ...nonEmptyDir(join11(root, "commands")) || manifest.commands ? ["commands"] : []
1126
+ ];
1127
+ }
1128
+ function claudePlugins(machine) {
1129
+ const installed = readJson2(join11(claudeDir(), "plugins", "installed_plugins.json"))?.plugins ?? {};
1130
+ const byOrigin = /* @__PURE__ */ new Map();
1131
+ for (const m of machine) if (m.kind === "plugin") byOrigin.set(m.origin, [...byOrigin.get(m.origin) ?? [], m.path]);
1132
+ return [...byOrigin].map(([origin, skills]) => {
1133
+ const synced = origin.endsWith(" (claude.ai)");
1134
+ const id = synced ? `${origin.replace(" (claude.ai)", "")}@synced` : origin;
1135
+ const entry = installed[origin]?.[0];
1136
+ return {
1137
+ id,
1138
+ scope: synced ? null : entry?.scope ?? "user",
1139
+ ...entry?.projectPath ? { projectPath: entry.projectPath } : {},
1140
+ synced,
1141
+ skills,
1142
+ extras: extrasOf(dirname3(dirname3(skills[0])))
1143
+ };
1144
+ });
1145
+ }
1146
+ function claudeBinary() {
1147
+ const candidates = ["claude", join11(userHome(), ".local", "bin", "claude"), join11(userHome(), ".claude", "local", "claude")];
1148
+ return candidates.find((bin) => {
1149
+ try {
1150
+ return execFileSync3(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 3e4, encoding: "utf-8" }).includes("Claude Code");
1151
+ } catch {
1152
+ return false;
1153
+ }
1154
+ }) ?? null;
1155
+ }
1156
+ function claude(args, cwd) {
1157
+ const bin = claudeBinary();
1158
+ if (!bin) return { ok: false, reason: `Claude Code's CLI didn't run (tried \`claude\` on your PATH and ~/.local/bin/claude); run \`claude ${args.join(" ")}\` yourself` };
1159
+ try {
1160
+ execFileSync3(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"], timeout: 12e4, encoding: "utf-8" });
1161
+ return { ok: true };
1162
+ } catch (err) {
1163
+ const e = err;
1164
+ return { ok: false, reason: `${(e.stderr || e.stdout || e.message || "failed").trim().split("\n").slice(-1)[0]} (ran ${bin})` };
1165
+ }
1166
+ }
1167
+ function turnOffSynced(id) {
1168
+ const file = join11(claudeDir(), "settings.json");
1169
+ const settings = existsSync10(file) ? readJson2(file) : {};
1170
+ if (!settings) return { ok: false, reason: `can't read ${file}` };
1171
+ const enabled = settings.enabledPlugins ?? {};
1172
+ mkdirSync6(claudeDir(), { recursive: true });
1173
+ writeFileSync6(file, JSON.stringify({ ...settings, enabledPlugins: { ...enabled, [id]: false } }, null, 2) + "\n");
1174
+ return { ok: true };
1175
+ }
1176
+ function removedFile() {
1177
+ return join11(skilllibHome(), "plugin-backup.json");
1178
+ }
1179
+ function removePlugin(plugin, how) {
1180
+ if (plugin.scope && plugin.scope !== "user" && !plugin.projectPath) {
1181
+ return { ok: false, message: `${plugin.id}: installed for one project; run \`claude plugin ${how === "delete" ? "uninstall" : "disable"} ${plugin.id} --scope ${plugin.scope}\` there` };
1182
+ }
1183
+ if (!plugin.synced && !claudeBinary()) {
1184
+ return { ok: false, message: `${plugin.id}: nothing changed; Claude Code's CLI didn't run (tried \`claude\` on your PATH and ~/.local/bin/claude)` };
1185
+ }
1186
+ const imported = plugin.skills.filter((dir) => latestVersion(basename4(dir)) === null).map((dir) => importSkill(dir).name);
1187
+ const scope = plugin.scope ? ["--scope", plugin.scope] : [];
1188
+ const r = plugin.synced ? turnOffSynced(plugin.id) : claude(["plugin", how === "delete" ? "uninstall" : "disable", plugin.id, ...scope], plugin.projectPath);
1189
+ const saved = imported.length ? `; ${imported.join(", ")} copied into Your skills` : "";
1190
+ if (!r.ok) return { ok: false, message: `${plugin.id}: ${r.reason}${saved}` };
1191
+ if (how === "delete" && !plugin.synced) {
1192
+ const list2 = readJson2(removedFile()) ?? [];
1193
+ mkdirSync6(skilllibHome(), { recursive: true });
1194
+ writeFileSync6(removedFile(), JSON.stringify([...list2, { id: plugin.id, scope: plugin.scope ?? "user", at: (/* @__PURE__ */ new Date()).toISOString() }], null, 2) + "\n");
1195
+ }
1196
+ return { ok: true, message: `${plugin.id} ${how === "delete" && !plugin.synced ? "removed" : "turned off"}${saved}` };
1197
+ }
1198
+ function pluginBackups() {
1199
+ return (readJson2(removedFile()) ?? []).map((r) => ({
1200
+ name: r.id,
1201
+ kind: "plugin",
1202
+ path: r.scope,
1203
+ movedAt: r.at.slice(0, 16).replace("T", " "),
1204
+ from: r.id
1205
+ }));
1206
+ }
1207
+ function restorePlugin(backup) {
1208
+ const r = claude(["plugin", "install", backup.from, "--scope", backup.path]);
1209
+ if (!r.ok) return r;
1210
+ writeFileSync6(removedFile(), JSON.stringify((readJson2(removedFile()) ?? []).filter((x) => x.id !== backup.from), null, 2) + "\n");
1211
+ return { ok: true, to: `Claude Code plugins (${backup.path})` };
1212
+ }
1213
+ function cursorPluginSkills() {
1214
+ const cache = join11(userHome(), ".cursor", "plugins", "cache");
1215
+ const dirs = (p) => existsSync10(p) ? readdirSync6(p).filter((d) => !d.startsWith(".") && statSync3(join11(p, d)).isDirectory()) : [];
1216
+ return dirs(cache).flatMap(
1217
+ (marketplace) => dirs(join11(cache, marketplace)).flatMap((plugin) => {
1218
+ const versions = dirs(join11(cache, marketplace, plugin)).map((v) => join11(cache, marketplace, plugin, v));
1219
+ const newest = versions.sort((a, b) => statSync3(b).mtimeMs - statSync3(a).mtimeMs)[0];
1220
+ return newest ? skillDirsIn(join11(newest, "skills")).map((path) => ({ name: basename4(path), plugin, path })) : [];
1221
+ })
1222
+ );
1223
+ }
1224
+
1225
+ // src/tidy.ts
1226
+ import { existsSync as existsSync11, mkdirSync as mkdirSync7, readdirSync as readdirSync7, unlinkSync as unlinkSync2 } from "fs";
1227
+ import { dirname as dirname4, join as join12, sep as sep4 } from "path";
1228
+ function entriesOf(dirs, name) {
1229
+ return dirs.flatMap((dir) => {
1230
+ const path = join12(dir, name);
1231
+ if (!entryExists(path)) return [];
1232
+ const link2 = isLink(path);
1233
+ const real = realpathOrNull2(path);
1234
+ const skill = existsSync11(join12(path, "SKILL.md"));
1235
+ if (!link2 && !skill) return [];
1236
+ return [{ dir, path, link: link2, real, hash: null }];
1237
+ });
1238
+ }
1239
+ function hashReals(reals) {
1240
+ if (reals.length > 1) for (const r of reals) r.hash = treeHash(r.path);
1241
+ }
1242
+ function namesIn(dirs) {
1243
+ const names = /* @__PURE__ */ new Set();
1244
+ for (const dir of dirs) {
1245
+ if (!existsSync11(dir)) continue;
1246
+ for (const name of readdirSync7(dir)) if (!name.startsWith(".") && name !== "synced") names.add(name);
1247
+ }
1248
+ return [...names].sort();
1249
+ }
1250
+ function runsFrom(id, dirsWithCopy, readsOf) {
1251
+ const reads = readsOf(id).filter((d) => dirsWithCopy.includes(d));
1252
+ return reads.find((d) => d.endsWith(join12(".claude", "skills"))) ?? reads[0];
1253
+ }
1254
+ function conflictOf(name, root, reals, enabled, readsOf) {
1255
+ const dirs = reals.map((r) => r.dir);
1256
+ return {
1257
+ name,
1258
+ root,
1259
+ copies: reals.map((r) => ({ dir: r.dir, path: r.path, runs: enabled.filter((id) => runsFrom(id, dirs, readsOf) === r.dir) }))
1260
+ };
1261
+ }
1262
+ function planProjectTidy(root, { keep = {}, enabled = enabledHarnesses() } = {}) {
1263
+ const report = { plans: [], conflicts: [], skipped: [] };
1264
+ const { skills } = readManifest(root);
1265
+ const abs = (dir) => join12(root, dir);
1266
+ const rel = (dir) => relativeTo(root, dir);
1267
+ const readsOf = (id) => HARNESSES.find((h) => h.id === id).projectDirs.map(abs);
1268
+ let git2;
1269
+ const skillsLock = projectSkillsLock(root);
1270
+ for (const name of namesIn(ALL_PROJECT_DIRS.map(abs))) {
1271
+ const entries = entriesOf(ALL_PROJECT_DIRS.map(abs), name);
1272
+ const external = entries.find((e) => e.link && e.real && !(e.real + sep4).startsWith(realpathOrNull2(root) + sep4));
1273
+ if (external) {
1274
+ report.skipped.push({ name, reason: `${rel(external.path)} links outside the repo` });
1275
+ continue;
1276
+ }
1277
+ const reals = entries.filter((e) => !e.link);
1278
+ if (reals.length === 0) continue;
1279
+ hashReals(reals);
1280
+ const dep = skills[name];
1281
+ const chosen = keep[name];
1282
+ let primary;
1283
+ if (dep) {
1284
+ primary = reals.find((r) => rel(r.dir) === (dep.dir ?? PROJECT_SKILLS_DIR));
1285
+ if (!primary) continue;
1286
+ } else if (chosen) {
1287
+ primary = reals.find((r) => r.dir === chosen);
1288
+ } else {
1289
+ if (git2 === void 0) git2 = gitInfo(root);
1290
+ const planned = installDirs(enabled).map(abs);
1291
+ const fromSkillsSh = name in skillsLock;
1292
+ const rank = (e) => (git2 && ["committed", "changed"].includes(git2.of(rel(e.path))) ? 0 : 1e3) + (fromSkillsSh && e.dir === abs(AGENTS_SKILLS_DIR) ? 0 : 100) + (planned.includes(e.dir) ? planned.indexOf(e.dir) : 50);
1293
+ primary = [...reals].sort((a, b) => rank(a) - rank(b))[0];
1294
+ }
1295
+ if (!primary) continue;
1296
+ if (chosen !== primary.dir && reals.some((r) => r.hash !== primary.hash)) {
1297
+ report.conflicts.push({ ...conflictOf(name, root, reals, enabled, readsOf), ...dep ? { managed: primary.dir } : {} });
1298
+ continue;
1299
+ }
1300
+ const wanted = new Set([...installDirs(enabled, rel(primary.dir)), ...dep?.links ?? []].map(abs));
1301
+ const steps = [];
1302
+ for (const e of entries) {
1303
+ if (e === primary) continue;
1304
+ const needed = wanted.has(e.dir);
1305
+ if (e.link) {
1306
+ if (e.real === primary.real) {
1307
+ if (!needed) steps.push({ kind: "unlink", path: e.path });
1308
+ } else steps.push(needed ? { kind: "link", path: e.path, target: primary.path } : { kind: "unlink", path: e.path });
1309
+ } else {
1310
+ steps.push(needed ? { kind: "replace", path: e.path, target: primary.path } : { kind: "remove", path: e.path });
1311
+ }
1312
+ }
1313
+ if (steps.length) report.plans.push({ name, root, primary: primary.path, steps });
1314
+ }
1315
+ return report;
1316
+ }
1317
+ function globalReadsOf(id) {
1318
+ return HARNESSES.find((h) => h.id === id).globalDirs();
1319
+ }
1320
+ function isSkillsDirPlugin(path) {
1321
+ return existsSync11(join12(path, ".claude-plugin", "plugin.json"));
1322
+ }
1323
+ function planGlobalTidy({ keep = {}, enabled = enabledHarnesses() } = {}) {
1324
+ const report = { plans: [], conflicts: [], skipped: [] };
1325
+ const dirs = globalSkillDirs();
1326
+ const agents = join12(userHome(), ".agents", "skills");
1327
+ for (const name of namesIn(dirs)) {
1328
+ const entries = entriesOf(dirs, name);
1329
+ if (entries.some((e) => isSkillsDirPlugin(e.path))) {
1330
+ report.skipped.push({ name, reason: "a Claude Code plugin kept in a skills folder" });
1331
+ continue;
1332
+ }
1333
+ const reals = entries.filter((e) => !e.link);
1334
+ if (reals.length < 2) continue;
1335
+ hashReals(reals);
1336
+ const primary = reals.find((r) => r.dir === keep[name]) ?? reals.find((r) => r.dir === agents) ?? reals[0];
1337
+ if (!keep[name] && reals.some((r) => r.hash !== primary.hash)) {
1338
+ report.conflicts.push(conflictOf(name, null, reals, enabled, globalReadsOf));
1339
+ continue;
1340
+ }
1341
+ const steps = reals.filter((r) => r !== primary).map((r) => ({ kind: "replace", path: r.path, target: primary.path }));
1342
+ report.plans.push({ name, root: null, primary: primary.path, steps });
1343
+ }
1344
+ return report;
1345
+ }
1346
+ function gitVisibleSteps(plan, git2 = plan.root ? gitInfo(plan.root) : null) {
1347
+ if (!plan.root || !git2) return [];
1348
+ return plan.steps.filter((s) => {
1349
+ const state = git2.of(relativeTo(plan.root, s.path));
1350
+ return state === "committed" || state === "changed";
1351
+ });
1352
+ }
1353
+ function applyTidy(plan, { git: git2 = "keep", info: info2 } = {}) {
1354
+ const held = git2 === "keep" ? gitVisibleSteps(plan, info2) : [];
1355
+ const applied = [];
1356
+ for (const step of plan.steps) {
1357
+ if (held.includes(step) || !entryExists(step.path) || "target" in step && !entryExists(step.target)) continue;
1358
+ if (step.kind === "link" || step.kind === "unlink") {
1359
+ if (isLink(step.path)) unlinkSync2(step.path);
1360
+ } else stash(step.path, "tidy-backup");
1361
+ if (step.kind === "link" || step.kind === "replace") {
1362
+ mkdirSync7(dirname4(step.path), { recursive: true });
1363
+ linkDir(step.target, step.path);
1364
+ }
1365
+ applied.push(step);
1366
+ }
1367
+ if (plan.root) {
1368
+ const manifest = readManifest(plan.root);
1369
+ const dep = manifest.skills[plan.name];
1370
+ const added = applied.filter((s) => s.kind === "link" || s.kind === "replace").map((s) => relativeTo(plan.root, dirname4(s.path)));
1371
+ if (dep && added.length) {
1372
+ manifest.skills[plan.name] = { ...dep, links: [.../* @__PURE__ */ new Set([...dep.links ?? [], ...added])] };
1373
+ writeManifest(plan.root, manifest);
1374
+ }
1375
+ }
1376
+ return { name: plan.name, root: plan.root, applied, held };
1377
+ }
1378
+ function describeStep(step, root) {
1379
+ const shown = root ? relativeTo(root, step.path) : step.path.replace(userHome(), "~");
1380
+ switch (step.kind) {
1381
+ case "link":
1382
+ return `link ${shown}`;
1383
+ case "unlink":
1384
+ return `remove extra link ${shown}`;
1385
+ case "replace":
1386
+ return `${shown}: duplicate copy \u2192 link`;
1387
+ case "remove":
1388
+ return `remove duplicate copy ${shown}`;
1389
+ }
1390
+ }
1391
+ function copyLabel(conflict, dir) {
1392
+ return conflict.root ? relativeTo(conflict.root, dir) : dir.replace(userHome(), "~");
1393
+ }
1394
+
1395
+ // src/health.ts
1396
+ function runFix(choice) {
1397
+ try {
1398
+ return { ok: true, message: choice.run() };
1399
+ } catch (err) {
1400
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
1401
+ }
1402
+ }
1403
+ function orThrow(r) {
1404
+ if (!r.ok) throw new Error(r.message);
1405
+ return r.message;
1406
+ }
1407
+ var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
1408
+ var agentNames = (ids) => ids.map((id) => HARNESSES.find((h) => h.id === id).name).join(", ");
1409
+ function conflictIssue(c) {
1410
+ const where = c.root ? basename5(c.root) : "your global folders";
1411
+ const winners = c.managed ? c.copies.filter((x) => x.dir === c.managed) : c.copies;
1412
+ return {
1413
+ id: `conflict:${c.root ?? "global"}:${c.name}`,
1414
+ severity: "problem",
1415
+ title: `${c.name} in ${where}: the copies differ`,
1416
+ detail: `Your agents run different versions: ${c.copies.map((x) => `${copyLabel(c, x.dir)}${x.runs.length ? ` (${agentNames(x.runs)})` : ""}`).join(" vs ")}. Keep one; the others become links to it and are restorable from Health.`,
1417
+ choices: winners.map((x) => ({
1418
+ label: `Keep the ${copyLabel(c, x.dir)} copy`,
1419
+ hint: x.dir === c.managed ? "skilllib's version" : "the others become links to it",
1420
+ run: () => {
1421
+ const keep = { [c.name]: x.dir };
1422
+ const plan = (c.root ? planProjectTidy(c.root, { keep }) : planGlobalTidy({ keep })).plans.find((p) => p.name === c.name);
1423
+ if (plan) applyTidy(plan, { git: "go" });
1424
+ return `${c.name}: kept the ${copyLabel(c, x.dir)} copy`;
1425
+ }
1426
+ }))
1427
+ };
1428
+ }
1429
+ function applyAll(plans, git2) {
1430
+ const results = plans.map((p) => applyTidy(p, { git: git2 }));
1431
+ return { skills: results.filter((r) => r.applied.length).length, held: results.reduce((n, r) => n + r.held.length, 0) };
1432
+ }
1035
1433
  function findIssues(projects2, machine, libraryNames, statusOf = projectStatus) {
1036
1434
  const issues = [];
1435
+ const statuses = new Map(projects2.map((root) => [root, statusOf(root)]));
1037
1436
  for (const skill of machine.filter((m) => m.broken)) {
1038
1437
  issues.push({
1039
1438
  id: `broken:${skill.name}`,
@@ -1051,36 +1450,85 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus)
1051
1450
  }
1052
1451
  const loaded = machine.filter((m) => !m.broken);
1053
1452
  const byName = /* @__PURE__ */ new Map();
1054
- for (const m of loaded) byName.set(m.name, [...byName.get(m.name) ?? [], m]);
1453
+ for (const m of loaded) if (m.kind !== "plugin") byName.set(m.name, [...byName.get(m.name) ?? [], m]);
1055
1454
  for (const [name, copies] of byName) {
1455
+ if (copies.every((c) => c.movable) || !copies.some((c) => c.movable)) continue;
1056
1456
  const harnesses = HARNESSES.filter((h) => copies.filter((c) => c.harnesses.includes(h.id)).length > 1).map((h) => h.name);
1057
1457
  if (harnesses.length === 0) continue;
1058
- const movable = copies.find((c) => c.movable);
1458
+ const vendor = copies.filter((c) => !c.movable);
1059
1459
  issues.push({
1060
1460
  id: `dup:${name}`,
1061
1461
  severity: "problem",
1062
1462
  title: `${name}: loaded twice in ${harnesses.join(" and ")}`,
1063
- detail: copies.map((c) => `${c.kind} (${c.origin})`).join(" and "),
1064
- fix: movable && copies.some((c) => !c.movable) ? {
1065
- label: "Keep the vendor copy, stop loading yours globally",
1066
- run: () => {
1067
- if (!libraryNames.has(name)) importSkill(movable.path);
1068
- const r = unloadGlobal(movable.path, movable.links);
1069
- return r.ok ? `${name} now loads once` : `${name}: ${r.reason}`;
1070
- }
1071
- } : void 0
1463
+ detail: `${copies.map((c) => `${c.kind} (${c.origin})`).join(" and ")}. Your skill wins: turn the ${vendor.map((c) => c.kind).join(" and ")} copy off at its source${vendor.some((c) => c.kind === "claude.ai") ? " (claude.ai \u2192 Settings \u2192 Skills)" : ""}.`
1464
+ });
1465
+ }
1466
+ const loadedYours = /* @__PURE__ */ new Set([...loaded.filter((m) => m.movable).map((m) => m.name), ...[...statuses.values()].flat().map((s) => s.name)]);
1467
+ const yours = /* @__PURE__ */ new Set([...libraryNames, ...loadedYours]);
1468
+ for (const plugin of claudePlugins(machine)) {
1469
+ const dupes = plugin.skills.map((p) => basename5(p)).filter((n) => yours.has(n));
1470
+ if (!dupes.length) continue;
1471
+ const others = plugin.skills.length - dupes.length;
1472
+ const pluginName = plugin.id.split("@")[0];
1473
+ const remove2 = {
1474
+ label: `Remove the plugin ${plugin.id}`,
1475
+ hint: plugin.extras.length ? `also removes its ${plugin.extras.join(", ")}` : "restorable from Health",
1476
+ run: () => orThrow(removePlugin(plugin, "delete"))
1477
+ };
1478
+ const off = { label: `Turn the plugin ${plugin.id} off`, hint: "stays installed", run: () => orThrow(removePlugin(plugin, "off")) };
1479
+ const notLoaded = dupes.filter((n) => !loadedYours.has(n));
1480
+ issues.push({
1481
+ id: `plugin:${plugin.id}`,
1482
+ severity: notLoaded.length === dupes.length ? "suggestion" : "problem",
1483
+ title: `${dupes.join(", ")}: also in the Claude Code plugin ${plugin.id}`,
1484
+ detail: [
1485
+ notLoaded.length < dupes.length ? `Claude Code loads both (the plugin's as /${pluginName}:${dupes[0]}). Your skill wins.` : "",
1486
+ notLoaded.length ? `Your ${notLoaded.join(", ")} ${notLoaded.length === 1 ? "is" : "are"} only in Your skills, not installed anywhere: once the plugin is gone, add ${notLoaded.length === 1 ? "it" : "them"} where you need ${notLoaded.length === 1 ? "it" : "them"}.` : "",
1487
+ others ? `The plugin's other ${plural(others, "skill")} are copied into Your skills first, so nothing is lost.` : "",
1488
+ plugin.extras.length ? `It also brings ${plugin.extras.join(", ")}: ${plugin.synced ? "turning it off pauses those" : "removing it drops those, turning it off pauses them"}.` : "",
1489
+ plugin.synced ? "It's synced from claude.ai, so it can only be turned off here." : ""
1490
+ ].filter(Boolean).join(" "),
1491
+ choices: plugin.synced ? [off] : plugin.extras.length ? [off, remove2] : [remove2, off]
1492
+ });
1493
+ }
1494
+ if (enabledHarnesses().includes("cursor")) {
1495
+ const byPlugin = /* @__PURE__ */ new Map();
1496
+ for (const s of cursorPluginSkills().filter((x) => loadedYours.has(x.name))) byPlugin.set(s.plugin, [...byPlugin.get(s.plugin) ?? [], s.name]);
1497
+ for (const [plugin, names] of byPlugin) {
1498
+ issues.push({
1499
+ id: `cursor-plugin:${plugin}`,
1500
+ severity: "suggestion",
1501
+ title: `${names.join(", ")}: also in the Cursor plugin ${plugin}`,
1502
+ detail: `Cursor lists both copies when that plugin is on. Your skill wins: turn the plugin off in Cursor (Settings \u2192 Plugins).`
1503
+ });
1504
+ }
1505
+ }
1506
+ const global = planGlobalTidy();
1507
+ if (global.plans.length) {
1508
+ issues.push({
1509
+ id: "tidy:global",
1510
+ severity: "suggestion",
1511
+ title: `${plural(global.plans.length, "global skill")} with duplicate copies`,
1512
+ detail: `${global.plans.map((p) => p.name).join(", ")}: each keeps one copy (in ~/.agents/skills when there's one) and the others become links. Restorable from Health.`,
1513
+ fix: {
1514
+ label: "Keep one copy of each",
1515
+ run: () => `${plural(applyAll(global.plans, "go").skills, "global skill")} now have one copy`
1516
+ }
1072
1517
  });
1073
1518
  }
1074
- const globalNames = new Set(loaded.map((m) => m.name));
1519
+ issues.push(...global.conflicts.map(conflictIssue));
1520
+ const kept = keptGlobal();
1521
+ const alsoGlobal = /* @__PURE__ */ new Map();
1522
+ const globalNames = new Set(loaded.filter((m) => m.kind !== "plugin").map((m) => m.name));
1075
1523
  for (const root of projects2) {
1076
- const where = basename4(root);
1077
- const status2 = statusOf(root);
1524
+ const where = basename5(root);
1525
+ const status2 = statuses.get(root);
1078
1526
  const behind = status2.filter((s) => s.state === "update available" || s.state === "folder missing");
1079
1527
  if (behind.length > 0) {
1080
1528
  issues.push({
1081
1529
  id: `sync:${root}`,
1082
1530
  severity: "problem",
1083
- title: `${where}: ${behind.length} skill${behind.length === 1 ? "" : "s"} out of date`,
1531
+ title: `${where}: ${plural(behind.length, "skill")} out of date`,
1084
1532
  detail: behind.map((s) => s.name).join(", "),
1085
1533
  fix: {
1086
1534
  label: "Sync the project",
@@ -1091,49 +1539,123 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus)
1091
1539
  }
1092
1540
  });
1093
1541
  }
1542
+ const tidy2 = planProjectTidy(root);
1543
+ if (tidy2.plans.length) {
1544
+ const git2 = gitInfo(root);
1545
+ const visible2 = tidy2.plans.flatMap((p) => gitVisibleSteps(p, git2).map((s) => describeStep(s, root)));
1546
+ const run = (mode) => () => {
1547
+ const r = applyAll(tidy2.plans, mode);
1548
+ return `${where}: tidied ${plural(r.skills, "skill")}${r.held ? `; left ${plural(r.held, "change")} git would see` : ""}`;
1549
+ };
1550
+ issues.push({
1551
+ id: `tidy:${root}`,
1552
+ severity: "suggestion",
1553
+ title: `${where}: ${plural(tidy2.plans.length, "skill")} with extra copies or links`,
1554
+ detail: tidy2.plans.map((p) => `${p.name}: ${p.steps.map((s) => describeStep(s, root)).join(", ")}`).join(" \xB7 ") + (visible2.length ? `. Git would see ${plural(visible2.length, "of these change")}: ${visible2.join(", ")}.` : ". Replaced copies are restorable from Health."),
1555
+ ...visible2.length ? {
1556
+ choices: [
1557
+ { label: "Tidy, but keep git as it is", hint: `skips the ${plural(visible2.length, "change")} git would see`, run: run("keep") },
1558
+ { label: "Tidy everything", hint: `${plural(visible2.length, "change")} will show in git status`, run: run("go") }
1559
+ ]
1560
+ } : { fix: { label: "Tidy", run: run("go") } }
1561
+ });
1562
+ }
1563
+ issues.push(...tidy2.conflicts.map(conflictIssue));
1564
+ const partial = status2.filter((s) => s.state !== "folder missing" && s.visibility.some((v) => v.paths === 0));
1565
+ if (partial.length) {
1566
+ const missing = [...new Set(partial.flatMap((s) => s.visibility.filter((v) => v.paths === 0).map((v) => v.id)))];
1567
+ const tracked = readConfig().agentsDirOk?.includes(root) ? [] : [...new Set(missing.map((id) => harness(id).projectDirs[0]))].filter((dir) => isGitTracked(root, dir));
1568
+ const run = (allowTracked) => () => {
1569
+ if (allowTracked) allowTrackedLinks(root);
1570
+ const r = linkAll(root, { allowTracked });
1571
+ return `${where}: linked ${plural(r.linked.length, "skill")} for ${agentNames(missing)}${r.blocked.length ? `; skipped ${r.blocked.join(", ")} (git tracks it)` : ""}`;
1572
+ };
1573
+ issues.push({
1574
+ id: `usable:${root}`,
1575
+ severity: "suggestion",
1576
+ title: `${where}: ${plural(partial.length, "skill")} not usable by ${agentNames(missing)}`,
1577
+ detail: `${partial.map((s) => `${s.name} (not ${agentNames(s.visibility.filter((v) => v.paths === 0).map((v) => v.id))})`).join(", ")}. Adding links makes the one real copy reach every agent you use; nothing is copied or moved.${tracked.length ? ` Git tracks ${tracked.join(", ")}, so links there show in git status.` : ""}`,
1578
+ ...tracked.length ? {
1579
+ choices: [
1580
+ { label: "Add links, but not where git tracks the folder", hint: `skips ${tracked.join(", ")}`, run: run(false) },
1581
+ { label: "Add links everywhere", hint: `remembered for ${where}; git will see them`, run: run(true) }
1582
+ ]
1583
+ } : { fix: { label: "Add the links", run: run(false) } }
1584
+ });
1585
+ }
1586
+ const differing = new Set(tidy2.conflicts.map((c) => c.name));
1094
1587
  for (const s of status2) {
1588
+ if (isLink(s.path) || differing.has(s.name)) continue;
1095
1589
  if (s.state === "local only") {
1096
1590
  issues.push({
1097
1591
  id: `local:${root}:${s.name}`,
1098
1592
  severity: "suggestion",
1099
1593
  title: `${s.name}: only exists in ${where}`,
1100
- detail: "Import it into the library to reuse it in other projects and keep it backed up.",
1101
- fix: {
1102
- label: "Import into the library",
1103
- run: () => {
1104
- importSkill(join10(projectSkillsDir(root), s.name));
1105
- addSkill(root, s.name);
1106
- return `${s.name} imported and tracked in ${where}`;
1594
+ detail: "Import it into the library to reuse it in other projects and keep it backed up. The repo then tracks it in skilllib.json.",
1595
+ choices: [
1596
+ {
1597
+ label: "Import into the library",
1598
+ hint: `${where} then tracks it`,
1599
+ run: () => {
1600
+ importSkill(s.path);
1601
+ addSkill(root, s.name);
1602
+ return `${s.name} imported and tracked in ${where}`;
1603
+ }
1107
1604
  }
1108
- }
1605
+ ]
1109
1606
  });
1110
1607
  } else if (s.state === "untracked copy of library skill") {
1111
1608
  issues.push({
1112
1609
  id: `adopt:${root}:${s.name}`,
1113
1610
  severity: "suggestion",
1114
1611
  title: `${s.name} in ${where} isn't tracked`,
1115
- detail: "It's identical to the library copy; track it so future library changes reach it.",
1116
- fix: { label: "Track it", run: () => `${addSkill(root, s.name).name} tracked in ${where}` }
1612
+ detail: "It's identical to the library copy; track it (in skilllib.json) so future library changes reach it.",
1613
+ choices: [{ label: "Track it", hint: `adds it to ${where}'s skilllib.json`, run: () => `${addSkill(root, s.name).name} tracked in ${where}` }]
1117
1614
  });
1118
1615
  }
1119
- if (globalNames.has(s.name) && s.managed) {
1120
- issues.push({
1121
- id: `twice:${root}:${s.name}`,
1122
- severity: "suggestion",
1123
- title: `${s.name}: in ${where} and also loaded globally`,
1124
- detail: "The project copy is enough; the global one loads it in every other project too.",
1125
- fix: machine.some((m) => m.name === s.name && m.movable) ? {
1126
- label: "Stop loading it globally",
1616
+ if (globalNames.has(s.name) && s.managed) alsoGlobal.set(s.name, [...alsoGlobal.get(s.name) ?? [], root]);
1617
+ }
1618
+ }
1619
+ for (const [name, roots] of alsoGlobal) {
1620
+ const wheres = roots.map((r) => basename5(r)).join(", ");
1621
+ if (kept.has(name)) {
1622
+ issues.push({
1623
+ id: `twice:${name}`,
1624
+ severity: "suggestion",
1625
+ title: `${name}: in ${wheres}, and you keep it global`,
1626
+ detail: "The global copy already loads there, so the project copies are extra. Claude Code runs the global one anyway.",
1627
+ choices: [
1628
+ {
1629
+ label: `Remove the copies in ${wheres}`,
1630
+ hint: "the global copy stays",
1127
1631
  run: () => {
1128
- const g = machine.find((m) => m.name === s.name && m.movable);
1129
- if (!libraryNames.has(s.name)) importSkill(g.path);
1130
- const r = unloadGlobal(g.path, g.links);
1131
- return r.ok ? `${s.name} no longer loads globally` : `${s.name}: ${r.reason}`;
1632
+ const removed = roots.filter((root) => removeSkill(root, name).action === "removed").map((r) => basename5(r));
1633
+ return `${name}: removed from ${removed.join(", ") || "no project"}; the global copy stays`;
1132
1634
  }
1133
- } : void 0
1134
- });
1135
- }
1635
+ }
1636
+ ]
1637
+ });
1638
+ continue;
1136
1639
  }
1640
+ const movable = machine.find((m) => m.name === name && m.movable);
1641
+ issues.push({
1642
+ id: `twice:${name}`,
1643
+ severity: "suggestion",
1644
+ title: `${name}: in ${wheres} and also loaded globally`,
1645
+ detail: "The project copies are enough; the global one loads it in every other project too. Or keep it global on purpose (Global \u2192 Enter).",
1646
+ choices: movable ? [
1647
+ {
1648
+ label: "Stop loading it globally",
1649
+ hint: "restorable from Health",
1650
+ run: () => {
1651
+ if (!libraryNames.has(name)) importSkill(movable.path);
1652
+ const r = unloadGlobal(movable.path, movable.links);
1653
+ if (!r.ok) throw new Error(`${name}: ${r.reason}`);
1654
+ return `${name} no longer loads globally`;
1655
+ }
1656
+ }
1657
+ ] : void 0
1658
+ });
1137
1659
  }
1138
1660
  return issues;
1139
1661
  }
@@ -1143,20 +1665,24 @@ var USAGE_DAYS = 30;
1143
1665
  function parseArgs(argv) {
1144
1666
  const daysIdx = argv.indexOf("--days");
1145
1667
  const days = daysIdx >= 0 ? Number(argv[daysIdx + 1]) : USAGE_DAYS;
1668
+ const keepIdx = argv.indexOf("--keep");
1146
1669
  return {
1147
- positional: argv.filter((a, i) => !a.startsWith("--") && argv[i - 1] !== "--days"),
1670
+ positional: argv.filter((a, i) => !a.startsWith("--") && argv[i - 1] !== "--days" && argv[i - 1] !== "--keep"),
1148
1671
  force: argv.includes("--force"),
1149
1672
  all: argv.includes("--all"),
1150
1673
  global: argv.includes("--global"),
1151
1674
  fix: argv.includes("--fix"),
1152
1675
  allowTracked: argv.includes("--allow-tracked"),
1153
- days: Number.isFinite(days) && days > 0 ? days : USAGE_DAYS
1676
+ days: Number.isFinite(days) && days > 0 ? days : USAGE_DAYS,
1677
+ dryRun: argv.includes("--dry-run"),
1678
+ allowGit: argv.includes("--allow-git"),
1679
+ ...keepIdx >= 0 && argv[keepIdx + 1] ? { keep: argv[keepIdx + 1] } : {}
1154
1680
  };
1155
1681
  }
1156
1682
  function colorState(state) {
1157
1683
  if (state === "ok") return green(state);
1158
1684
  if (state === "folder missing" || state === "not in library") return red(state);
1159
- if (state === "local only") return dim(state);
1685
+ if (state === "local only" || state === "from npx skills") return dim(state);
1160
1686
  return yellow(state);
1161
1687
  }
1162
1688
  function printChanges(changes) {
@@ -1176,9 +1702,9 @@ function projectOfFactory(projects2) {
1176
1702
  const sorted = [...projects2].sort((a, b) => b.length - a.length);
1177
1703
  return (cwd) => {
1178
1704
  if (!cwd) return null;
1179
- const known = sorted.find((p) => cwd === p || cwd.startsWith(p + sep4));
1705
+ const known = sorted.find((p) => cwd === p || cwd.startsWith(p + sep5));
1180
1706
  if (known) return known;
1181
- const repo = cwd.split(`${sep4}.claude${sep4}worktrees${sep4}`)[0];
1707
+ const repo = cwd.split(`${sep5}.claude${sep5}worktrees${sep5}`)[0];
1182
1708
  return isProjectCandidate(repo) ? repo : null;
1183
1709
  };
1184
1710
  }
@@ -1194,7 +1720,7 @@ function lastUsed(summary) {
1194
1720
  async function status(args) {
1195
1721
  const root = findProjectRoot();
1196
1722
  const skills = projectStatus(root);
1197
- info(`${basename5(root)} ${dim(tildify(root))}
1723
+ info(`${basename6(root)} ${dim(tildify(root))}
1198
1724
  `);
1199
1725
  if (skills.length === 0) {
1200
1726
  info("No skills in this project yet. Add one from your library with: skilllib add <name>");
@@ -1204,7 +1730,7 @@ async function status(args) {
1204
1730
  table(
1205
1731
  skills.map((s) => ({
1206
1732
  Skill: s.name,
1207
- Status: colorState(s.state),
1733
+ Status: s.source ? dim(`npx skills: ${s.source}`) : colorState(s.state),
1208
1734
  [`Uses (${args.days}d)`]: String(usage2.get(s.name)?.uses ?? 0),
1209
1735
  "Last used": lastUsed(usage2.get(s.name))
1210
1736
  }))
@@ -1230,7 +1756,7 @@ async function list(args) {
1230
1756
  const installs = /* @__PURE__ */ new Map();
1231
1757
  for (const project of visibleProjects()) {
1232
1758
  for (const name of Object.keys(readManifest(project).skills)) {
1233
- installs.set(name, [...installs.get(name) ?? [], basename5(project)]);
1759
+ installs.set(name, [...installs.get(name) ?? [], basename6(project)]);
1234
1760
  }
1235
1761
  }
1236
1762
  const usage2 = await usageBySkill(args.days);
@@ -1272,7 +1798,7 @@ function projects() {
1272
1798
  const managed = skills.filter((s) => s.managed);
1273
1799
  const attention = skills.filter((s) => s.state !== "ok" && s.state !== "local only").length;
1274
1800
  return {
1275
- Project: basename5(root),
1801
+ Project: basename6(root),
1276
1802
  Path: dim(tildify(root)),
1277
1803
  Skills: managed.map((s) => s.name).join(", ") || dim("none"),
1278
1804
  "Local only": String(skills.filter((s) => s.state === "local only").length),
@@ -1304,7 +1830,7 @@ function sync(args) {
1304
1830
  for (const root of roots) {
1305
1831
  if (args.allowTracked) allowTrackedLinks(root);
1306
1832
  const changes = syncProject(root, { force: args.force });
1307
- if (args.all) info(`${basename5(root)} ${dim(tildify(root))}`);
1833
+ if (args.all) info(`${basename6(root)} ${dim(tildify(root))}`);
1308
1834
  if (changes.length === 0) success("Installed versions match skilllib.json");
1309
1835
  else printChanges(changes);
1310
1836
  }
@@ -1315,7 +1841,7 @@ function update(args) {
1315
1841
  for (const root of roots) {
1316
1842
  if (args.allowTracked) allowTrackedLinks(root);
1317
1843
  const changes = updateProject(root, args.positional.length ? args.positional : void 0, { force: args.force });
1318
- if (args.all) info(`${basename5(root)} ${dim(tildify(root))}`);
1844
+ if (args.all) info(`${basename6(root)} ${dim(tildify(root))}`);
1319
1845
  if (changes.length === 0) success("Everything is on the latest version");
1320
1846
  else printChanges(changes);
1321
1847
  }
@@ -1323,7 +1849,7 @@ function update(args) {
1323
1849
  function outdated(args) {
1324
1850
  const roots = args.all ? visibleProjects() : [findProjectRoot()];
1325
1851
  const rows = roots.flatMap(
1326
- (root) => projectStatus(root).filter((s) => s.managed && s.latest !== null && s.version !== null && s.latest > s.version).map((s) => ({ Project: basename5(root), Skill: s.name, Installed: `v${s.version}`, Latest: `v${s.latest}`, Status: colorState(s.state) }))
1852
+ (root) => projectStatus(root).filter((s) => s.managed && s.latest !== null && s.version !== null && s.latest > s.version).map((s) => ({ Project: basename6(root), Skill: s.name, Installed: `v${s.version}`, Latest: `v${s.latest}`, Status: colorState(s.state) }))
1327
1853
  );
1328
1854
  if (rows.length === 0) return success("Everything is on the latest version");
1329
1855
  table(rows);
@@ -1349,10 +1875,10 @@ function importSkills(args) {
1349
1875
  success(`${name} ${dim(result === "unchanged" ? "already in library" : result === "added" ? "added to library" : "library updated")}`);
1350
1876
  const skillsDir = resolve4(dir, "..");
1351
1877
  const root = resolve4(skillsDir, "..", "..");
1352
- if (skillsDir === join11(root, ".claude", "skills") && isProjectCandidate(root)) {
1878
+ if (skillsDir === join13(root, ".claude", "skills") && isProjectCandidate(root)) {
1353
1879
  if (readManifest(root).skills[name]?.hash !== treeHash(dir)) {
1354
1880
  const change = addSkill(root, name);
1355
- if (change.action !== "skipped") info(dim(` ${basename5(root)} now depends on ${name} v${change.to}`));
1881
+ if (change.action !== "skipped") info(dim(` ${basename6(root)} now depends on ${name} v${change.to}`));
1356
1882
  }
1357
1883
  rememberProjects([root]);
1358
1884
  }
@@ -1367,13 +1893,13 @@ function scan(args) {
1367
1893
  }
1368
1894
  const found = discoverProjects();
1369
1895
  const library = new Set(librarySkills().map((s) => s.name));
1370
- const skillNames = (root) => [...skillDirsIn(projectSkillsDir(root)), ...skillDirsIn(agentsSkillsDir(root))].map((d) => basename5(d));
1896
+ const skillNames = (root) => [...skillDirsIn(projectSkillsDir(root)), ...skillDirsIn(agentsSkillsDir(root))].map((d) => basename6(d));
1371
1897
  const withSkills = found.filter((root) => skillNames(root).length > 0);
1372
1898
  info(`Scanned ${roots.map((r) => tildify(r)).join(", ")}: ${found.length} project(s), ${withSkills.length} with skills
1373
1899
  `);
1374
1900
  table(
1375
1901
  withSkills.map((root) => ({
1376
- Project: basename5(root),
1902
+ Project: basename6(root),
1377
1903
  Path: dim(tildify(root)),
1378
1904
  Skills: [...new Set(skillNames(root))].map((n) => library.has(n) ? n : yellow(n)).join(", ")
1379
1905
  }))
@@ -1414,14 +1940,14 @@ function newSkill(args) {
1414
1940
  error(`Can't create ${name}: ${r.reason}`);
1415
1941
  process.exit(1);
1416
1942
  }
1417
- success(`Created ${tildify(join11(r.dir, "SKILL.md"))}`);
1943
+ success(`Created ${tildify(join13(r.dir, "SKILL.md"))}`);
1418
1944
  }
1419
1945
  function restore(args) {
1420
- const backups = listBackups();
1946
+ const backups = [...listBackups(), ...pluginBackups()];
1421
1947
  const name = args.positional[0];
1422
1948
  if (!name) {
1423
1949
  if (backups.length === 0) return info("Nothing to restore.");
1424
- table(backups.map((b) => ({ Skill: b.name, From: b.kind === "trash" ? "library (deleted)" : "~/.claude/skills", Moved: b.movedAt })));
1950
+ table(backups.map((b) => ({ Skill: b.name, From: b.kind === "trash" ? "library (deleted)" : b.kind === "plugin" ? "Claude Code plugin" : tildify(dirname5(b.from)), Moved: b.movedAt })));
1425
1951
  info(dim("\n\u2192 skilllib restore <name>"));
1426
1952
  return;
1427
1953
  }
@@ -1430,7 +1956,7 @@ function restore(args) {
1430
1956
  error(`No backup named ${name}`);
1431
1957
  process.exit(1);
1432
1958
  }
1433
- const r = restoreBackup(backup);
1959
+ const r = backup.kind === "plugin" ? restorePlugin(backup) : restoreBackup(backup);
1434
1960
  if (r.ok) success(`Restored ${name} to ${tildify(r.to)}`);
1435
1961
  else error(`Can't restore ${name}: ${r.reason}`);
1436
1962
  }
@@ -1441,9 +1967,13 @@ function doctor(args) {
1441
1967
  for (const issue of issues) {
1442
1968
  info(`${issue.severity === "problem" ? red("\u25CF") : yellow("\u25C6")} ${issue.title}`);
1443
1969
  info(dim(` ${issue.detail}${issue.fix ? ` \u2192 ${issue.fix.label}` : ""}`));
1444
- if (args.fix && issue.fix) info(` ${green("\u2713")} ${issue.fix.run()}`);
1970
+ if (issue.choices) info(dim(` choose in skilllib \u2192 Health: ${issue.choices.map((c) => c.label).join(" / ")}`));
1971
+ if (args.fix && issue.fix) {
1972
+ const r = runFix(issue.fix);
1973
+ info(` ${r.ok ? green("\u2713") : red("\u2717")} ${r.message}`);
1974
+ }
1445
1975
  }
1446
- if (!args.fix && issues.some((i) => i.fix)) info(dim("\nRun `skilllib doctor --fix` to apply the fixes, or fix them one by one in `skilllib` \u2192 5 Health."));
1976
+ if (!args.fix && issues.some((i) => i.fix)) info(dim("\nRun `skilllib doctor --fix` to apply the fixes, or fix them one by one in `skilllib` \u2192 Health."));
1447
1977
  }
1448
1978
  async function usage(args) {
1449
1979
  const summaries = summarize(await scanUsage(args.days), projectOfFactory(knownProjects()));
@@ -1452,13 +1982,13 @@ async function usage(args) {
1452
1982
  return;
1453
1983
  }
1454
1984
  const library = new Set(librarySkills().map((s) => s.name));
1455
- info(`Skill usage, last ${args.days} days ${dim(`(from ${tildify(join11(claudeDir(), "projects"))})`)}
1985
+ info(`Skill usage, last ${args.days} days ${dim(`(from ${tildify(join13(claudeDir(), "projects"))})`)}
1456
1986
  `);
1457
1987
  table(
1458
1988
  summaries.map((s) => ({
1459
1989
  Skill: s.skill,
1460
1990
  Uses: String(s.uses),
1461
- Projects: [...s.projects].map((p) => basename5(p)).join(", ") || dim("-"),
1991
+ Projects: [...s.projects].map((p) => basename6(p)).join(", ") || dim("-"),
1462
1992
  "Last used": lastUsed(s),
1463
1993
  Library: library.has(s.skill) ? green("yes") : dim("no")
1464
1994
  }))
@@ -1471,7 +2001,7 @@ async function usage(args) {
1471
2001
  }
1472
2002
  function show(args) {
1473
2003
  const name = args.positional[0];
1474
- const dir = name ? join11(libraryDir(), name) : "";
2004
+ const dir = name ? join13(libraryDir(), name) : "";
1475
2005
  if (!name || !isSkillDir(dir)) {
1476
2006
  error(name ? `${name} is not in the library` : "Usage: skilllib show <skill-name>");
1477
2007
  process.exit(1);
@@ -1507,7 +2037,7 @@ function link(args) {
1507
2037
  for (const root of roots) {
1508
2038
  if (args.allowTracked) allowTrackedLinks(root);
1509
2039
  const res = linkAll(root);
1510
- const label = args.all ? `${basename5(root)}: ` : "";
2040
+ const label = args.all ? `${basename6(root)}: ` : "";
1511
2041
  if (res.linked.length) success(`${label}linked ${res.linked.length} skill(s) for your other agents`);
1512
2042
  else if (!args.all) success("Every skill here is already usable by all your agents");
1513
2043
  if (res.blocked.length) warn(`${label}git tracks ${res.blocked.join(", ")}; re-run with --allow-tracked to link there too`);
@@ -1535,11 +2065,43 @@ function globalCommand(args) {
1535
2065
  if (unreviewed) info(dim(`
1536
2066
  ${unreviewed} not reviewed. Keep one global on purpose: skilllib global keep <name>`));
1537
2067
  }
2068
+ function tidy(args) {
2069
+ const names = args.positional;
2070
+ const keepFor = (base) => args.keep ? Object.fromEntries(names.map((n) => [n, base ? resolve4(base, args.keep) : expandHome(args.keep)])) : {};
2071
+ const reports = args.global ? [{ label: "global folders", report: planGlobalTidy({ keep: keepFor(null) }) }] : (args.all ? visibleProjects() : [findProjectRoot()]).map((root) => ({ label: basename6(root), report: planProjectTidy(root, { keep: keepFor(root) }) }));
2072
+ let held = 0;
2073
+ let changed = 0;
2074
+ for (const { label, report } of reports) {
2075
+ const plans = report.plans.filter((p) => !names.length || names.includes(p.name));
2076
+ const conflicts = report.conflicts.filter((c) => !names.length || names.includes(c.name));
2077
+ if (!plans.length && !conflicts.length && !args.all) success(`${label}: every skill has one copy and the links your agents need`);
2078
+ for (const plan of plans) {
2079
+ const result = args.dryRun ? { applied: [], held: gitVisibleSteps(plan) } : applyTidy(plan, { git: args.allowGit ? "go" : "keep" });
2080
+ const show2 = args.dryRun ? plan.steps : result.applied;
2081
+ if (show2.length) info(`${args.dryRun ? dim("would") : green("\u2713")} ${label} \xB7 ${plan.name}: ${show2.map((s) => describeStep(s, plan.root)).join(", ")}`);
2082
+ if (!args.dryRun) for (const step of result.held) info(`${yellow("!")} ${label} \xB7 ${plan.name}: held back, git would see it: ${describeStep(step, plan.root)}`);
2083
+ held += args.dryRun ? 0 : result.held.length;
2084
+ changed += result.applied.length;
2085
+ }
2086
+ for (const c of conflicts) {
2087
+ warn(`${label} \xB7 ${c.name}: the copies differ`);
2088
+ for (const copy of c.copies) {
2089
+ const runs = copy.runs.map((id) => HARNESSES.find((h) => h.id === id).name);
2090
+ info(` ${copyLabel(c, copy.dir)}${runs.length ? dim(` run by ${runs.join(", ")}`) : ""}${c.managed === copy.dir ? dim(" (skilllib's copy)") : ""}`);
2091
+ }
2092
+ const pick = copyLabel(c, c.managed ?? c.copies[0].dir);
2093
+ info(dim(` keep one: skilllib tidy ${c.name}${args.global ? " --global" : ""} --keep ${pick}`));
2094
+ }
2095
+ for (const s of report.skipped.filter((x) => !names.length || names.includes(x.name))) info(dim(`- ${label} \xB7 ${s.name}: skipped, ${s.reason}`));
2096
+ }
2097
+ if (args.dryRun) info(dim("\nNothing changed (--dry-run)."));
2098
+ else if (changed) info(dim("\nReplaced copies are in ~/.skilllib/tidy-backup; `skilllib restore` puts them back."));
2099
+ if (held) info(dim(`${held} change${held === 1 ? "" : "s"} held back because git would see them. Re-run with --allow-git to make them.`));
2100
+ }
1538
2101
 
1539
2102
  export {
1540
2103
  userHome,
1541
2104
  skilllibHome,
1542
- MANIFEST_FILE,
1543
2105
  HARNESSES,
1544
2106
  ALL_PROJECT_DIRS,
1545
2107
  installDirs,
@@ -1585,6 +2147,11 @@ export {
1585
2147
  error,
1586
2148
  scanUsage,
1587
2149
  summarize,
2150
+ gitInfo,
2151
+ relativeTo,
2152
+ pluginBackups,
2153
+ restorePlugin,
2154
+ runFix,
1588
2155
  findIssues,
1589
2156
  parseArgs,
1590
2157
  projectOfFactory,
@@ -1607,5 +2174,6 @@ export {
1607
2174
  show,
1608
2175
  harnessesCommand,
1609
2176
  link,
1610
- globalCommand
2177
+ globalCommand,
2178
+ tidy
1611
2179
  };