skilllib 1.11.3 → 1.11.5

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.
@@ -16,13 +16,13 @@ function realPath(path) {
16
16
  }
17
17
  }
18
18
  function skilllibHome() {
19
- return process.env.SKILLLIB_HOME ?? join(userHome(), ".skilllib");
19
+ return process.env.SKILLLIB_HOME?.trim() || join(userHome(), ".skilllib");
20
20
  }
21
21
  function libraryDir() {
22
22
  return join(skilllibHome(), "library");
23
23
  }
24
24
  function claudeDir() {
25
- return process.env.CLAUDE_CONFIG_DIR ?? join(userHome(), ".claude");
25
+ return process.env.CLAUDE_CONFIG_DIR?.trim() || join(userHome(), ".claude");
26
26
  }
27
27
  function grokDir() {
28
28
  return process.env.GROK_HOME?.trim() || join(userHome(), ".grok");
@@ -81,6 +81,7 @@ function writeState(path, data) {
81
81
  throw err;
82
82
  }
83
83
  }
84
+ var BUILD_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "out", "vendor", "target", "coverage"]);
84
85
 
85
86
  // src/output.ts
86
87
  var jsonMode = false;
@@ -103,8 +104,8 @@ var warn = (msg) => out(`${yellow("!")} ${msg}`);
103
104
  var error = (msg) => console.error(`${red("\u2717")} ${msg}`);
104
105
  function tildify(path) {
105
106
  for (const home2 of /* @__PURE__ */ new Set([resolve2(userHome()), realPath(userHome())]))
106
- if (path === home2 || path.startsWith(home2 + sep)) return ("~" + path.slice(home2.length)).replace(/\\/g, "/");
107
- return path;
107
+ if (path === home2 || path.startsWith(home2 + sep)) path = "~" + path.slice(home2.length);
108
+ return path.replace(/\\/g, "/");
108
109
  }
109
110
  function truncate(s, max) {
110
111
  const line = s.replace(/\s+/g, " ");
@@ -124,13 +125,8 @@ function localStamp(ms) {
124
125
  return new Date(ms - at.getTimezoneOffset() * 6e4).toISOString().slice(0, 16).replace("T", " ");
125
126
  }
126
127
 
127
- // src/agentSkill.ts
128
- import { existsSync as existsSync8, lstatSync as lstatSync3, mkdirSync as mkdirSync5, readFileSync as readFileSync4, rmSync as rmSync4, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
129
- import { join as join9 } from "path";
130
-
131
128
  // src/config.ts
132
129
  import { existsSync as existsSync5, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
133
- import { homedir as homedir2 } from "os";
134
130
  import { join as join6, resolve as resolve4 } from "path";
135
131
 
136
132
  // src/harnesses.ts
@@ -246,7 +242,7 @@ function readSkillInfo(dir) {
246
242
  const skillMd = join3(dir, "SKILL.md");
247
243
  const fallback = { name: basename(dir), description: "" };
248
244
  if (!existsSync2(skillMd)) return fallback;
249
- const match = readFileSync2(skillMd, "utf-8").match(/^---\r?\n([\s\S]*?)\r?\n---/);
245
+ const match = readFileSync2(skillMd, "utf-8").replace(/^\uFEFF/, "").match(/^---\r?\n([\s\S]*?)\r?\n---/);
250
246
  if (!match) return fallback;
251
247
  try {
252
248
  const fm = parseYaml(match[1]);
@@ -255,21 +251,25 @@ function readSkillInfo(dir) {
255
251
  description: typeof fm?.description === "string" ? fm.description.trim() : ""
256
252
  };
257
253
  } catch {
258
- return fallback;
254
+ const line = (key) => match[1].match(new RegExp(`^${key}:[ ]*(.*)$`, "m"))?.[1]?.trim().replace(/^(["'])(.*)\1$/, "$2") ?? "";
255
+ return { name: line("name") || fallback.name, description: line("description") };
259
256
  }
260
257
  }
261
258
  function isSkillDir(dir) {
262
259
  return existsSync2(join3(dir, "SKILL.md"));
263
260
  }
264
- function listFiles(dir) {
261
+ function listFiles(dir, seen = /* @__PURE__ */ new Set()) {
262
+ const real = realpathSync2(dir);
263
+ if (seen.has(real)) return [];
264
+ seen.add(real);
265
265
  return readdirSync(dir).filter((entry) => !IGNORED.has(entry)).flatMap((entry) => {
266
266
  const full = join3(dir, entry);
267
267
  const st = statSync2(full, { throwIfNoEntry: false });
268
- return !st ? [] : st.isDirectory() ? listFiles(full) : [full];
268
+ return !st ? [] : st.isDirectory() ? listFiles(full, seen) : [full];
269
269
  });
270
270
  }
271
271
  function treeHash(dir) {
272
- if (!existsSync2(dir)) return null;
272
+ if (!isDir(dir)) return null;
273
273
  const hash = createHash("sha256");
274
274
  const files = listFiles(dir).map((f) => ({ path: relative(dir, f).split(sep2).join("/"), full: f })).sort((a, b) => a.path.localeCompare(b.path, "en"));
275
275
  for (const file of files) {
@@ -280,10 +280,18 @@ function treeHash(dir) {
280
280
  function copySkill(from, to) {
281
281
  const source = realpathSync2(from);
282
282
  rmSync2(to, { recursive: true, force: true });
283
- cpSync(source, to, { recursive: true, dereference: true, filter: (src) => !IGNORED.has(basename(src)) && existsSync2(src) });
283
+ const loops = (src) => src !== source && ((r) => r === source || source.startsWith(r + sep2))(realpathSync2(src));
284
+ cpSync(source, to, { recursive: true, dereference: true, filter: (src) => !IGNORED.has(basename(src)) && existsSync2(src) && !loops(src) });
285
+ }
286
+ function isDir(path) {
287
+ try {
288
+ return statSync2(path, { throwIfNoEntry: false })?.isDirectory() === true;
289
+ } catch {
290
+ return false;
291
+ }
284
292
  }
285
293
  function skillDirsIn(dir) {
286
- if (!existsSync2(dir)) return [];
294
+ if (!isDir(dir)) return [];
287
295
  return readdirSync(dir, { withFileTypes: true }).filter((d) => (d.isDirectory() || d.isSymbolicLink()) && isSkillDir(join3(dir, d.name))).map((d) => join3(dir, d.name)).sort();
288
296
  }
289
297
 
@@ -357,12 +365,17 @@ function readManifest(root) {
357
365
  } catch {
358
366
  throw new Error(`${path} isn't valid JSON (a merge conflict?): fix it, then run skilllib again`);
359
367
  }
368
+ if (!data || typeof data !== "object" || Array.isArray(data) || data.skills !== void 0 && (typeof data.skills !== "object" || data.skills === null || Array.isArray(data.skills)))
369
+ throw new Error(`${path} isn't a skilllib.json (it should hold an object with "skills": { name: pin })`);
360
370
  const skills = Object.fromEntries(
361
371
  Object.entries(data.skills ?? {}).flatMap(([name, value]) => {
362
- if (name.startsWith("@")) return [];
372
+ if (name.startsWith("@") || !name || name === "." || name === ".." || /[\\/]/.test(name)) return [];
363
373
  if (typeof value === "string") return [[name, { version: versionForHash(name, value)?.version ?? 0, hash: value }]];
364
374
  if (value && typeof value === "object" && "hash" in value) {
365
375
  const dep = value;
376
+ const folders2 = [...typeof dep.dir === "string" ? [dep.dir] : [], ...Array.isArray(dep.links) ? dep.links.map(String) : []];
377
+ const bad = folders2.find((d) => !ALL_PROJECT_DIRS.includes(d));
378
+ if (bad !== void 0) throw new Error(`${path} isn't a valid skilllib.json: ${name}'s folder "${bad}" isn't a skill folder (${ALL_PROJECT_DIRS.join(", ")})`);
366
379
  return [
367
380
  [
368
381
  name,
@@ -375,7 +388,7 @@ function readManifest(root) {
375
388
  ]
376
389
  ];
377
390
  }
378
- return [];
391
+ throw new Error(`${path} isn't a valid skilllib.json: ${name}'s pin has no hash`);
379
392
  })
380
393
  );
381
394
  return { ...data, skills };
@@ -406,7 +419,9 @@ function onePerFolder(paths) {
406
419
  return [...new Set(byReal.values())];
407
420
  }
408
421
  function isProjectCandidate(root) {
409
- return realPath(root) !== realPath(userHome());
422
+ const real = realPath(root);
423
+ const own = [userHome(), skilllibHome(), claudeDir(), codexHome(), grokDir(), join5(userHome(), ".agents"), join5(userHome(), ".cursor")];
424
+ return !own.some((d) => realPath(d) === real);
410
425
  }
411
426
  function rememberProjects(roots) {
412
427
  const incoming = roots.map((r) => resolve3(r)).filter((r) => isProjectCandidate(r) && !isAgentWorktree(r));
@@ -446,7 +461,7 @@ function writeConfig(config) {
446
461
  writeState(configFile(), config);
447
462
  }
448
463
  function expandHome(path) {
449
- return resolve4(path.replace(/^~(?=$|\/)/, homedir2()));
464
+ return resolve4(path.replace(/^~(?=$|[\\/])/, userHome()));
450
465
  }
451
466
  function addRoot(path) {
452
467
  const root = expandHome(path);
@@ -470,7 +485,7 @@ function setHidden(project, hidden) {
470
485
  writeConfig({ ...config, hidden: hidden ? [...rest, project].sort() : rest });
471
486
  return true;
472
487
  }
473
- var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "vendor", "target", "Library", "Applications", "Pictures", "Music", "Movies"]);
488
+ var SKIP_DIRS = /* @__PURE__ */ new Set([...BUILD_DIRS, "Library", "Applications", "Pictures", "Music", "Movies"]);
474
489
  function findProjects(dir, depth = 4) {
475
490
  if (depth < 0 || !existsSync5(dir)) return [];
476
491
  if (isProjectCandidate(dir)) {
@@ -522,10 +537,14 @@ function setKeepGlobal(names, keep) {
522
537
  writeConfig({ ...readConfig(), keepGlobal: [...kept].sort() });
523
538
  }
524
539
 
540
+ // src/agentSkill.ts
541
+ import { existsSync as existsSync8, lstatSync as lstatSync3, mkdirSync as mkdirSync5, readFileSync as readFileSync5, rmSync as rmSync4, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
542
+ import { join as join9 } from "path";
543
+
525
544
  // src/library.ts
526
545
  import { execFileSync } from "child_process";
527
- import { cpSync as cpSync2, existsSync as existsSync7, lstatSync as lstatSync2, mkdirSync as mkdirSync4, readdirSync as readdirSync4, renameSync as renameSync2, rmSync as rmSync3, symlinkSync, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
528
- import { basename as basename3, dirname as dirname3, join as join8, relative as relative2, sep as sep4 } from "path";
546
+ import { cpSync as cpSync2, existsSync as existsSync7, lstatSync as lstatSync2, mkdirSync as mkdirSync4, readdirSync as readdirSync4, readFileSync as readFileSync4, readlinkSync as readlinkSync2, renameSync as renameSync2, rmSync as rmSync3, symlinkSync, unlinkSync, writeFileSync as writeFileSync2, realpathSync as realpathSync3 } from "fs";
547
+ import { basename as basename3, dirname as dirname3, isAbsolute, join as join8, posix, relative as relative2, resolve as resolve6, sep as sep4 } from "path";
529
548
 
530
549
  // src/sources.ts
531
550
  import { existsSync as existsSync6, lstatSync, mkdirSync as mkdirSync3, readdirSync as readdirSync3, readlinkSync } from "fs";
@@ -568,7 +587,7 @@ function globalFolderSkills(enabled) {
568
587
  const lock = skillsShLock();
569
588
  const agentsDir = join7(userHome(), ".agents", "skills");
570
589
  const entries = globalSkillDirs().flatMap((dir) => {
571
- if (!existsSync6(dir)) return [];
590
+ if (!isDir(dir)) return [];
572
591
  return readdirSync3(dir).filter((name) => !name.startsWith(".") && name !== "synced").flatMap((name) => {
573
592
  const path = join7(dir, name);
574
593
  const isLink2 = lstatSync(path).isSymbolicLink();
@@ -596,7 +615,7 @@ function globalFolderSkills(enabled) {
596
615
  skills.push({
597
616
  name: e.name,
598
617
  kind: locked || e.isLink && inAgents ? "skills.sh" : "global",
599
- origin: locked ?? (e.isLink && inAgents ? "skills.sh (not in its lock file)" : e.isLink ? `link \u2192 ${target_}` : `${tildify2(e.dir)} (origin unknown)`),
618
+ origin: locked ?? (e.isLink && inAgents ? "skills.sh (not in its lock file)" : e.isLink ? `link \u2192 ${target_}` : `${tildify(e.dir)} (origin unknown)`),
600
619
  path: e.path,
601
620
  description: e.broken ? "" : readSkillInfo(e.path).description,
602
621
  movable: true,
@@ -613,13 +632,14 @@ function globalFolderSkills(enabled) {
613
632
  }
614
633
  return skills;
615
634
  }
616
- function tildify2(p) {
617
- return tildify(p).replace(/\\/g, "/");
618
- }
619
635
  function grokBuiltInSkills(enabled) {
620
636
  if (!enabled.includes("grok")) return [];
621
637
  return skillDirsIn(join7(grokDir(), "bundled", "skills")).map((path) => vendorSkill(path, "built-in", "Grok", ["grok"]));
622
638
  }
639
+ function codexBuiltInSkills(enabled) {
640
+ if (!enabled.includes("codex")) return [];
641
+ return skillDirsIn(join7(codexHome(), "skills", ".system")).map((path) => vendorSkill(path, "built-in", "Codex", ["codex"]));
642
+ }
623
643
  function cursorBuiltInSkills(enabled) {
624
644
  if (!enabled.includes("cursor")) return [];
625
645
  return skillDirsIn(join7(userHome(), ".cursor", "skills-cursor")).map((path) => vendorSkill(path, "built-in", "Cursor", ["cursor"]));
@@ -636,7 +656,7 @@ function claudeAiSkills(enabled) {
636
656
  byReal.set(real, entry);
637
657
  }
638
658
  return [...byReal.values()].flatMap(
639
- ({ dir, readers }) => readdirSync3(dir).filter((d) => !d.startsWith(".")).flatMap(
659
+ ({ dir, readers }) => (isDir(dir) ? readdirSync3(dir) : []).filter((d) => !d.startsWith(".")).flatMap(
640
660
  (bucket) => skillDirsIn(join7(dir, bucket)).map((path) => vendorSkill(path, "claude.ai", "claude.ai account", enabled.filter((id) => readers.includes(id))))
641
661
  )
642
662
  );
@@ -646,6 +666,8 @@ function installedPluginsJson() {
646
666
  }
647
667
  function pluginDirCandidates(id, installPaths) {
648
668
  const [name = "", marketplace = ""] = id.split("@");
669
+ const oneName = (s) => s !== "" && s !== "." && s !== ".." && !/[\\/]/.test(s);
670
+ if (!oneName(name) || !oneName(marketplace)) return installPaths;
649
671
  const plugins = join7(claudeDir(), "plugins");
650
672
  return [
651
673
  ...installPaths,
@@ -678,7 +700,7 @@ function pluginSkills(harnesses, root) {
678
700
  return loadedBy.length ? skillDirsIn(join7(dir, "skills")).map((path) => ({ path, origin: id, loadedBy })) : [];
679
701
  });
680
702
  const syncedDir = join7(plugins, "synced");
681
- const fromSynced = claude2 && existsSync6(syncedDir) ? readdirSync3(syncedDir).filter((b) => !b.startsWith(".")).flatMap(
703
+ const fromSynced = claude2 && existsSync6(syncedDir) ? readdirSync3(syncedDir).filter((b) => !b.startsWith(".") && isDir(join7(syncedDir, b))).flatMap(
682
704
  (bucket) => readdirSync3(join7(syncedDir, bucket), { withFileTypes: true }).filter((d) => d.isDirectory()).flatMap((d) => {
683
705
  const root2 = join7(syncedDir, bucket, d.name);
684
706
  const name = readJson(join7(root2, ".claude-plugin", "plugin.json"))?.name ?? d.name;
@@ -695,6 +717,7 @@ function machineSkills(enabled = enabledHarnesses()) {
695
717
  ...claudeAiSkills(enabled),
696
718
  ...pluginSkills(enabled),
697
719
  ...cursorBuiltInSkills(enabled),
720
+ ...codexBuiltInSkills(enabled),
698
721
  ...grokBuiltInSkills(enabled),
699
722
  ...systemSkills(enabled)
700
723
  ].sort(byKindAndName);
@@ -719,7 +742,9 @@ function recordOrigin(name, origin) {
719
742
  function originFor(dir) {
720
743
  const under = (path, folder) => path.startsWith((realpathOrNull(folder) ?? folder) + sep3);
721
744
  const real = realpathOrNull(dir) ?? dir;
722
- if ([tmpdir(), "/tmp", "/var/tmp"].some((t) => under(real, t)) && !under(real, userHome())) return "";
745
+ const temps = [tmpdir(), "/tmp", "/var/tmp"];
746
+ const homeInTemp = temps.some((t) => under(realpathOrNull(userHome()) ?? userHome(), t));
747
+ if (temps.some((t) => under(real, t)) && !(homeInTemp && under(real, userHome()))) return "";
723
748
  const match = machineSkills().find((s) => resolve5(s.path) === resolve5(dir));
724
749
  if (match) return match.kind === "global" ? "global folder" : `${match.kind}: ${match.origin}`;
725
750
  const project = dir.split(/[\\/]\.(?:claude|agents|grok|cursor|codex)[\\/]skills[\\/]/)[0];
@@ -746,6 +771,62 @@ function isLink(path) {
746
771
  return false;
747
772
  }
748
773
  }
774
+ function notWritable(root, dir) {
775
+ const top = realpathOrNull(root) ?? root;
776
+ let p = root;
777
+ for (const part of dir.split(/[\\/]/)) {
778
+ p = join8(p, part);
779
+ if (!entryExists(p)) return null;
780
+ const real = realpathOrNull(p);
781
+ if (!isDir(p) || real === null || real !== top && !real.startsWith(top + sep4)) return relative2(root, p).split(sep4).join("/");
782
+ }
783
+ return null;
784
+ }
785
+ var linksOut = (root, path) => isLink(join8(root, path)) && isDir(join8(root, path));
786
+ function blockerSaid(root, path) {
787
+ const full = join8(root, path);
788
+ if (!isLink(full)) return `${path} is a file, not a folder`;
789
+ const real = realpathOrNull(full);
790
+ return real === null ? `${path} is a link to nothing` : linksOut(root, path) ? `${path} links out of the repo (to ${tildify(real)})` : `${path} links to a file`;
791
+ }
792
+ function inTheWaySaid(root, paths) {
793
+ return paths.map((p) => ALL_PROJECT_DIRS.includes(posix.dirname(p)) ? `${p} is taken (another copy, or a file)` : blockerSaid(root, p)).join("; ");
794
+ }
795
+ var writableDir = (root, dir) => notWritable(root, dir) === null;
796
+ function reposChoice(root, dir) {
797
+ if (writableDir(root, dir)) return true;
798
+ const real = isDir(join8(root, dir)) ? realNative(join8(root, dir)) : null;
799
+ return real !== null && !globalSkillDirs().some((g) => realNative(g) === real);
800
+ }
801
+ var firstStuck = (root, dirs) => dirs.map((d) => notWritable(root, d)).find((p) => p !== null) ?? null;
802
+ var fileAt = (path) => entryExists(path) && !isLink(path) && !isDir(path);
803
+ function syncDone(root, name, dep) {
804
+ const copy = join8(root, dep.dir ?? PROJECT_SKILLS_DIR, name);
805
+ return treeHash(copy) === dep.hash && (dep.links ?? []).every((d) => realpathOrNull(join8(root, d, name)) === realpathOrNull(copy));
806
+ }
807
+ function slotAt(link2, to) {
808
+ if (isLink(link2) && !isDir(link2)) return "free";
809
+ if (!entryExists(link2)) return "free";
810
+ return realpathOrNull(link2) === realpathOrNull(to) ? "ok" : "taken";
811
+ }
812
+ function syncBlocker(root, name, dep = readManifest(root).skills[name]) {
813
+ if (!dep) return null;
814
+ const dir = dep.dir ?? PROJECT_SKILLS_DIR;
815
+ const stuck = firstStuck(root, [dir, ...dep.links ?? []]);
816
+ if (stuck) return { said: blockerSaid(root, stuck), outside: linksOut(root, stuck) };
817
+ if (syncDone(root, name, dep)) return null;
818
+ const copy = join8(root, dir, name);
819
+ if (fileAt(copy)) return { said: `${dir}/${name} is a file, not a skill folder`, outside: false };
820
+ for (const d of dep.links ?? []) {
821
+ const link2 = join8(root, d, name);
822
+ if (!entryExists(link2)) continue;
823
+ if (!isLink(link2)) return { said: `${d}/${name} is in the way of its link`, outside: false };
824
+ if (!isDir(link2) || realpathOrNull(link2) === realpathOrNull(copy)) continue;
825
+ const there = treeHash(link2);
826
+ if (there !== dep.hash && versionForHash(name, there ?? "") === null) return { said: `${d}/${name} links to a different copy`, outside: false };
827
+ }
828
+ return null;
829
+ }
749
830
  function linkDir(target, link2) {
750
831
  if (process.platform === "win32") symlinkSync(target, link2, "junction");
751
832
  else symlinkSync(relative2(dirname3(link2), target), link2);
@@ -759,11 +840,160 @@ function visibilityOf(root, name, enabled = enabledHarnesses()) {
759
840
  paths: harness(id).projectDirs.filter((dir) => existsSync7(join8(root, dir, name, "SKILL.md"))).length
760
841
  }));
761
842
  }
843
+ function realNative(p) {
844
+ try {
845
+ return realpathSync3.native(p);
846
+ } catch {
847
+ return null;
848
+ }
849
+ }
850
+ function survives(path, gone) {
851
+ return lostAt(path, gone) === null;
852
+ }
853
+ function lostAt(path, gone) {
854
+ const key = (p) => process.platform === "win32" ? p.toLowerCase() : p;
855
+ const canon = (p) => {
856
+ const dir = realNative(dirname3(p)) ?? dirname3(p);
857
+ const leaf = basename3(p);
858
+ try {
859
+ lstatSync2(join8(dir, leaf));
860
+ const names = readdirSync4(dir);
861
+ return join8(dir, names.includes(leaf) ? leaf : names.find((n) => n.toLowerCase() === leaf.toLowerCase()) ?? leaf);
862
+ } catch {
863
+ return join8(dir, leaf);
864
+ }
865
+ };
866
+ const moved = gone.map((g) => key(canon(g)));
867
+ let at = path;
868
+ let last = null;
869
+ for (let hop = 0; hop < 40; hop++) {
870
+ const here = canon(at);
871
+ if (moved.some((m) => key(here) === m || key(here).startsWith(m + sep4))) return last ?? here;
872
+ if (!isLink(here)) return existsSync7(join8(here, "SKILL.md")) ? null : here;
873
+ last = here;
874
+ at = resolve6(dirname3(here), readlinkSync2(here).replace(/^\\\\\?\\(UNC\\)?/, (_m, unc) => unc ? "\\\\" : ""));
875
+ }
876
+ return last;
877
+ }
878
+ function blindWithoutGlobal(name, copies, roots, nestedOf = (root) => nestedEntries(root), packagesOf = (root) => packageDirs(root)) {
879
+ const gone = copies.flatMap((c) => [c.path, ...c.links ?? []]);
880
+ const readers = new Set(copies.flatMap((c) => c.harnesses));
881
+ const enabled = enabledHarnesses();
882
+ const holds = (root) => ALL_PROJECT_DIRS.some((d) => reposChoice(root, d) && entryExists(join8(root, d, name))) || pinsSkill(root, name);
883
+ const keepsTop = (root, id) => harness(id).projectDirs.some((d) => existsSync7(join8(root, d, name, "SKILL.md")) && survives(join8(root, d, name), gone));
884
+ const shown = (root, p) => {
885
+ const under = (dir) => ((d) => p.startsWith(d + sep4) ? relative2(d, p).split(sep4).join("/") : null)(realNative(dir) ?? dir);
886
+ return under(root) ?? ((h) => h === null ? p : `~/${h}`)(under(userHome()));
887
+ };
888
+ const blindIn = (root) => enabled.flatMap((id) => {
889
+ const reach = harness(id).projectDirs.some((d) => existsSync7(join8(root, d, name, "SKILL.md")));
890
+ if (keepsTop(root, id) || !readers.has(id) && !reach) return [];
891
+ const folder = harness(id).projectDirs.map((d) => reposChoice(root, d) ? null : notWritable(root, d)).find((f) => f !== null) ?? null;
892
+ const shared = harness(id).projectDirs.find((d) => !writableDir(root, d) && reposChoice(root, d));
893
+ const at = shared && existsSync7(join8(root, shared, name, "SKILL.md")) ? lostAt(join8(root, shared, name), gone) : null;
894
+ if (shared && !at) return [{ root, id, folder, shared: { dir: shared, to: shown(root, realNative(join8(root, shared)) ?? join8(root, shared)) } }];
895
+ const own = at ? null : harness(id).projectDirs.find((d) => writableDir(root, d) && isLink(join8(root, d, name)) && existsSync7(join8(root, d, name, "SKILL.md")));
896
+ const ownAt = own ? lostAt(join8(root, own, name), gone) : null;
897
+ return [{ root, id, folder, ...at ? { link: shown(root, at) } : ownAt ? { link: shown(root, ownAt) } : {} }];
898
+ });
899
+ const nestedBlind = (root) => nestedOf(root).filter((n) => n.name === name && !survives(n.path, gone)).flatMap(
900
+ (n) => n.readers.filter((id) => !keepsTop(root, id)).map((id) => ((pkg) => ({ root: pkg, id, folder: null, link: shown(pkg, lostAt(n.path, gone) ?? n.path) }))(join8(root, n.location, "..", "..")))
901
+ );
902
+ const unique = [...new Map(roots.map((r) => [realpathOrNull(r) ?? r, r])).values()];
903
+ const key = (p) => realpathOrNull(p) ?? p;
904
+ const asked = new Set(unique.map(key));
905
+ const pinnedPackages = (root) => packagesOf(root).filter((sub) => existsSync7(join8(sub, MANIFEST_FILE)) && pinsSkill(sub, name) && !asked.has(key(sub))).flatMap((sub) => blindIn(sub).filter((b) => !keepsTop(root, b.id)));
906
+ const all = unique.flatMap((root) => [...holds(root) ? blindIn(root) : [], ...nestedBlind(root), ...pinnedPackages(root)]);
907
+ const byWho = /* @__PURE__ */ new Map();
908
+ for (const b of all) {
909
+ const k = `${key(b.root)}\0${b.id}`;
910
+ const had = byWho.get(k);
911
+ if (!had || !(had.link || had.folder || had.shared) && (b.link || b.folder || b.shared)) byWho.set(k, b);
912
+ }
913
+ const within = (d) => unique.some((r) => d === r || d.startsWith(r + sep4));
914
+ const home2 = realNative(userHome()) ?? userHome();
915
+ const belowHome = (d) => (realNative(d) ?? d) !== home2 && !home2.startsWith((realNative(d) ?? d) + sep4);
916
+ const gitTop = (from) => {
917
+ for (let d = from; d !== dirname3(d) && belowHome(d); d = dirname3(d)) if (existsSync7(join8(d, ".git"))) return d;
918
+ return null;
919
+ };
920
+ const servedAbove = (b) => {
921
+ const top = gitTop(b.root);
922
+ const inside = (d) => belowHome(d) && (top ? d === top || d.startsWith(top + sep4) : within(d));
923
+ for (let d = dirname3(b.root); inside(d) && d !== dirname3(d); d = dirname3(d)) if (keepsTop(d, b.id)) return true;
924
+ return false;
925
+ };
926
+ return [...byWho.values()].filter((b) => !servedAbove(b));
927
+ }
928
+ function strandedLinks(name, copies, roots, label = basename3) {
929
+ const gone = copies.flatMap((c) => [c.path, ...c.links ?? []]);
930
+ const unique = [...new Map(roots.map((r) => [realpathOrNull(r) ?? r, r])).values()];
931
+ const seen = /* @__PURE__ */ new Set();
932
+ const once = (p) => ((k) => seen.has(k) ? false : (seen.add(k), true))(join8(realNative(dirname3(p)) ?? dirname3(p), basename3(p)));
933
+ return unique.flatMap((root) => [
934
+ ...ALL_PROJECT_DIRS.filter(
935
+ (d) => writableDir(root, d) && isLink(join8(root, d, name)) && existsSync7(join8(root, d, name, "SKILL.md")) && !survives(join8(root, d, name), gone) && once(join8(root, d, name))
936
+ ).map(
937
+ (d) => `${d}/${name} in ${label(root)}`
938
+ ),
939
+ ...nestedEntries(root).filter((n) => n.name === name && isLink(n.path) && !survives(n.path, gone) && once(n.path)).map((n) => `${relative2(root, n.path).split(sep4).join("/")} in ${label(root)}`)
940
+ ]);
941
+ }
942
+ function copiesDiffer(paths) {
943
+ return new Set(paths.map((p) => treeHash(p)).filter((h) => h !== null)).size > 1;
944
+ }
945
+ function blindSaid(name, blind, label = basename3) {
946
+ const who = blind.map((b) => `${harness(b.id).name} in ${label(b.root)}`).join(", ");
947
+ const folders2 = [...new Set(blind.flatMap((b) => b.folder ? [`${b.folder} in ${label(b.root)}`] : []))];
948
+ const links = [...new Set(blind.flatMap((b) => b.link ? [b.link.startsWith("~") || isAbsolute(b.link) ? b.link : `${b.link} in ${label(b.root)}`] : []))];
949
+ const sharedBy = /* @__PURE__ */ new Map();
950
+ for (const b of blind.filter((x) => x.shared)) {
951
+ if (!sharedBy.has(b.shared.to)) sharedBy.set(b.shared.to, { to: b.shared.to, where: /* @__PURE__ */ new Set(), dirs: /* @__PURE__ */ new Set() });
952
+ sharedBy.get(b.shared.to).where.add(`${b.shared.dir} in ${label(b.root)}`);
953
+ sharedBy.get(b.shared.to).dirs.add(b.shared.dir);
954
+ }
955
+ const shared = [...sharedBy.values()].map(
956
+ (g) => `put a copy of ${name} in ${g.to} (${[...g.where].join(", ")} link${g.where.size === 1 ? "s" : ""} there), or point ${g.where.size === 1 ? `${[...g.dirs][0]} back into the repo` : "those folders back into their repos"}`
957
+ );
958
+ const repos = blind.filter((b) => !b.folder && !b.link && !b.shared);
959
+ const fixes = [
960
+ ...folders2.length ? [`point ${folders2.join(", ")} back into the repo`] : [],
961
+ // A package's link to the global copy: add or link at the repo's top doesn't touch it.
962
+ ...links.length ? [`give ${links.join(", ")} ${links.length === 1 ? "a copy of its own" : "each a copy of its own"} instead of a link to the global one`] : [],
963
+ ...shared,
964
+ // Named, so with other advice beside it, it's clear which repo (or package) this is about.
965
+ ...repos.length ? ((names) => [`give ${names.join(", ")} ${names.length === 1 ? "a copy of its own" : "their own copies"} (skilllib add, sync or link there)`])([...new Set(repos.map((b) => label(b.root)))]) : []
966
+ ];
967
+ return `${who} only ${blind.length === 1 ? "sees" : "see"} the global copy: ${fixes.join("; ")}, or keep ${name} global (skilllib global keep)`;
968
+ }
762
969
  function dependencyDirs(root, name, dep) {
763
970
  const untracked = ALL_PROJECT_DIRS.find((d) => d !== GROK_SKILLS_DIR && existsSync7(join8(root, d, name, "SKILL.md")) && !isLink(join8(root, d, name)));
764
971
  const primary = dep ? dep.dir ?? PROJECT_SKILLS_DIR : untracked ?? installDirs(enabledHarnesses())[0];
765
972
  const wanted = installDirs(enabledHarnesses(), primary);
766
- return [primary, .../* @__PURE__ */ new Set([...dep?.links ?? [], ...wanted.filter((d) => d !== primary)])];
973
+ const real = (d) => realpathOrNull(join8(root, d));
974
+ const same = (d) => d === primary || real(d) !== null && real(d) === real(primary);
975
+ return [primary, .../* @__PURE__ */ new Set([...dep?.links ?? [], ...wanted.filter((d) => !same(d))])];
976
+ }
977
+ function trackBlocker(root, name) {
978
+ let recorded;
979
+ try {
980
+ recorded = readManifest(root).skills[name];
981
+ } catch {
982
+ return `${MANIFEST_FILE} can't be read`;
983
+ }
984
+ const stuck = firstStuck(root, dependencyDirs(root, name, recorded));
985
+ return stuck ? blockerSaid(root, stuck) : null;
986
+ }
987
+ function pinsSkill(root, name) {
988
+ try {
989
+ return name in readManifest(root).skills;
990
+ } catch {
991
+ try {
992
+ return readFileSync4(join8(root, MANIFEST_FILE), "utf-8").includes(JSON.stringify(name));
993
+ } catch {
994
+ return false;
995
+ }
996
+ }
767
997
  }
768
998
  function isGitTracked(root, dir) {
769
999
  try {
@@ -779,16 +1009,18 @@ function projectStatus(root) {
779
1009
  const managed = Object.entries(skills).map(([name, dep]) => {
780
1010
  const location = dep.dir ?? PROJECT_SKILLS_DIR;
781
1011
  const path = join8(root, location, name);
782
- const local = treeHash(path);
1012
+ const local = notWritable(root, location) ? null : treeHash(path);
783
1013
  const latest = latestVersion(name);
784
- const edited = local !== dep.hash;
785
- const behind = latest !== null && latest.hash !== dep.hash && latest.version > dep.version;
786
- const state = local === null ? "folder missing" : latest === null ? "not in library" : local === latest.hash ? "ok" : edited && behind ? "edited locally, update available" : edited ? "edited locally" : behind ? "update available" : "ok";
1014
+ const known = local !== null && local !== dep.hash && versionForHash(name, local) !== null;
1015
+ const edited = local !== dep.hash && !known;
1016
+ const pinned = versionForHash(name, dep.hash)?.version ?? dep.version;
1017
+ const behind = latest !== null && latest.hash !== dep.hash && latest.version > pinned;
1018
+ const state = local === null ? "folder missing" : latest === null ? "not in library" : local === latest.hash && !known ? "ok" : known ? "out of sync" : edited && behind ? "edited locally, update available" : edited ? "edited locally" : behind ? "update available" : "ok";
787
1019
  return {
788
1020
  name,
789
1021
  managed: true,
790
1022
  state,
791
- version: dep.version || null,
1023
+ version: pinned || null,
792
1024
  latest: latest?.version ?? null,
793
1025
  location,
794
1026
  path,
@@ -799,7 +1031,7 @@ function projectStatus(root) {
799
1031
  const lock = projectSkillsLock(root);
800
1032
  const unmanaged = [];
801
1033
  const byName = /* @__PURE__ */ new Map();
802
- for (const dir of ALL_PROJECT_DIRS) {
1034
+ for (const dir of ALL_PROJECT_DIRS.filter((d) => writableDir(root, d))) {
803
1035
  for (const path of skillDirsIn(join8(root, dir))) {
804
1036
  const name = basename3(path);
805
1037
  if (seen.has(name)) continue;
@@ -840,40 +1072,57 @@ var NESTED_READERS = [
840
1072
  // Cursor reads nested .grok/skills too, but skilllib doesn't count it: that folder stays Grok's.
841
1073
  [".grok/skills", ["grok"]]
842
1074
  ];
843
- var NESTED_SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "out", "vendor", "target", "coverage"]);
844
- function nestedSkills(root, { depth = 3, enabled = enabledHarnesses() } = {}) {
1075
+ function nestedSkills(root, { depth = PACKAGE_DEPTH, enabled = enabledHarnesses() } = {}) {
1076
+ return nestedEntries(root, { depth, enabled }).map((n) => ({
1077
+ name: n.name,
1078
+ managed: false,
1079
+ state: unmanagedState(n.name, n.path, isRepoSkillLocation(n.skillsDir), void 0),
1080
+ version: null,
1081
+ latest: latestVersion(n.name)?.version ?? null,
1082
+ location: n.location,
1083
+ path: n.path,
1084
+ visibility: enabled.map((id) => ({ id, paths: n.readers.includes(id) ? 1 : 0 }))
1085
+ }));
1086
+ }
1087
+ function nestedEntries(root, { depth = PACKAGE_DEPTH, enabled = enabledHarnesses(), subs } = {}) {
1088
+ const top = realpathOrNull(root) ?? root;
1089
+ const found = packageSkillFolders(root, subs ?? packageDirs(root, depth)).flatMap(
1090
+ ({ sub, skillsDir, location }) => skillDirsIn(join8(sub, skillsDir)).flatMap((path) => {
1091
+ if (isLink(path) && (realpathOrNull(path) ?? "").startsWith(top + sep4)) return [];
1092
+ const name = basename3(path);
1093
+ const readers = NESTED_READERS.filter(([d]) => realpathOrNull(join8(sub, d, name)) === realpathOrNull(path)).flatMap(([, r]) => r);
1094
+ return [{ name, path, location, skillsDir, readers: enabled.filter((id) => readers.includes(id)) }];
1095
+ })
1096
+ );
1097
+ return found.sort((a, b) => a.location.localeCompare(b.location) || a.name.localeCompare(b.name));
1098
+ }
1099
+ var PACKAGE_DEPTH = 5;
1100
+ function packageDirs(root, depth = PACKAGE_DEPTH) {
845
1101
  const found = [];
846
1102
  const walk = (dir, left) => {
847
1103
  let subs = [];
848
1104
  try {
849
- subs = readdirSync4(dir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".") && !NESTED_SKIP.has(d.name)).map((d) => join8(dir, d.name));
1105
+ subs = readdirSync4(dir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".") && !BUILD_DIRS.has(d.name)).map((d) => join8(dir, d.name));
850
1106
  } catch {
851
1107
  return;
852
1108
  }
853
1109
  for (const sub of subs) {
854
1110
  if (existsSync7(join8(sub, ".git"))) continue;
855
- for (const [skillsDir, readers] of NESTED_READERS) {
856
- const location = relative2(root, join8(sub, skillsDir)).split(sep4).join("/");
857
- for (const path of skillDirsIn(join8(sub, skillsDir))) {
858
- if (isLink(path) && (realpathOrNull(path) ?? "").startsWith((realpathOrNull(root) ?? root) + sep4)) continue;
859
- const name = basename3(path);
860
- found.push({
861
- name,
862
- managed: false,
863
- state: unmanagedState(name, path, isRepoSkillLocation(skillsDir), void 0),
864
- version: null,
865
- latest: latestVersion(name)?.version ?? null,
866
- location,
867
- path,
868
- visibility: enabled.map((id) => ({ id, paths: readers.includes(id) ? 1 : 0 }))
869
- });
870
- }
871
- }
1111
+ found.push(sub);
872
1112
  if (left > 1) walk(sub, left - 1);
873
1113
  }
874
1114
  };
875
1115
  walk(root, depth);
876
- return found.sort((a, b) => a.location.localeCompare(b.location) || a.name.localeCompare(b.name));
1116
+ return found;
1117
+ }
1118
+ function packageSkillFolders(root, subs = packageDirs(root)) {
1119
+ return subs.flatMap(
1120
+ (sub) => NESTED_READERS.flatMap(([skillsDir]) => {
1121
+ if (!existsSync7(join8(sub, skillsDir))) return [];
1122
+ const location = relative2(root, join8(sub, skillsDir)).split(sep4).join("/");
1123
+ return reposChoice(root, location) ? [{ sub, skillsDir, location }] : [];
1124
+ })
1125
+ );
877
1126
  }
878
1127
  function sharedByGit(root, location, name) {
879
1128
  const copy = `${location}/${name}`;
@@ -888,13 +1137,24 @@ function sharedByGit(root, location, name) {
888
1137
  }
889
1138
  function linkEverywhere(root, name, location, { allowTracked = false, dryRun = false } = {}) {
890
1139
  const enabled = enabledHarnesses();
891
- const result = { created: [], blocked: [], uncommitted: [] };
1140
+ const result = { created: [], blocked: [], uncommitted: [], inTheWay: [] };
892
1141
  let shared;
1142
+ const stuck = (path) => void (result.inTheWay.includes(path) || result.inTheWay.push(path));
893
1143
  for (const id of enabled) {
894
1144
  if (visibilityOf(root, name, [id])[0].paths > 0) continue;
895
1145
  const dir = harness(id).projectDirs[0];
896
1146
  const link2 = join8(root, dir, name);
897
- if (entryExists(link2) || Object.values(result).some((dirs) => dirs.includes(dir))) continue;
1147
+ if (Object.values(result).some((dirs) => dirs.includes(dir))) continue;
1148
+ const blocker = notWritable(root, dir);
1149
+ if (blocker) {
1150
+ stuck(blocker);
1151
+ continue;
1152
+ }
1153
+ const broken = isLink(link2) && !isDir(link2);
1154
+ if (entryExists(link2) && !broken) {
1155
+ stuck(`${dir}/${name}`);
1156
+ continue;
1157
+ }
898
1158
  if (isGitTracked(root, dir)) {
899
1159
  if (!(shared ??= sharedByGit(root, location, name))) {
900
1160
  if (!result.uncommitted.includes(dir)) result.uncommitted.push(dir);
@@ -906,6 +1166,7 @@ function linkEverywhere(root, name, location, { allowTracked = false, dryRun = f
906
1166
  }
907
1167
  }
908
1168
  if (!dryRun) {
1169
+ if (broken) unlinkSync(link2);
909
1170
  mkdirSync4(join8(root, dir), { recursive: true });
910
1171
  linkDir(join8(root, location, name), link2);
911
1172
  }
@@ -913,15 +1174,10 @@ function linkEverywhere(root, name, location, { allowTracked = false, dryRun = f
913
1174
  }
914
1175
  return result;
915
1176
  }
916
- function unlinkEverywhere(root, name, removing) {
917
- const gone = removing ? realpathOrNull(removing) : null;
918
- return ALL_PROJECT_DIRS.filter((dir) => {
1177
+ function unlinkEverywhere(root, name) {
1178
+ return ALL_PROJECT_DIRS.filter((dir) => writableDir(root, dir)).filter((dir) => {
919
1179
  const link2 = join8(root, dir, name);
920
- if (!isLink(link2)) return false;
921
- if (dir === GROK_SKILLS_DIR) {
922
- const target = realpathOrNull(link2);
923
- if (target !== null && target !== gone) return false;
924
- }
1180
+ if (!isLink(link2) || realpathOrNull(link2) !== null) return false;
925
1181
  unlinkSync(link2);
926
1182
  return true;
927
1183
  });
@@ -929,7 +1185,7 @@ function unlinkEverywhere(root, name, removing) {
929
1185
  function relinkDependency(root, name, { allowTracked = false } = {}) {
930
1186
  const manifest = readManifest(root);
931
1187
  const dep = manifest.skills[name];
932
- if (!dep) return { created: [], blocked: [], uncommitted: [] };
1188
+ if (!dep) return { created: [], blocked: [], uncommitted: [], inTheWay: [] };
933
1189
  const result = linkEverywhere(root, name, dep.dir ?? PROJECT_SKILLS_DIR, { allowTracked });
934
1190
  if (result.created.length) {
935
1191
  manifest.skills[name] = { ...dep, links: [.../* @__PURE__ */ new Set([...dep.links ?? [], ...result.created])] };
@@ -937,39 +1193,60 @@ function relinkDependency(root, name, { allowTracked = false } = {}) {
937
1193
  }
938
1194
  return result;
939
1195
  }
940
- function linkAll(root, { allowTracked = false } = {}) {
1196
+ function linkAll(root, { allowTracked = false, names } = {}) {
941
1197
  const linked = [];
942
1198
  const blocked = /* @__PURE__ */ new Set();
943
1199
  const uncommitted = [];
1200
+ const inTheWay = /* @__PURE__ */ new Set();
944
1201
  for (const skill of projectStatus(root)) {
945
- if (!skill.visibility.some((v) => v.paths === 0) || skill.state === "folder missing") continue;
1202
+ if (names && !names.includes(skill.name) || !skill.visibility.some((v) => v.paths === 0) || skill.state === "folder missing") continue;
946
1203
  const res = skill.managed ? relinkDependency(root, skill.name, { allowTracked }) : linkEverywhere(root, skill.name, skill.location, { allowTracked });
947
1204
  if (res.created.length) linked.push({ name: skill.name, into: res.created });
948
1205
  for (const dir of res.blocked) blocked.add(dir);
949
1206
  if (res.uncommitted.length) uncommitted.push(skill.name);
1207
+ for (const path of res.inTheWay) inTheWay.add(path);
950
1208
  }
951
- return { linked, blocked: [...blocked], uncommitted };
1209
+ return { linked, blocked: [...blocked], uncommitted, inTheWay: [...inTheWay] };
952
1210
  }
953
- function addSkill(root, name, { force = false, version, allowTracked = false } = {}) {
954
- const target = version === void 0 ? latestVersion(name) : getVersion(name, version);
955
- if (!target) return { name, action: "skipped", reason: version === void 0 ? "not in the library" : `version ${version} doesn't exist` };
1211
+ function addPlan(root, name, hash, force) {
956
1212
  const manifest = readManifest(root);
957
1213
  const recorded = manifest.skills[name];
958
- const [primary = PROJECT_SKILLS_DIR, ...linkDirs] = dependencyDirs(root, name, recorded);
1214
+ const [primary = PROJECT_SKILLS_DIR, ...wantedLinks] = dependencyDirs(root, name, recorded);
1215
+ const stuck = firstStuck(root, [primary, ...wantedLinks]);
1216
+ if (stuck) return { reason: `${blockerSaid(root, stuck)}, so skilllib doesn't install there` };
959
1217
  const to = join8(root, primary, name);
1218
+ if (fileAt(to)) return { reason: `${primary}/${name} is a file, not a skill folder: move it first` };
960
1219
  const local = isLink(to) && !recorded ? null : treeHash(to);
961
1220
  const knownContent = local !== null && (local === recorded?.hash || versionForHash(name, local) !== null);
962
- if (local !== null && !recorded && local !== target.hash && isGitTracked(root, `${primary}/${name}`))
963
- return { name, action: "skipped", reason: `${primary}/${name} is the repo's own (committed): skilllib doesn't replace it; remove it in the repo first if the library's should win` };
964
- if (local !== null && local !== target.hash && !knownContent && !force) {
1221
+ if (!recorded && entryExists(to) && isGitTracked(root, `${primary}/${name}`))
1222
+ return { reason: `${primary}/${name} is the repo's own (committed): skilllib doesn't replace it; remove it in the repo first if the library's should win` };
1223
+ const linked = local === null && !recorded && isLink(to) ? treeHash(to) : null;
1224
+ if (linked !== null && linked !== hash && versionForHash(name, linked) === null && !force)
1225
+ return { reason: `${primary}/${name} links to a different copy (use --force to replace the link)` };
1226
+ if (local !== null && local !== hash && !knownContent && !force) {
965
1227
  return {
966
- name,
967
- action: "skipped",
968
1228
  reason: recorded ? "has local edits (use --force to overwrite, or save them to the library first)" : `${primary}/${name} already exists with different content (use --force to replace it)`
969
1229
  };
970
1230
  }
1231
+ return { manifest, recorded, primary, wantedLinks, to, local };
1232
+ }
1233
+ function withoutForceHint(reason) {
1234
+ return reason.replace(/ \(use --force[^)]*\)$/, "");
1235
+ }
1236
+ function addRefusal(root, name, hash) {
1237
+ const plan = addPlan(root, name, hash, false);
1238
+ return "reason" in plan ? plan.reason : null;
1239
+ }
1240
+ function addSkill(root, name, { force = false, version, allowTracked = false } = {}) {
1241
+ if (!name || name === "." || name === ".." || /[\\/]/.test(name)) return { name, action: "skipped", reason: "not a skill name" };
1242
+ const target = version === void 0 ? latestVersion(name) : getVersion(name, version);
1243
+ if (!target) return { name, action: "skipped", reason: version === void 0 ? "not in the library" : `version ${version} doesn't exist` };
1244
+ const plan = addPlan(root, name, target.hash, force);
1245
+ if ("reason" in plan) return { name, action: "skipped", reason: plan.reason };
1246
+ const { manifest, recorded, primary, wantedLinks, to, local } = plan;
971
1247
  let backedUp;
972
- if (local !== target.hash || isLink(to)) {
1248
+ const copied = local !== target.hash || isLink(to);
1249
+ if (copied) {
973
1250
  mkdirSync4(join8(root, primary), { recursive: true });
974
1251
  if (isLink(to)) unlinkSync(to);
975
1252
  else if (local !== null && versionForHash(name, local) === null) backedUp = stash(to, "edit-backup");
@@ -978,14 +1255,21 @@ function addSkill(root, name, { force = false, version, allowTracked = false } =
978
1255
  const links = [];
979
1256
  const blocked = [];
980
1257
  const uncommitted = [];
1258
+ const inTheWay = [];
981
1259
  let shared;
982
- for (const dir of linkDirs) {
1260
+ let relinked = false;
1261
+ for (const dir of wantedLinks) {
983
1262
  const link2 = join8(root, dir, name);
984
- if (isLink(link2) && realpathOrNull(link2) === realpathOrNull(to)) {
1263
+ const slot = slotAt(link2, to);
1264
+ if (slot === "ok") {
985
1265
  links.push(dir);
986
1266
  continue;
987
1267
  }
988
- if (entryExists(link2)) continue;
1268
+ if (slot === "taken") {
1269
+ inTheWay.push(`${dir}/${name}`);
1270
+ if (recorded?.links?.includes(dir)) links.push(dir);
1271
+ continue;
1272
+ }
989
1273
  if (!recorded?.links?.includes(dir) && isGitTracked(root, dir)) {
990
1274
  if (!(shared ??= sharedByGit(root, primary, name))) {
991
1275
  uncommitted.push(dir);
@@ -996,9 +1280,11 @@ function addSkill(root, name, { force = false, version, allowTracked = false } =
996
1280
  continue;
997
1281
  }
998
1282
  }
1283
+ if (isLink(link2)) unlinkSync(link2);
999
1284
  mkdirSync4(join8(root, dir), { recursive: true });
1000
1285
  linkDir(to, link2);
1001
1286
  links.push(dir);
1287
+ relinked = true;
1002
1288
  }
1003
1289
  manifest.skills[name] = {
1004
1290
  version: target.version,
@@ -1007,13 +1293,21 @@ function addSkill(root, name, { force = false, version, allowTracked = false } =
1007
1293
  ...links.length ? { links } : {}
1008
1294
  };
1009
1295
  writeManifest(root, manifest);
1296
+ const from = recorded ? versionForHash(name, recorded.hash)?.version ?? recorded.version : void 0;
1010
1297
  return {
1011
1298
  name,
1012
- action: !recorded && local === null ? "installed" : backedUp && recorded?.version === target.version ? "reset" : "updated",
1013
- ...recorded ? { from: recorded.version } : {},
1299
+ action: (
1300
+ // Nothing of it was there (or only an identical untracked copy, now tracked): installed, not updated.
1301
+ !recorded || local === null ? "installed" : backedUp && from === target.version ? "reset" : recorded && !copied && from === target.version ? (
1302
+ // The same version, already there: only links (if any) were written.
1303
+ relinked ? "relinked" : "unchanged"
1304
+ ) : "updated"
1305
+ ),
1306
+ ...from !== void 0 ? { from } : {},
1014
1307
  to: target.version,
1015
1308
  ...blocked.length ? { blocked } : {},
1016
1309
  ...uncommitted.length ? { uncommitted } : {},
1310
+ ...inTheWay.length ? { inTheWay } : {},
1017
1311
  ...backedUp ? { backedUp } : {}
1018
1312
  };
1019
1313
  }
@@ -1021,37 +1315,79 @@ function removeSkill(root, name, { force = false } = {}) {
1021
1315
  const manifest = readManifest(root);
1022
1316
  const recorded = manifest.skills[name];
1023
1317
  if (!recorded) return { name, action: "skipped", reason: "not managed by skilllib in this project" };
1024
- const dir = join8(root, recorded.dir ?? PROJECT_SKILLS_DIR, name);
1318
+ const home2 = recorded.dir ?? PROJECT_SKILLS_DIR;
1319
+ const dir = join8(root, home2, name);
1320
+ const takenLinks = [];
1321
+ const unlinkOwn = () => {
1322
+ for (const linkDir2 of (recorded.links ?? []).filter((d) => writableDir(root, d))) {
1323
+ const link2 = join8(root, linkDir2, name);
1324
+ if (isLink(link2) && slotAt(link2, dir) !== "taken") unlinkSync(link2);
1325
+ else if (entryExists(link2) && slotAt(link2, dir) === "taken") takenLinks.push(`${linkDir2}/${name} is something else (another copy, or a file): left as it is`);
1326
+ }
1327
+ };
1328
+ const leftLinks = (recorded.links ?? []).flatMap(
1329
+ (d) => ((b) => b && entryExists(join8(root, d, name)) ? [`${blockerSaid(root, b)}: its link there was left alone`] : [])(notWritable(root, d))
1330
+ );
1331
+ const ownStuck = notWritable(root, home2);
1332
+ if (ownStuck || fileAt(dir)) {
1333
+ delete manifest.skills[name];
1334
+ writeManifest(root, manifest);
1335
+ const why = ownStuck ? blockerSaid(root, ownStuck) : `${home2}/${name} is a file, not a skill folder`;
1336
+ const keptLinks = (recorded.links ?? []).flatMap((d) => {
1337
+ const link2 = join8(root, d, name);
1338
+ if (!writableDir(root, d) || !entryExists(link2)) return [];
1339
+ return [slotAt(link2, dir) === "taken" ? `${d}/${name} is something else (another copy, or a file): left as it is` : `its link ${d}/${name} was left too`];
1340
+ });
1341
+ return { name, action: "removed", reason: [`${why}: only its pin was removed, and that copy was left as it is`, ...keptLinks, ...leftLinks].join("; ") };
1342
+ }
1025
1343
  const local = treeHash(dir);
1026
1344
  const inLibrary = local !== null && versionForHash(name, local) !== null;
1027
1345
  const edited = local !== null && local !== recorded.hash && !inLibrary;
1028
- if (edited && !force) return { name, action: "skipped", reason: "has local edits (use --force to delete anyway)" };
1029
- for (const linkDir2 of recorded.links ?? []) {
1030
- const link2 = join8(root, linkDir2, name);
1031
- if (isLink(link2)) unlinkSync(link2);
1032
- }
1346
+ if (edited && !force)
1347
+ return {
1348
+ name,
1349
+ action: "skipped",
1350
+ // A pin from another library isn't an edit of yours: say what it is. Either way --force backs the copy up first.
1351
+ reason: versionForHash(name, recorded.hash) === null ? "doesn't match what skilllib.json pins, and your library has neither (use --force to remove it; it's backed up first)" : "has local edits (use --force to delete anyway; they're backed up first)"
1352
+ };
1353
+ unlinkOwn();
1033
1354
  const backedUp = local !== null && !inLibrary ? stash(dir, "edit-backup") : void 0;
1034
1355
  if (!backedUp) rmSync3(dir, { recursive: true, force: true });
1035
1356
  delete manifest.skills[name];
1036
1357
  writeManifest(root, manifest);
1037
- return { name, action: "removed", ...backedUp ? { backedUp } : {} };
1358
+ const left = [...leftLinks, ...takenLinks];
1359
+ return { name, action: "removed", ...left.length ? { reason: left.join("; ") } : {}, ...backedUp ? { backedUp } : {} };
1038
1360
  }
1039
- function syncProject(root, { force = false } = {}) {
1040
- return Object.keys(readManifest(root).skills).flatMap((name) => syncSkill(root, name, { force }));
1361
+ function syncProject(root, { force = false, names } = {}) {
1362
+ return Object.keys(readManifest(root).skills).filter((name) => !names || names.includes(name)).flatMap((name) => syncSkill(root, name, { force }));
1041
1363
  }
1042
1364
  function syncSkill(root, name, { force = false } = {}) {
1043
1365
  const dep = readManifest(root).skills[name];
1044
1366
  if (!dep) return [];
1045
1367
  const copy = join8(root, dep.dir ?? PROJECT_SKILLS_DIR, name);
1046
1368
  const local = treeHash(copy);
1047
- const linksOk = (dep.links ?? []).every((d) => realpathOrNull(join8(root, d, name)) === realpathOrNull(copy));
1048
- if (local === dep.hash && linksOk) return [];
1369
+ const blocker = syncBlocker(root, name, dep);
1370
+ if (blocker) return [{ name, action: "skipped", reason: `${blocker.said}: ${blocker.outside ? "skilllib only installs inside the repo" : "move it, then sync"}` }];
1371
+ if (syncDone(root, name, dep)) return [];
1049
1372
  if (local === dep.hash) return [relinkPinned(root, name, dep)];
1050
- if (local !== null && local !== dep.hash && !force) return [{ name, action: "skipped", reason: "has local edits (use --force to reset them)" }];
1051
1373
  const version = pinnedVersion(name, dep);
1052
1374
  if (typeof version === "string") return [{ name, action: "skipped", reason: version }];
1053
- const change = addSkill(root, name, { force: true, version });
1054
- return [{ ...change, action: local === null ? "installed" : change.action }];
1375
+ if (local !== null && local !== dep.hash && versionForHash(name, local) === null && !force) return [{ name, action: "skipped", reason: "has local edits (use --force to reset them)" }];
1376
+ const backedUp = local !== null && versionForHash(name, local) === null ? stash(copy, "edit-backup") : void 0;
1377
+ mkdirSync4(dirname3(copy), { recursive: true });
1378
+ copySkill(versionDir(name, version), copy);
1379
+ const links = relinkPinned(root, name, dep);
1380
+ const from = (local !== null ? versionForHash(name, local)?.version : void 0) ?? version;
1381
+ return [
1382
+ {
1383
+ name,
1384
+ action: local === null ? "installed" : backedUp ? "reset" : "updated",
1385
+ from,
1386
+ to: version,
1387
+ ...backedUp ? { backedUp } : {},
1388
+ ...links.action === "skipped" ? { reason: links.reason } : {}
1389
+ }
1390
+ ];
1055
1391
  }
1056
1392
  function relinkPinned(root, name, dep) {
1057
1393
  const to = join8(root, dep.dir ?? PROJECT_SKILLS_DIR, name);
@@ -1063,7 +1399,7 @@ function relinkPinned(root, name, dep) {
1063
1399
  mkdirSync4(join8(root, dir), { recursive: true });
1064
1400
  linkDir(to, link2);
1065
1401
  }
1066
- return { name, action: "relinked", to: dep.version };
1402
+ return { name, action: "relinked", to: versionForHash(name, dep.hash)?.version ?? dep.version };
1067
1403
  }
1068
1404
  function pinnedVersion(name, dep) {
1069
1405
  if (!latestVersion(name)) return `not in your library (${tildify(libraryDir())}); import it, or set SKILLLIB_HOME to the library that has it`;
@@ -1074,20 +1410,18 @@ function discardEdits(root, name) {
1074
1410
  if (!dep) return { name, action: "skipped", reason: "not managed by skilllib in this project" };
1075
1411
  const version = pinnedVersion(name, dep);
1076
1412
  if (typeof version === "string") return { name, action: "skipped", reason: version };
1077
- const dir = join8(root, dep.dir ?? PROJECT_SKILLS_DIR, name);
1078
- if (existsSync7(dir) && !isLink(dir)) stash(dir, "edit-backup");
1079
1413
  return addSkill(root, name, { force: true, version });
1080
1414
  }
1081
- function updateProject(root, names, { force = false } = {}) {
1415
+ function updateProject(root, names, { force = false, allowTracked = false } = {}) {
1082
1416
  const { skills } = readManifest(root);
1083
1417
  return Object.keys(skills).filter((name) => !names || names.includes(name)).flatMap((name) => {
1084
1418
  const latest = latestVersion(name);
1085
- if (!latest) return [{ name, action: "skipped", reason: "no longer in the library" }];
1419
+ if (!latest) return [{ name, action: "skipped", reason: "not in your library" }];
1086
1420
  const dep = skills[name];
1087
1421
  const local = treeHash(join8(root, dep.dir ?? PROJECT_SKILLS_DIR, name));
1088
1422
  if (dep.hash === latest.hash && local === latest.hash) return [];
1089
1423
  if (dep.hash === latest.hash && local !== null && !force) return [];
1090
- const change = addSkill(root, name, { force });
1424
+ const change = addSkill(root, name, { force, allowTracked });
1091
1425
  return change.action === "skipped" || change.from !== change.to || local !== latest.hash ? [change] : [];
1092
1426
  });
1093
1427
  }
@@ -1100,6 +1434,10 @@ function importSkill(dir, { force = false } = {}) {
1100
1434
  if (existing !== null && !force) return { name, status: "exists" };
1101
1435
  mkdirSync4(libraryDir(), { recursive: true });
1102
1436
  const origin = existing !== null && libraryOrigins()[name] ? null : originFor(dir);
1437
+ if (existing !== null) {
1438
+ forgetLatest(name);
1439
+ latestVersion(name);
1440
+ }
1103
1441
  copySkill(dir, to);
1104
1442
  if (origin) recordOrigin(name, origin);
1105
1443
  forgetLatest(name);
@@ -1134,11 +1472,14 @@ function stash(dir, bucket) {
1134
1472
  return target;
1135
1473
  }
1136
1474
  function removeUntracked(root, name, path) {
1137
- unlinkEverywhere(root, name, path);
1138
- return stash(path, "trash");
1475
+ const stuck = notWritable(root, relative2(root, dirname3(path)));
1476
+ if (stuck) throw new Error(`${stuck} isn't a folder in the repo: skilllib leaves what's in it alone`);
1477
+ const to = stash(path, "trash");
1478
+ unlinkEverywhere(root, name);
1479
+ return to;
1139
1480
  }
1140
1481
  function projectsUsing(name) {
1141
- return knownProjects().filter((p) => name in pinsIn(p));
1482
+ return knownProjects().filter((p) => pinsSkill(p, name));
1142
1483
  }
1143
1484
  function deleteLibrarySkill(name) {
1144
1485
  const using = projectsUsing(name);
@@ -1173,7 +1514,7 @@ function linkGlobal(path, agents) {
1173
1514
  continue;
1174
1515
  }
1175
1516
  mkdirSync4(dir, { recursive: true });
1176
- linkDir(path, link2);
1517
+ linkDir(realpathOrNull(path) ?? path, join8(realpathOrNull(dir) ?? dir, name));
1177
1518
  linked.push(link2);
1178
1519
  }
1179
1520
  return { linked, skipped };
@@ -1185,9 +1526,10 @@ function createSkill(name, description) {
1185
1526
  mkdirSync4(dir, { recursive: true });
1186
1527
  writeFileSync2(
1187
1528
  join8(dir, "SKILL.md"),
1529
+ // JSON strings are valid YAML: a description with ": " or "#" stays one value.
1188
1530
  `---
1189
1531
  name: ${name}
1190
- description: ${description || "Describe when an agent should use this skill."}
1532
+ description: ${JSON.stringify(description || "Describe when an agent should use this skill.")}
1191
1533
  ---
1192
1534
 
1193
1535
  # ${name}
@@ -1212,6 +1554,11 @@ function listBackups() {
1212
1554
  });
1213
1555
  }).sort((a, b) => b.path.slice(-24).localeCompare(a.path.slice(-24)));
1214
1556
  }
1557
+ function movedTogether(backups, backup) {
1558
+ if (backup.kind === "plugin") return [backup];
1559
+ const at = (b) => Date.parse(b.path.slice(-24).replace(/^(.{13})-(\d\d)-(\d\d)-(\d{3})Z$/, "$1:$2:$3.$4Z"));
1560
+ return backups.filter((b) => b.name === backup.name && b.kind === backup.kind && Math.abs(at(b) - at(backup)) <= 2e3);
1561
+ }
1215
1562
  function backupFrom(backup) {
1216
1563
  if (backup.kind === "plugin") return "Claude Code plugin";
1217
1564
  if (!backup.from) return "unknown";
@@ -1222,12 +1569,7 @@ function restoreBackup(backup) {
1222
1569
  if (!to) return { ok: false, reason: `skilllib doesn't know where it came from; its files are in ${tildify(backup.path)}` };
1223
1570
  if (backup.kind === "tidy-backup" && isLink(to)) unlinkSync(to);
1224
1571
  if (backup.kind === "edit-backup" && !isLink(to) && versionForHash(backup.name, treeHash(to) ?? "") !== null) rmSync3(to, { recursive: true, force: true });
1225
- let taken = existsSync7(to);
1226
- try {
1227
- taken = taken || lstatSync2(to) !== null;
1228
- } catch {
1229
- }
1230
- if (taken) return { ok: false, reason: `${backup.name} already exists there` };
1572
+ if (entryExists(to)) return { ok: false, reason: `${backup.name} already exists there` };
1231
1573
  mkdirSync4(join8(to, ".."), { recursive: true });
1232
1574
  move(backup.path, to);
1233
1575
  forgetLatest(backup.name);
@@ -1237,45 +1579,39 @@ function restoreBackup(backup) {
1237
1579
  // src/agentSkill.ts
1238
1580
  var AGENT_SKILL_NAME = "skilllib";
1239
1581
  function shippedSkill() {
1240
- return readFileSync4(new URL("../skills/skilllib/SKILL.md", import.meta.url), "utf-8");
1582
+ return readFileSync5(new URL("../skills/skilllib/SKILL.md", import.meta.url), "utf-8");
1241
1583
  }
1242
1584
  function agentSkillDirs(enabled = enabledHarnesses()) {
1243
1585
  const claudeSkills = join9(claudeDir(), "skills");
1244
1586
  const agentsSkills = join9(userHome(), ".agents", "skills");
1245
1587
  const needsClaude = enabled.some((id) => harness(id).globalDirs().includes(claudeSkills));
1246
1588
  const needsAgents = enabled.some((id) => !harness(id).globalDirs().includes(claudeSkills));
1247
- return [...needsClaude ? [claudeSkills] : [], ...needsAgents ? [agentsSkills] : []].map((d) => join9(d, AGENT_SKILL_NAME));
1589
+ const dirs = [...needsClaude ? [claudeSkills] : [], ...needsAgents ? [agentsSkills] : []];
1590
+ return dirs.filter((d, i) => dirs.findIndex((o) => realPath(o) === realPath(d)) === i).map((d) => join9(d, AGENT_SKILL_NAME));
1248
1591
  }
1249
1592
  function isOurs(dir) {
1250
1593
  return existsSync8(join9(dir, OWN_SKILL_MARKER));
1251
1594
  }
1252
1595
  function isDangling(path) {
1253
- return entryExists2(path) && lstatSync3(path).isSymbolicLink() && !existsSync8(path);
1254
- }
1255
- function entryExists2(path) {
1256
- try {
1257
- lstatSync3(path);
1258
- return true;
1259
- } catch {
1260
- return false;
1261
- }
1596
+ return entryExists(path) && lstatSync3(path).isSymbolicLink() && !existsSync8(path);
1262
1597
  }
1263
1598
  function agentSkillState(enabled = enabledHarnesses()) {
1264
1599
  const [primary, ...links] = agentSkillDirs(enabled);
1265
1600
  if (!primary || !isOurs(primary) || links.some((l) => !existsSync8(join9(l, "SKILL.md")))) return "missing";
1266
- return readFileSync4(join9(primary, "SKILL.md"), "utf-8") === shippedSkill() ? "installed" : "outdated";
1601
+ return [primary, ...links].every((d) => readFileSync5(join9(d, "SKILL.md"), "utf-8") === shippedSkill()) ? "installed" : "outdated";
1267
1602
  }
1268
1603
  function installAgentSkill(enabled = enabledHarnesses()) {
1269
1604
  const [primary, ...links] = agentSkillDirs(enabled);
1270
1605
  if (!primary) return { ok: false, reason: "no agents chosen yet (skilllib harnesses <id>...)" };
1271
- const foreign = [primary, ...links].find((d) => entryExists2(d) && !isOurs(d) && !isDangling(d));
1606
+ const foreign = [primary, ...links].find((d) => entryExists(d) && !isOurs(d) && !isDangling(d));
1272
1607
  if (foreign) return { ok: false, reason: `${tildify(foreign)} already exists and isn't skilllib's` };
1273
1608
  for (const d of [primary, ...links]) if (isDangling(d)) unlinkSync2(d);
1274
1609
  mkdirSync5(primary, { recursive: true });
1275
1610
  writeFileSync3(join9(primary, "SKILL.md"), shippedSkill());
1276
1611
  writeFileSync3(join9(primary, OWN_SKILL_MARKER), "Installed by skilllib. `skilllib agent-skill remove` removes it.\n");
1277
1612
  for (const link2 of links) {
1278
- if (entryExists2(link2)) continue;
1613
+ if (entryExists(link2) && !isLink(link2) && isOurs(link2)) rmSync4(link2, { recursive: true, force: true });
1614
+ if (entryExists(link2)) continue;
1279
1615
  mkdirSync5(join9(link2, ".."), { recursive: true });
1280
1616
  linkDir(primary, link2);
1281
1617
  }
@@ -1283,9 +1619,9 @@ function installAgentSkill(enabled = enabledHarnesses()) {
1283
1619
  }
1284
1620
  function removeAgentSkill() {
1285
1621
  const all = agentSkillDirs(["claude-code", "codex"]);
1286
- const removed = all.filter((d) => entryExists2(d) && (isOurs(d) || isDangling(d)));
1622
+ const removed = all.filter((d) => entryExists(d) && (isOurs(d) || isDangling(d)));
1287
1623
  for (const d of removed) {
1288
- if (!entryExists2(d)) continue;
1624
+ if (!entryExists(d)) continue;
1289
1625
  if (lstatSync3(d).isSymbolicLink()) unlinkSync2(d);
1290
1626
  else rmSync4(d, { recursive: true, force: true });
1291
1627
  }
@@ -1299,14 +1635,17 @@ function refreshAgentSkill() {
1299
1635
  }
1300
1636
 
1301
1637
  // src/commands.ts
1302
- import { existsSync as existsSync14, readFileSync as readFileSync6 } from "fs";
1638
+ import { existsSync as existsSync14, readFileSync as readFileSync7 } from "fs";
1303
1639
  import { parseArgs as parseFlags } from "util";
1304
- import { basename as basename9, join as join15, resolve as resolve7, sep as sep6 } from "path";
1640
+ import { basename as basename9, join as join15, resolve as resolve8, sep as sep8 } from "path";
1305
1641
 
1306
1642
  // src/git.ts
1307
1643
  import { execFileSync as execFileSync2 } from "child_process";
1308
- import { existsSync as existsSync9 } from "fs";
1309
- import { join as join10, relative as relative3 } from "path";
1644
+ import { existsSync as existsSync9, realpathSync as realpathSync4 } from "fs";
1645
+ import { isAbsolute as isAbsolute2, join as join10, relative as relative3, sep as sep5 } from "path";
1646
+ function isShared(state) {
1647
+ return state === "committed" || state === "changed";
1648
+ }
1310
1649
  function git(root, args, input) {
1311
1650
  try {
1312
1651
  return execFileSync2("git", args, { cwd: root, encoding: "utf-8", input, stdio: ["pipe", "pipe", "ignore"], maxBuffer: 16 * 1024 * 1024 });
@@ -1316,11 +1655,32 @@ function git(root, args, input) {
1316
1655
  }
1317
1656
  }
1318
1657
  function gitInfo(root, extraDirs = []) {
1319
- if (!existsSync9(join10(root, ".git"))) return null;
1320
- const dirs = [...ALL_PROJECT_DIRS, MANIFEST_FILE, ...extraDirs].filter((d) => existsSync9(join10(root, d)));
1658
+ let inRepo;
1659
+ try {
1660
+ inRepo = execFileSync2("git", ["rev-parse", "--show-prefix"], { cwd: root, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
1661
+ } catch {
1662
+ if (!existsSync9(join10(root, ".git"))) return null;
1663
+ inRepo = "";
1664
+ }
1665
+ const real = (p) => {
1666
+ try {
1667
+ return realpathSync4.native(p);
1668
+ } catch {
1669
+ return p;
1670
+ }
1671
+ };
1672
+ const realRoot = real(root);
1673
+ const places = new Map(
1674
+ [.../* @__PURE__ */ new Set([...ALL_PROJECT_DIRS, MANIFEST_FILE, ...extraDirs])].filter((d) => existsSync9(join10(root, d))).map((d) => [d, relative3(realRoot, real(join10(root, d))).split(sep5).join("/")]).filter(([, place]) => place !== "" && place !== ".." && !place.startsWith("../") && !isAbsolute2(place))
1675
+ );
1676
+ const dirs = [...new Set(places.values())];
1321
1677
  if (dirs.length === 0) return { of: () => "new" };
1678
+ const placeOf = (rel) => {
1679
+ const dir = [...places.keys()].find((d) => rel === d || rel.startsWith(d + "/"));
1680
+ return dir === void 0 ? rel : places.get(dir) + rel.slice(dir.length);
1681
+ };
1322
1682
  const tracked = (git(root, ["ls-files", "-z", "--", ...dirs]) ?? "").split("\0").filter(Boolean);
1323
- const dirty = (git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ...dirs]) ?? "").split("\0").filter(Boolean).map((entry) => entry.slice(3));
1683
+ const dirty = (git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ...dirs]) ?? "").split("\0").filter(Boolean).map((entry) => entry.slice(3)).filter((path) => path.startsWith(inRepo)).map((path) => path.slice(inRepo.length));
1324
1684
  const ignoredCache = /* @__PURE__ */ new Map();
1325
1685
  const isIgnored = (rel) => {
1326
1686
  if (!ignoredCache.has(rel)) {
@@ -1332,7 +1692,7 @@ function gitInfo(root, extraDirs = []) {
1332
1692
  const under = (list2, rel) => list2.some((p) => p === rel || p.startsWith(rel + "/"));
1333
1693
  return {
1334
1694
  of: (relPath) => {
1335
- const rel = relPath.replace(/\/+$/, "");
1695
+ const rel = placeOf(relPath.replace(/\/+$/, ""));
1336
1696
  if (under(tracked, rel)) return under(dirty, rel) ? "changed" : "committed";
1337
1697
  return isIgnored(rel) ? "ignored" : "new";
1338
1698
  }
@@ -1347,13 +1707,16 @@ import { readdirSync as readdirSync8 } from "fs";
1347
1707
  import { basename as basename7 } from "path";
1348
1708
 
1349
1709
  // src/health.ts
1350
- import { existsSync as existsSync12, lstatSync as lstatSync4, readdirSync as readdirSync7, unlinkSync as unlinkSync4 } from "fs";
1351
- import { basename as basename6, dirname as dirname6, join as join13 } from "path";
1710
+ import { existsSync as existsSync12, lstatSync as lstatSync4, readdirSync as readdirSync7 } from "fs";
1711
+ import { basename as basename6, dirname as dirname6, isAbsolute as isAbsolute3, join as join13, relative as relative4, sep as sep7 } from "path";
1352
1712
 
1353
1713
  // src/plugins.ts
1354
1714
  import { execFileSync as execFileSync3 } from "child_process";
1355
- import { existsSync as existsSync10, mkdirSync as mkdirSync6, readdirSync as readdirSync5, readFileSync as readFileSync5, statSync as statSync4, writeFileSync as writeFileSync4 } from "fs";
1356
- import { basename as basename4, dirname as dirname4, join as join11, resolve as resolve6 } from "path";
1715
+ import { existsSync as existsSync10, mkdirSync as mkdirSync6, readdirSync as readdirSync5, readFileSync as readFileSync6, statSync as statSync4, writeFileSync as writeFileSync4 } from "fs";
1716
+ import { basename as basename4, dirname as dirname4, join as join11, resolve as resolve7 } from "path";
1717
+ function pluginNameOf(id) {
1718
+ return id.split("@")[0];
1719
+ }
1357
1720
  function nonEmptyDir(path) {
1358
1721
  try {
1359
1722
  return readdirSync5(path).some((f) => !f.startsWith("."));
@@ -1383,6 +1746,8 @@ function claudePlugins(machine, root) {
1383
1746
  scope: synced ? null : entry?.scope ?? "user",
1384
1747
  ...entry?.projectPath ? { projectPath: entry.projectPath } : {},
1385
1748
  synced,
1749
+ // Turned on in your settings with no install record (as installedPlugins finds it).
1750
+ ...!synced && !entries.length ? { settingsOnly: true } : {},
1386
1751
  skills,
1387
1752
  extras: extrasOf(dirname4(dirname4(skills[0])))
1388
1753
  };
@@ -1522,8 +1887,8 @@ function ignoreLocalSettings(root) {
1522
1887
  } catch {
1523
1888
  }
1524
1889
  try {
1525
- const exclude = resolve6(root, git2("rev-parse", "--git-path", "info/exclude"));
1526
- const before = existsSync10(exclude) ? readFileSync5(exclude, "utf-8") : "";
1890
+ const exclude = resolve7(root, git2("rev-parse", "--git-path", "info/exclude"));
1891
+ const before = existsSync10(exclude) ? readFileSync6(exclude, "utf-8") : "";
1527
1892
  mkdirSync6(dirname4(exclude), { recursive: true });
1528
1893
  writeFileSync4(exclude, `${before}${before && !before.endsWith("\n") ? "\n" : ""}/${git2("rev-parse", "--show-prefix")}${file}
1529
1894
  `);
@@ -1534,7 +1899,7 @@ function ignoreLocalSettings(root) {
1534
1899
  }
1535
1900
  function turnOffIn(plugin, root) {
1536
1901
  const done = `${plugin.id} turned off in ${basename4(root)}; other repos keep it`;
1537
- const bin = plugin.synced ? null : claudeBinary();
1902
+ const bin = plugin.synced || plugin.settingsOnly ? null : claudeBinary();
1538
1903
  if (!bin) {
1539
1904
  const r2 = setPluginIn(join11(root, ".claude", "settings.local.json"), plugin.id, false);
1540
1905
  return r2.ok ? { ok: true, message: `${done} (in .claude/settings.local.json${ignoreLocalSettings(root)})` } : { ok: false, message: `${plugin.id}: ${r2.reason}` };
@@ -1542,6 +1907,10 @@ function turnOffIn(plugin, root) {
1542
1907
  const r = claude(["plugin", "disable", plugin.id, "--scope", "local"], root, bin);
1543
1908
  return r.ok ? { ok: true, message: done } : { ok: false, message: `${plugin.id}: ${r.reason}` };
1544
1909
  }
1910
+ function unplaced(p, verb) {
1911
+ if (!p.scope || p.scope === "user" || p.projectPath) return null;
1912
+ return { ok: false, message: `${p.id}: skilllib doesn't know which project it's installed in; run \`claude plugin ${verb} ${p.id} --scope ${p.scope}\` there` };
1913
+ }
1545
1914
  function settingsFileOf(p) {
1546
1915
  if (p.synced || !p.scope || p.scope === "user") return join11(claudeDir(), "settings.json");
1547
1916
  if (!p.projectPath) return null;
@@ -1551,8 +1920,10 @@ function whereOf(p) {
1551
1920
  return p.projectPath ? ` in ${basename4(p.projectPath)} (${p.scope})` : "";
1552
1921
  }
1553
1922
  function setPluginOn(p, on) {
1554
- const done = { ok: true, message: `${p.id} is ${on ? "on" : "off"}${whereOf(p)}${p.synced ? "" : "; Claude Code picks it up in its next session"}` };
1555
- if (p.scope && p.scope !== "user" && !p.projectPath) return { ok: false, message: `${p.id}: skilllib doesn't know which project it's installed in; run \`claude plugin ${on ? "enable" : "disable"} ${p.id} --scope ${p.scope}\` there` };
1923
+ const shared = p.scope === "project" && p.projectPath ? "; it's in .claude/settings.json, which git tracks, so commit it if the team should get it" : "";
1924
+ const done = { ok: true, message: `${p.id} is ${on ? "on" : "off"}${whereOf(p)}${p.synced ? "" : "; Claude Code picks it up in its next session"}${shared}` };
1925
+ const lost = unplaced(p, on ? "enable" : "disable");
1926
+ if (lost) return lost;
1556
1927
  const bin = p.synced || p.settingsOnly ? null : claudeBinary();
1557
1928
  if (bin) {
1558
1929
  const r2 = claude(["plugin", on ? "enable" : "disable", p.id, ...p.scope ? ["--scope", p.scope] : []], p.projectPath, bin);
@@ -1560,12 +1931,14 @@ function setPluginOn(p, on) {
1560
1931
  }
1561
1932
  const r = setPluginIn(settingsFileOf(p), p.id, on);
1562
1933
  if (!r.ok) return { ok: false, message: `${p.id}: ${r.reason}` };
1563
- return p.scope === "local" && p.projectPath ? { ...done, message: done.message + ignoreLocalSettings(p.projectPath) } : done;
1934
+ if (p.scope === "local" && p.projectPath) return { ...done, message: done.message + ignoreLocalSettings(p.projectPath) };
1935
+ return done;
1564
1936
  }
1565
1937
  function uninstallPlugin(p) {
1566
1938
  if (p.synced) return { ok: false, message: `${p.id} is synced from your claude.ai account: remove it there, or turn it off here` };
1567
1939
  if (p.settingsOnly) return { ok: false, message: `${p.id} isn't installed, only turned on in your settings: turn it off instead` };
1568
- if (p.scope && p.scope !== "user" && !p.projectPath) return { ok: false, message: `${p.id}: skilllib doesn't know which project it's installed in; run \`claude plugin uninstall ${p.id} --scope ${p.scope}\` there` };
1940
+ const lost = unplaced(p, "uninstall");
1941
+ if (lost) return lost;
1569
1942
  const wasOn = enabledPlugins(p.projectPath)[p.id] === true;
1570
1943
  const r = claude(["plugin", "uninstall", p.id, "--keep-data", "--scope", p.scope ?? "user"], p.projectPath);
1571
1944
  if (!r.ok) return { ok: false, message: `${p.id}: ${r.reason}` };
@@ -1590,10 +1963,10 @@ function removedFile() {
1590
1963
  return join11(skilllibHome(), "plugin-backup.json");
1591
1964
  }
1592
1965
  function removePlugin(plugin, how) {
1593
- if (plugin.scope && plugin.scope !== "user" && !plugin.projectPath) {
1594
- return { ok: false, message: `${plugin.id}: installed for one project; run \`claude plugin ${how === "delete" ? "uninstall" : "disable"} ${plugin.id} --scope ${plugin.scope}\` there` };
1595
- }
1596
- const viaSettings = plugin.synced || !claudeBinary() && how === "off" && !plugin.projectPath;
1966
+ if (plugin.settingsOnly && how === "delete") return { ok: false, message: `${plugin.id} isn't installed, only turned on in your settings: turn it off instead` };
1967
+ const lost = unplaced(plugin, how === "delete" ? "uninstall" : "disable");
1968
+ if (lost) return lost;
1969
+ const viaSettings = plugin.synced || plugin.settingsOnly || !claudeBinary() && how === "off" && !plugin.projectPath;
1597
1970
  if (!viaSettings && !claudeBinary()) {
1598
1971
  return { ok: false, message: `${plugin.id}: nothing changed; Claude Code's CLI didn't run (tried \`claude\` on your PATH and ~/.local/bin/claude)` };
1599
1972
  }
@@ -1602,7 +1975,7 @@ function removePlugin(plugin, how) {
1602
1975
  const imported = plugin.skills.filter((dir) => !yours.has(basename4(dir)) && latestVersion(basename4(dir)) === null).map((dir) => importSkill(dir).name);
1603
1976
  const scope = plugin.scope ? ["--scope", plugin.scope] : [];
1604
1977
  const r = viaSettings ? (
1605
- // Synced plugins aren't installed, so `claude plugin` can't touch them: their switch is enabledPlugins.
1978
+ // Synced and settings-only plugins aren't installed, so `claude plugin` can't touch them: their switch is enabledPlugins.
1606
1979
  setPluginIn(join11(claudeDir(), "settings.json"), plugin.id, false)
1607
1980
  ) : (
1608
1981
  // --keep-data, as uninstallPlugin: its saved data stays, so a restore brings it back as it was.
@@ -1611,7 +1984,9 @@ function removePlugin(plugin, how) {
1611
1984
  const saved = imported.length ? `; ${imported.join(", ")} copied into Your skills` : "";
1612
1985
  if (!r.ok) return { ok: false, message: `${plugin.id}: ${r.reason}${saved}` };
1613
1986
  if (how === "delete" && !plugin.synced) recordRemovedPlugin(plugin.id, plugin.scope ?? "user", plugin.projectPath, wasOn);
1614
- return { ok: true, message: `${plugin.id} ${how === "delete" && !plugin.synced ? "removed" : "turned off"}${saved}` };
1987
+ const others = how === "delete" ? (installedPluginsJson()[plugin.id] ?? []).filter((e) => (e.scope ?? "user") !== (plugin.scope ?? "user") || e.projectPath !== plugin.projectPath) : [];
1988
+ const left = others.length ? `; its ${others.map((e) => `${e.scope ?? "user"} install${e.projectPath ? ` in ${basename4(e.projectPath)}` : ""}`).join(", ")} stays` : "";
1989
+ return { ok: true, message: `${plugin.id} ${how === "delete" && !plugin.synced ? `removed (${plugin.scope ?? "user"} install)` : "turned off"}${saved}${left}` };
1615
1990
  }
1616
1991
  function recordRemovedPlugin(id, scope, projectPath, on = true) {
1617
1992
  const list2 = readState(removedFile()) ?? [];
@@ -1642,12 +2017,12 @@ function restorePlugin(backup) {
1642
2017
  return { ok: true, to: where };
1643
2018
  }
1644
2019
  function cursorPluginSkills(plugins = cursorPlugins()) {
1645
- return plugins.flatMap((p) => p.skills.map((path) => ({ name: basename4(path), plugin: p.id.split("@")[0], path })));
2020
+ return plugins.flatMap((p) => p.skills.map((path) => ({ name: basename4(path), plugin: pluginNameOf(p.id), path })));
1646
2021
  }
1647
2022
 
1648
2023
  // src/tidy.ts
1649
2024
  import { existsSync as existsSync11, mkdirSync as mkdirSync7, readdirSync as readdirSync6, unlinkSync as unlinkSync3 } from "fs";
1650
- import { basename as basename5, dirname as dirname5, join as join12, sep as sep5 } from "path";
2025
+ import { basename as basename5, dirname as dirname5, join as join12, sep as sep6 } from "path";
1651
2026
  function entriesOf(dirs, name) {
1652
2027
  return dirs.flatMap((dir) => {
1653
2028
  const path = join12(dir, name);
@@ -1665,12 +2040,13 @@ function hashReals(reals) {
1665
2040
  function namesIn(dirs) {
1666
2041
  const names = /* @__PURE__ */ new Set();
1667
2042
  for (const dir of dirs) {
1668
- if (!existsSync11(dir)) continue;
2043
+ if (!isDir(dir)) continue;
1669
2044
  for (const name of readdirSync6(dir)) if (!name.startsWith(".") && name !== "synced") names.add(name);
1670
2045
  }
1671
2046
  return [...names].sort();
1672
2047
  }
1673
2048
  var PROJECT_TIDY_DIRS = ALL_PROJECT_DIRS.filter((d) => d !== GROK_SKILLS_DIR);
2049
+ var repoTidyDirs = (root) => PROJECT_TIDY_DIRS.filter((d) => notWritable(root, d) === null);
1674
2050
  function globalTidyDirs() {
1675
2051
  const own = realPath(join12(grokDir(), "skills"));
1676
2052
  return globalSkillDirs().filter((d) => realPath(d) !== own);
@@ -1697,11 +2073,13 @@ function planProjectTidy(root, { keep = {}, enabled = enabledHarnesses() } = {})
1697
2073
  const readsOf = (id) => harness(id).projectDirs.map(abs);
1698
2074
  let git2;
1699
2075
  const skillsLock = projectSkillsLock(root);
1700
- for (const name of namesIn(PROJECT_TIDY_DIRS.map(abs))) {
1701
- const entries = entriesOf(PROJECT_TIDY_DIRS.map(abs), name);
1702
- const external = entries.find((e) => e.link && e.real && !(e.real + sep5).startsWith(realpathOrNull(root) + sep5));
2076
+ const dirs = repoTidyDirs(root).map(abs);
2077
+ for (const name of namesIn(dirs)) {
2078
+ const entries = entriesOf(dirs, name);
2079
+ const external = entries.find((e) => e.link && e.real && !(e.real + sep6).startsWith(realpathOrNull(root) + sep6));
1703
2080
  if (external) {
1704
- report.skipped.push({ name, reason: `${rel(external.path)} links outside the repo` });
2081
+ const differs = entries.some((e) => e !== external && e.real && existsSync11(join12(e.real, "SKILL.md")) && treeHash(e.real) !== treeHash(external.real));
2082
+ report.skipped.push({ name, reason: `${rel(external.path)} links outside the repo`, ...differs ? { differs } : {} });
1705
2083
  continue;
1706
2084
  }
1707
2085
  const reals = entries.filter((e, i, all) => !e.link && all.findIndex((o) => !o.link && realpathOrNull(o.path) === realpathOrNull(e.path)) === i);
@@ -1719,7 +2097,7 @@ function planProjectTidy(root, { keep = {}, enabled = enabledHarnesses() } = {})
1719
2097
  if (git2 === void 0) git2 = gitInfo(root);
1720
2098
  const planned = installDirs(enabled).map(abs);
1721
2099
  const fromSkillsSh = name in skillsLock;
1722
- 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);
2100
+ const rank = (e) => (isShared(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);
1723
2101
  primary = [...reals].sort((a, b) => rank(a) - rank(b))[0];
1724
2102
  }
1725
2103
  if (!primary) continue;
@@ -1727,6 +2105,11 @@ function planProjectTidy(root, { keep = {}, enabled = enabledHarnesses() } = {})
1727
2105
  report.conflicts.push({ ...conflictOf(name, root, reals, enabled, readsOf), ...dep ? { managed: primary.dir } : {} });
1728
2106
  continue;
1729
2107
  }
2108
+ const elsewhere = entries.find((e) => e.link && e.real && e.real !== primary.real && existsSync11(join12(e.real, "SKILL.md")) && treeHash(e.real) !== treeHash(primary.path));
2109
+ if (elsewhere) {
2110
+ report.skipped.push({ name, reason: `${rel(elsewhere.path)} links to a different copy`, differs: true });
2111
+ continue;
2112
+ }
1730
2113
  const wanted = new Set([...installDirs(enabled, rel(primary.dir)), ...dep?.links ?? []].map(abs));
1731
2114
  const steps = [];
1732
2115
  for (const e of entries) {
@@ -1734,11 +2117,12 @@ function planProjectTidy(root, { keep = {}, enabled = enabledHarnesses() } = {})
1734
2117
  const needed = wanted.has(e.dir);
1735
2118
  if (e.link) {
1736
2119
  if (e.real === primary.real) {
1737
- if (!needed && !dep && ["committed", "changed"].includes((git2 ??= gitInfo(root))?.of(rel(e.path)) ?? "")) continue;
2120
+ if (!needed && !dep && isShared((git2 ??= gitInfo(root))?.of(rel(e.path)))) continue;
1738
2121
  if (!needed) steps.push({ kind: "unlink", path: e.path });
1739
2122
  } else steps.push(needed ? { kind: "link", path: e.path, target: primary.path } : { kind: "unlink", path: e.path });
1740
2123
  } else {
1741
2124
  const differs = e.hash !== primary.hash ? { differs: true } : {};
2125
+ if (!needed && !chosen && isShared((git2 ??= gitInfo(root))?.of(rel(e.path)))) continue;
1742
2126
  steps.push(needed ? { kind: "replace", path: e.path, target: primary.path, ...differs } : { kind: "remove", path: e.path, ...differs });
1743
2127
  }
1744
2128
  }
@@ -1778,13 +2162,13 @@ function planGlobalTidy({ keep = {}, enabled = enabledHarnesses() } = {}) {
1778
2162
  }
1779
2163
  function gitVisibleSteps(plan, git2 = plan.root ? gitInfo(plan.root) : null) {
1780
2164
  if (!plan.root || !git2) return [];
1781
- return plan.steps.filter((s) => {
1782
- const state = git2.of(relativeTo(plan.root, s.path));
1783
- return state === "committed" || state === "changed";
1784
- });
2165
+ const shared = (rel) => isShared(git2.of(rel));
2166
+ const dep = readManifest(plan.root).skills[plan.name];
2167
+ const recordsLink = (s) => !!dep && (s.kind === "link" || s.kind === "replace") && !dep.links?.includes(relativeTo(plan.root, dirname5(s.path)));
2168
+ return plan.steps.filter((s) => shared(relativeTo(plan.root, s.path)) || recordsLink(s) && shared(MANIFEST_FILE));
1785
2169
  }
1786
- function applyTidy(plan, { git: git2 = "keep", info: info2 } = {}) {
1787
- const held = git2 === "keep" ? gitVisibleSteps(plan, info2) : [];
2170
+ function applyTidy(plan, { git: git2 = "keep" } = {}) {
2171
+ const held = git2 === "keep" ? gitVisibleSteps(plan) : [];
1788
2172
  const applied = [];
1789
2173
  for (const step of plan.steps) {
1790
2174
  if (held.includes(step) || !entryExists(step.path) || "target" in step && !entryExists(step.target)) continue;
@@ -1822,18 +2206,19 @@ function describeStep(step, root) {
1822
2206
  return `remove ${step.differs ? "differing" : "duplicate"} copy ${shown}`;
1823
2207
  }
1824
2208
  }
1825
- function copyLabel(conflict, dir) {
1826
- return folderLabel(conflict.root, dir);
1827
- }
1828
2209
  function folderLabel(root, dir) {
1829
2210
  return root ? relativeTo(root, dir) : tildify(dir);
1830
2211
  }
1831
2212
  function foldersOf(root, name) {
1832
- const entries = entriesOf(root ? PROJECT_TIDY_DIRS.map((d) => join12(root, d)) : globalTidyDirs(), name);
2213
+ const entries = entriesOf(root ? repoTidyDirs(root).map((d) => join12(root, d)) : globalTidyDirs(), name);
1833
2214
  return { all: entries.map((e) => e.dir), real: entries.filter((e) => !e.link).map((e) => e.dir) };
1834
2215
  }
1835
2216
 
1836
2217
  // src/health.ts
2218
+ function detailWithGit(issue) {
2219
+ const notes = [...issue.fix ? [issue.fix] : [], ...issue.choices ?? []].filter((c) => c.hint && /\bgit\b|committed/.test(c.hint)).map((c) => `"${c.label}": ${c.hint}.`);
2220
+ return notes.length ? `${issue.detail} ${notes.join(" ")}` : issue.detail;
2221
+ }
1837
2222
  function runFix(choice) {
1838
2223
  try {
1839
2224
  return { ok: true, message: choice.run() };
@@ -1865,6 +2250,16 @@ function summarize(where, verb, changes) {
1865
2250
  }
1866
2251
  var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
1867
2252
  var rows = (root, skills) => skills.map((skill) => ({ root, skill }));
2253
+ var removesClean = (root, s) => s.managed && !s.state.startsWith("edited") && s.state !== "not in library" && !syncBlocker(root, s.name);
2254
+ var gitMemo = /* @__PURE__ */ new Map();
2255
+ var gitOf = (root) => {
2256
+ if (!gitMemo.has(root)) gitMemo.set(root, gitInfo(root));
2257
+ return gitMemo.get(root);
2258
+ };
2259
+ var gitNote = (root, paths) => {
2260
+ const git2 = gitOf(root);
2261
+ return paths.some((p) => isShared(git2?.of(relativeTo(root, p)))) ? "; git will see it" : "";
2262
+ };
1868
2263
  var agentNames = (ids) => ids.map((id) => harness(id).name).join(", ");
1869
2264
  function conflictIssue(c) {
1870
2265
  const where = c.root ? basename6(c.root) : "your global folders";
@@ -1874,21 +2269,23 @@ function conflictIssue(c) {
1874
2269
  const plan = planFor(dir);
1875
2270
  const r = plan ? applyTidy(plan, { git: git2 }) : null;
1876
2271
  if (!r?.applied.length) throw new Error(`${c.name}: nothing changed${r?.held.length ? " (git would see every change)" : ""}`);
1877
- return `${c.name}: kept the ${copyLabel(c, dir)} copy${r.held.length ? `; held back ${plural(r.held.length, "change")} git would see` : ""}`;
2272
+ return `${c.name}: kept the ${folderLabel(c.root, dir)} copy${r.held.length ? `; held back ${plural(r.held.length, "change")} git would see` : ""}`;
1878
2273
  };
1879
2274
  return {
1880
2275
  id: `conflict:${c.root ?? "global"}:${c.name}`,
1881
2276
  severity: "problem",
1882
2277
  title: `${c.name} in ${where}: the copies differ`,
1883
- 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 with skilllib restore (Settings \u203A Backups in the app).`,
2278
+ detail: `Your agents run different versions: ${c.copies.map((x) => `${folderLabel(c.root, x.dir)}${x.runs.length ? ` (${agentNames(x.runs)})` : ""}`).join(" vs ")}. Keep one; the others become links to it and are restorable with skilllib restore (Settings \u203A Backups in the app).`,
1884
2279
  at: rows(c.root, [c.name]),
1885
2280
  choices: winners.flatMap((x) => {
1886
2281
  const plan = planFor(x.dir);
2282
+ if (!plan?.steps.length) return [];
1887
2283
  const visible2 = plan ? gitVisibleSteps(plan).map((s) => describeStep(s, c.root)) : [];
1888
- const label = `Keep the ${copyLabel(c, x.dir)} copy`;
2284
+ const label = `Keep the ${folderLabel(c.root, x.dir)} copy`;
1889
2285
  if (!visible2.length) return [{ label, hint: x.dir === c.managed ? "skilllib's version" : "the others become links to it", run: keep(x.dir, "go") }];
1890
2286
  return [
1891
- { label: `${label}, but keep git as it is`, hint: `skips what git would see: ${visible2.join(", ")}`, run: keep(x.dir, "keep") },
2287
+ // "Keep git as it is" only when it leaves something to do.
2288
+ ...visible2.length < (plan?.steps.length ?? 0) ? [{ label: `${label}, but keep git as it is`, hint: `skips what git would see: ${visible2.join(", ")}`, run: keep(x.dir, "keep") }] : [],
1892
2289
  { label: `${label}, and change what git tracks`, hint: `git will see: ${visible2.join(", ")}`, run: keep(x.dir, "go") }
1893
2290
  ];
1894
2291
  })
@@ -1898,34 +2295,68 @@ function applyAll(plans, git2) {
1898
2295
  const results = plans.map((p) => applyTidy(p, { git: git2 }));
1899
2296
  return { skills: results.filter((r) => r.applied.length).length, held: results.reduce((n, r) => n + r.held.length, 0) };
1900
2297
  }
1901
- function brokenLinksIn(root) {
2298
+ function brokenLinksIn(root, opened) {
1902
2299
  const where = basename6(root);
1903
- const tidied = ALL_PROJECT_DIRS.filter((d) => d !== GROK_SKILLS_DIR);
1904
- const hasCopy = (name) => tidied.some((d) => !isLink(join13(root, d, name)) && existsSync12(join13(root, d, name, "SKILL.md")));
2300
+ const tidyDirs = repoTidyDirs(root);
2301
+ const hasCopy = (name) => tidyDirs.some((d) => !isLink(join13(root, d, name)) && existsSync12(join13(root, d, name, "SKILL.md")));
2302
+ const mends = (project, dir, name) => {
2303
+ const dep = pinsIn(project)[name];
2304
+ return !!dep && (dep.links ?? []).includes(dir) && !syncBlocker(project, name, dep) && versionForHash(name, dep.hash) !== null;
2305
+ };
2306
+ const syncMends = (dir, name) => mends(root, dir, name);
1905
2307
  let git2;
1906
- return ALL_PROJECT_DIRS.flatMap((dir) => {
2308
+ const realRoot = realNative(root) ?? root;
2309
+ const ownerOf = (real) => {
2310
+ let nearest = null;
2311
+ for (let d = dirname6(dirname6(real)); d.startsWith(realRoot + sep7); d = dirname6(d)) {
2312
+ if (!existsSync12(join13(d, MANIFEST_FILE))) continue;
2313
+ if (opened.has(d)) return { opened: true, own: d };
2314
+ nearest ??= d;
2315
+ }
2316
+ return { opened: false, own: nearest };
2317
+ };
2318
+ const seen = /* @__PURE__ */ new Set();
2319
+ const aliased = (dir) => (realNative(join13(root, dir)) ?? "") !== join13(realRoot, dir);
2320
+ const packageFolders = packageSkillFolders(root).map((f) => ({ dir: f.location, top: false })).sort((a, b) => Number(aliased(a.dir)) - Number(aliased(b.dir)));
2321
+ const folders2 = [...ALL_PROJECT_DIRS.map((dir) => ({ dir, top: true })), ...packageFolders].flatMap(({ dir, top }) => {
2322
+ const real = realNative(join13(root, dir)) ?? join13(root, dir);
2323
+ if (seen.has(real)) return [];
2324
+ seen.add(real);
2325
+ const owner = ownerOf(real);
2326
+ return owner.opened ? [] : [{ dir, top: top && !owner.own, own: owner.own, real }];
2327
+ });
2328
+ return folders2.flatMap(({ dir, top, own, real }) => {
1907
2329
  const folder = join13(root, dir);
1908
- if (!existsSync12(folder)) return [];
2330
+ if (!isDir(folder) || notWritable(root, dir)) return [];
1909
2331
  return readdirSync7(folder).flatMap((name) => {
1910
2332
  const path = join13(folder, name);
1911
- if (!isLink(path) || existsSync12(path) || dir !== GROK_SKILLS_DIR && hasCopy(name)) return [];
2333
+ if (!isLink(path) || isDir(path) || top && (dir !== GROK_SKILLS_DIR && hasCopy(name) || syncMends(dir, name))) return [];
1912
2334
  const remove2 = {
1913
2335
  label: "Remove the link",
2336
+ hint: "restorable with skilllib restore",
1914
2337
  run: () => {
1915
- unlinkSync4(path);
1916
- return `Removed the broken link ${dir}/${name} in ${where}`;
2338
+ if (!isLink(path)) return `${dir}/${name} in ${where} is already gone`;
2339
+ stash(path, "tidy-backup");
2340
+ return `Removed the broken link ${dir}/${name} in ${where} (restorable with skilllib restore)`;
1917
2341
  }
1918
2342
  };
1919
- git2 ??= gitInfo(root);
1920
- const tracked = ["committed", "changed"].includes(git2?.of(relativeTo(root, path)) ?? "");
2343
+ const placeOf = (p) => relative4(realRoot, p).split(sep7).join("/");
2344
+ git2 ??= gitInfo(
2345
+ root,
2346
+ folders2.map((f) => placeOf(f.real)).filter((p) => p !== "" && p !== ".." && !p.startsWith("../") && !isAbsolute3(p))
2347
+ );
2348
+ const tracked = isShared(git2?.of(placeOf(join13(real, name))));
2349
+ const recorded = !!own && mends(own, relative4(own, real).split(sep7).join("/"), name);
2350
+ const pinnedThere = own ? ` ${placeOf(own)} has its own skilllib.json${recorded ? `, which records this link: skilllib sync in ${placeOf(own)} brings back what it points to` : ""}.` : "";
1921
2351
  return [
1922
2352
  {
1923
2353
  id: `broken:${root}:${dir}/${name}`,
1924
2354
  severity: "problem",
1925
2355
  title: `${name}: broken link in ${where}/${dir}`,
1926
- detail: `It points at a folder that no longer exists, so it loads nothing.${tracked ? " Git tracks the link, so removing it shows in git status." : ""}`,
2356
+ detail: `It points at ${existsSync12(path) ? "a file, not a skill folder" : "a folder that no longer exists"}, so it loads nothing.${tracked ? " Git tracks the link, so removing it shows in git status." : ""}${pinnedThere}`,
1927
2357
  at: rows(root, [name]),
1928
- ...tracked ? { choices: [remove2] } : { fix: remove2 }
2358
+ // Removed by itself only at the repo's top, uncommitted: a package's link (or a committed one) is asked first.
2359
+ ...top && !tracked ? { fix: remove2 } : { choices: [remove2] }
1929
2360
  }
1930
2361
  ];
1931
2362
  });
@@ -1956,32 +2387,37 @@ function grokDiffers(projects2, machine, statusOf) {
1956
2387
  return [...global, ...repos];
1957
2388
  }
1958
2389
  function findIssues(roots, machine, libraryNames, statusOf = projectStatus, loadedIn = (root) => skillsLoadedIn(root, machine), nestedOf = nestedSkills) {
2390
+ gitMemo = /* @__PURE__ */ new Map();
1959
2391
  const { readable: projects2, unreadable } = readableRoots(roots);
1960
2392
  const issues = unreadable.map(({ root, reason }) => ({
1961
2393
  id: `manifest:${root}`,
1962
2394
  severity: "problem",
1963
2395
  title: `${basename6(root)}: skilllib.json can't be read`,
1964
2396
  detail: `${reason}. Until it's fixed, skilllib leaves ${basename6(root)} out.`,
1965
- at: []
2397
+ at: [{ root, skill: "" }]
1966
2398
  }));
1967
2399
  const statuses = new Map(projects2.map((root) => [root, statusOf(root)]));
2400
+ const openedProjects = new Set(projects2.map((p) => realNative(p) ?? p));
1968
2401
  issues.push(...grokDiffers(projects2, machine, (root) => statuses.get(root)));
1969
2402
  const nested = new Map(projects2.map((root) => [root, nestedOf(root)]));
1970
2403
  for (const skill of machine.filter((m) => m.broken)) {
2404
+ const reaches = isDir(skill.path) ? "folder" : existsSync12(skill.path) ? "file" : "nothing";
2405
+ const remove2 = {
2406
+ label: "Remove the link",
2407
+ hint: "restorable with skilllib restore",
2408
+ run: () => {
2409
+ const r = unloadGlobal(skill.path);
2410
+ if (!r.ok) throw new Error(`${skill.name}: ${r.reason}`);
2411
+ return `Removed broken link ${skill.name}`;
2412
+ }
2413
+ };
1971
2414
  issues.push({
1972
2415
  id: `broken:${skill.path}`,
1973
2416
  severity: "problem",
1974
2417
  title: `${skill.name}: broken link in ${tildify(dirname6(skill.path))}`,
1975
- detail: "It points at a folder that no longer exists, so it loads nothing.",
2418
+ detail: reaches === "nothing" ? "It points at a folder that no longer exists, so it loads nothing." : reaches === "file" ? "It points at a file, not a skill folder, so it loads nothing." : "It points at a folder with no SKILL.md: most agents load nothing from it (Cursor may find skills deeper inside).",
1976
2419
  at: rows(null, [skill.name]),
1977
- fix: {
1978
- label: "Remove the link",
1979
- run: () => {
1980
- const r = unloadGlobal(skill.path);
1981
- if (!r.ok) throw new Error(`${skill.name}: ${r.reason}`);
1982
- return `Removed broken link ${skill.name}`;
1983
- }
1984
- }
2420
+ ...reaches === "folder" ? { choices: [remove2] } : { fix: remove2 }
1985
2421
  });
1986
2422
  }
1987
2423
  const loaded = machine.filter((m) => !m.broken);
@@ -2006,18 +2442,21 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2006
2442
  ];
2007
2443
  const pluginAgentsIn = (plugin, root) => new Set(loadedHere.get(root).filter((m) => plugin.skills.includes(m.path)).flatMap((m) => m.harnesses));
2008
2444
  const globalYours = new Set(loaded.filter((m) => m.movable).map((m) => m.name));
2009
- const installedYours = /* @__PURE__ */ new Set([...globalYours, ...[...statuses.values()].flat().map((s) => s.name)]);
2445
+ const installedYours = /* @__PURE__ */ new Set([...globalYours, ...[...statuses.values()].flat().map((s) => s.name), ...knownProjects().flatMap((p) => Object.keys(pinsIn(p)))]);
2010
2446
  const repoChoices = (plugin, root, names) => {
2011
2447
  const where = basename6(root);
2012
2448
  const copies = statuses.get(root).filter((s) => names.includes(s.name));
2013
- const allManaged = names.every((n) => copies.some((c) => c.name === n && c.managed));
2449
+ const allManaged = names.every((n) => copies.some((c) => c.name === n && removesClean(root, c)));
2450
+ const committed = gitNote(root, copies.map((s) => s.path)) !== "";
2451
+ const pluginAgents = pluginAgentsIn(plugin, root);
2452
+ const leftOut = copies.flatMap((s) => s.visibility.filter((v) => v.paths > 0 && !pluginAgents.has(v.id)).map((v) => v.id));
2014
2453
  return [
2015
2454
  { label: `Turn the plugin ${plugin.id} off in ${where} only`, hint: "other repos keep it; in .claude/settings.local.json, a local-only file", run: () => orThrow(turnOffIn(plugin, root)) },
2016
2455
  // Only copies skilllib installed: Your skills keeps them. A repo's own skill is the team's, never removed from here.
2017
- ...names.length && allManaged ? [
2456
+ ...names.length && allManaged && !committed && !leftOut.length ? [
2018
2457
  {
2019
2458
  label: `Remove your ${copies.map((s) => s.name).join(", ")} from ${where}`,
2020
- hint: "the plugin's copy stays; Your skills keeps yours",
2459
+ hint: `the plugin's copy stays; Your skills keeps yours${gitNote(root, [join13(root, MANIFEST_FILE)])}`,
2021
2460
  run: () => {
2022
2461
  for (const s of copies) {
2023
2462
  const r = removeSkill(root, s.name);
@@ -2039,7 +2478,7 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2039
2478
  const dupes = names.filter((n) => libraryNames.has(n) || loadedYours.has(n));
2040
2479
  if (!dupes.length) continue;
2041
2480
  const others = plugin.skills.length - dupes.length;
2042
- const pluginName = plugin.id.split("@")[0];
2481
+ const pluginName = pluginNameOf(plugin.id);
2043
2482
  const remove2 = {
2044
2483
  label: `Remove the plugin ${plugin.id}`,
2045
2484
  hint: plugin.extras.length ? `also removes its ${plugin.extras.join(", ")}` : "restorable with skilllib restore",
@@ -2058,12 +2497,13 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2058
2497
  claudeBoth.length ? `Claude Code loads both (the plugin's as /${pluginName}:${claudeBoth[0]}). Your skill wins.` : "",
2059
2498
  cursorWheres.length ? `${cursorWheres.join(", ")} ${cursorWheres.length === 1 ? "turns" : "turn"} it off for Claude Code, but Cursor ignores repo settings and lists both there: only turning the plugin off or removing it changes that.` : "",
2060
2499
  notInstalled.length ? `Your ${notInstalled.join(", ")} ${notInstalled.length === 1 ? "is" : "are"} only in Your skills, not installed anywhere: once the plugin is gone, add ${notInstalled.length === 1 ? "it" : "them"} where you need ${notInstalled.length === 1 ? "it" : "them"}.` : "",
2061
- others ? `The plugin's other ${others === 1 ? "skill is" : `${others} skills are`} copied into Your skills first, so nothing is lost.` : "",
2062
- 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"}.` : "",
2500
+ others ? `Turning it off or removing it copies its other ${others === 1 ? "skill" : `${others} skills`} into Your skills first, so nothing is lost.` : "",
2501
+ plugin.extras.length ? `It also brings ${plugin.extras.join(", ")}: ${plugin.synced || plugin.settingsOnly ? "turning it off pauses those" : "removing it drops those, turning it off pauses them"}.` : "",
2063
2502
  plugin.synced ? "It's synced from claude.ai, so it can only be turned off here." : "",
2503
+ plugin.settingsOnly ? "It's only turned on in your settings, not installed, so it can only be turned off here." : "",
2064
2504
  inRepos.length ? `Or keep it and turn it off only in ${inRepos.map(([root]) => basename6(root)).join(", ")}.` : ""
2065
2505
  ].filter(Boolean).join(" "),
2066
- choices: [...plugin.synced ? [off] : plugin.extras.length ? [off, remove2] : [remove2, off], ...inRepos.flatMap(([root, d]) => repoChoices(plugin, root, d))],
2506
+ choices: [...plugin.synced || plugin.settingsOnly ? [off] : plugin.extras.length ? [off, remove2] : [remove2, off], ...inRepos.flatMap(([root, d]) => repoChoices(plugin, root, d))],
2067
2507
  // Global's rows (yours and the plugin's), and the repos where both load.
2068
2508
  at: [...rows(null, dupes), ...[...inRepos, ...cursorRepos].flatMap(([root, d]) => rows(root, d))],
2069
2509
  plugin: plugin.id
@@ -2086,7 +2526,7 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2086
2526
  for (const root of projects2) {
2087
2527
  const where = basename6(root);
2088
2528
  const repoOnly = loadedHere.get(root).filter((m) => m.kind === "plugin" && !machinePluginSkills.has(m.path));
2089
- for (const plugin of claudePlugins(repoOnly, root)) {
2529
+ for (const plugin of enabledHarnesses().includes("claude-code") ? claudePlugins(repoOnly, root) : []) {
2090
2530
  const names = plugin.skills.map((p) => basename6(p));
2091
2531
  const inRepo = namesIn2(root).filter((n) => names.includes(n));
2092
2532
  const dupes = names.filter((n) => inRepo.includes(n) || globalYours.has(n));
@@ -2095,7 +2535,7 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2095
2535
  id: `plugin:${root}:${plugin.id}`,
2096
2536
  severity: "problem",
2097
2537
  title: `${dupes.join(", ")}: also in the Claude Code plugin ${plugin.id}, on in ${where}`,
2098
- detail: `${where}'s Claude Code settings turn the plugin on, so Claude Code loads both there (the plugin's as /${plugin.id.split("@")[0]}:${dupes[0]}). Your skill wins.`,
2538
+ detail: `${where}'s Claude Code settings turn the plugin on, so Claude Code loads both there (the plugin's as /${pluginNameOf(plugin.id)}:${dupes[0]}). Your skill wins.`,
2099
2539
  choices: repoChoices(plugin, root, inRepo),
2100
2540
  at: rows(root, dupes),
2101
2541
  plugin: plugin.id
@@ -2180,19 +2620,37 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2180
2620
  for (const root of projects2) {
2181
2621
  const where = basename6(root);
2182
2622
  const status2 = statuses.get(root);
2183
- issues.push(...brokenLinksIn(root));
2623
+ issues.push(...brokenLinksIn(root, openedProjects));
2184
2624
  const pins = readManifest(root).skills;
2185
- const missing = status2.filter((s) => s.state === "folder missing" && s.latest !== null);
2186
- const restorable = missing.filter((s) => versionForHash(s.name, pins[s.name]?.hash ?? "") !== null);
2187
- for (const s of missing.filter((m) => !restorable.includes(m)))
2625
+ const blockedFor = /* @__PURE__ */ new Map();
2626
+ const untrackable = (s) => {
2627
+ if (!blockedFor.has(s.name)) blockedFor.set(s.name, trackBlocker(root, s.name));
2628
+ return blockedFor.get(s.name);
2629
+ };
2630
+ const blockers = new Map(status2.filter((s) => s.managed).flatMap((s) => ((b) => b ? [[s.name, b]] : [])(syncBlocker(root, s.name, pins[s.name]))));
2631
+ for (const [name, blocker] of blockers)
2632
+ issues.push({
2633
+ id: `stuck:${root}:${name}`,
2634
+ severity: "problem",
2635
+ title: `${name} in ${where}: sync can't put it back as skilllib.json pins it`,
2636
+ // What to do depends on what's there: a folder that links out of the repo is a setup of yours, and a pin
2637
+ // your library has no version of can't come back by sync anyway.
2638
+ detail: `${blocker.said}, so sync can't put it back there.${blocker.outside ? " skilllib only installs inside the repo: point that folder back into the repo, or remove the skill here (skilllib remove)." : versionForHash(name, pins[name]?.hash ?? "") !== null ? " Move it out of the way, then run skilllib sync." : " Move it out of the way; your library has no copy of the pinned version either."}`,
2639
+ at: rows(root, [name])
2640
+ });
2641
+ const unstuck = status2.filter((s) => !blockers.has(s.name));
2642
+ const missing = unstuck.filter((s) => s.state === "folder missing" && s.latest !== null);
2643
+ const knownPin = (s) => versionForHash(s.name, pins[s.name]?.hash ?? "") !== null;
2644
+ const restorable = missing.filter(knownPin);
2645
+ for (const s of unstuck.filter((m) => (m.state === "folder missing" || m.state === "out of sync") && m.latest !== null && !knownPin(m)))
2188
2646
  issues.push({
2189
2647
  id: `pin:${root}:${s.name}`,
2190
2648
  severity: "problem",
2191
2649
  title: `${s.name}: the version ${where}'s skilllib.json pins isn't in your library`,
2192
- detail: `The folder is missing, and no version in Your skills has the content it pins (pinned from another library?). Install your newest v${s.latest}; ${where}'s skilllib.json then pins it.`,
2650
+ detail: `${s.state === "folder missing" ? "The folder is missing, and no" : "The copy is another version, and no"} version in Your skills has the content it pins (pinned from another library?). ${untrackable(s) ? `${untrackable(s)}, so skilllib can't install it here.` : `Install your newest v${s.latest}; ${where}'s skilllib.json then pins it.`}`,
2193
2651
  at: rows(root, [s.name]),
2194
2652
  // It changes what the shared skilllib.json pins: a choice, never run by `doctor --fix`.
2195
- choices: [{ label: `Install your newest (v${s.latest})`, hint: `${where}'s skilllib.json pins it`, run: () => `${s.name}: installed v${track(root, s.name).to}` }]
2653
+ ...untrackable(s) ? {} : { choices: [{ label: `Install your newest (v${s.latest})`, hint: `${where}'s skilllib.json pins it`, run: () => `${s.name}: installed v${track(root, s.name).to}` }] }
2196
2654
  });
2197
2655
  if (restorable.length > 0) {
2198
2656
  issues.push({
@@ -2203,16 +2661,35 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2203
2661
  at: rows(root, restorable.map((s) => s.name)),
2204
2662
  fix: {
2205
2663
  label: "Sync the project",
2206
- run: () => summarize(where, "restored", syncProject(root))
2664
+ // Only the missing folders: an out-of-sync copy (maybe committed) is its own issue's choice.
2665
+ run: () => summarize(where, "restored", syncProject(root, { names: restorable.map((s) => s.name) }))
2207
2666
  }
2208
2667
  });
2209
2668
  }
2210
- for (const s of status2.filter((s2) => s2.state === "folder missing" && s2.latest === null))
2669
+ const drifted = unstuck.filter((s) => s.state === "out of sync" && knownPin(s));
2670
+ if (drifted.length) {
2671
+ const seen = drifted.some((s) => isShared(gitOf(root)?.of(relativeTo(root, s.path))));
2672
+ const yours = drifted.some((s) => untrackable(s));
2673
+ const install = {
2674
+ label: "Install the pinned versions",
2675
+ hint: seen ? "git will see the copies change" : "your library keeps every version",
2676
+ run: () => summarize(where, "synced", syncProject(root, { names: drifted.map((s) => s.name) }))
2677
+ };
2678
+ issues.push({
2679
+ id: `out-of-sync:${root}`,
2680
+ severity: "suggestion",
2681
+ title: `${where}: ${plural(drifted.length, "skill")} not at the version skilllib.json pins`,
2682
+ detail: `${drifted.map((s) => s.name).join(", ")}: each copy is another version from your library. sync installs the pinned one.${yours ? ` ${[...new Set(drifted.flatMap((s) => untrackable(s) ? [untrackable(s)] : []))].map((why) => `${drifted.filter((s) => untrackable(s) === why).map((s) => s.name).join(", ")}: ${why}`).join("; ")}, so skilllib can't pin the version that's there; ${drifted.filter((s) => untrackable(s)).length === 1 ? "it may be edits you copied into Your skills on purpose, and stays" : "they may be edits you copied into Your skills on purpose, and stay"} unless you choose this.` : ""}`,
2683
+ at: rows(root, drifted.map((s) => s.name)),
2684
+ ...seen || yours ? { choices: [install] } : { fix: install }
2685
+ });
2686
+ }
2687
+ for (const s of unstuck.filter((s2) => s2.state === "folder missing" && s2.latest === null))
2211
2688
  issues.push({
2212
2689
  id: `unavailable:${root}:${s.name}`,
2213
2690
  severity: "problem",
2214
2691
  title: `${s.name}: pinned in ${where}'s skilllib.json, but not in your library`,
2215
- detail: `Its folder is missing, and your library (${tildify(libraryDir())}) has no copy to restore: import it, or set SKILLLIB_HOME to the library that has it.`,
2692
+ detail: `Its folder is missing, and your library (${tildify(libraryDir())}) has no copy to restore: import it, or set SKILLLIB_HOME to the skilllib folder whose library has it.`,
2216
2693
  at: rows(root, [s.name]),
2217
2694
  // Editing skilllib.json is the team's call: a choice, never run by `doctor --fix`.
2218
2695
  choices: [
@@ -2227,7 +2704,7 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2227
2704
  }
2228
2705
  ]
2229
2706
  });
2230
- const behind = status2.filter((s) => s.state === "update available");
2707
+ const behind = unstuck.filter((s) => s.state === "update available" && !untrackable(s));
2231
2708
  if (behind.length > 0)
2232
2709
  issues.push({
2233
2710
  id: `update:${root}`,
@@ -2235,14 +2712,18 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2235
2712
  title: `${where}: ${plural(behind.length, "skill")} out of date`,
2236
2713
  detail: behind.map((s) => `${s.name} (v${s.version} \u2192 v${s.latest})`).join(", "),
2237
2714
  at: rows(root, behind.map((s) => s.name)),
2238
- fix: {
2239
- label: "Update to the newest versions",
2240
- run: () => summarize(where, "updated", updateProject(root, behind.map((s) => s.name)))
2241
- }
2715
+ // It changes what the repo pins and the copies its agents load (often committed): a choice, never `doctor --fix`.
2716
+ choices: [
2717
+ {
2718
+ label: "Update to the newest versions",
2719
+ hint: isShared(gitOf(root)?.of(MANIFEST_FILE)) ? "git will see the new copies and skilllib.json" : "replaces each copy with your library's newest",
2720
+ run: () => summarize(where, "updated", updateProject(root, behind.map((s) => s.name)))
2721
+ }
2722
+ ]
2242
2723
  });
2243
2724
  const tidy2 = planProjectTidy(root);
2244
2725
  if (tidy2.plans.length) {
2245
- const git3 = gitInfo(root);
2726
+ const git3 = gitOf(root);
2246
2727
  const visible2 = tidy2.plans.flatMap((p) => gitVisibleSteps(p, git3).map((s) => describeStep(s, root)));
2247
2728
  const run = (mode) => () => {
2248
2729
  const r = applyAll(tidy2.plans, mode);
@@ -2253,7 +2734,7 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2253
2734
  id: `tidy:${root}`,
2254
2735
  severity: "suggestion",
2255
2736
  title: `${where}: ${plural(tidy2.plans.length, "skill")} with extra copies or links`,
2256
- 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 with skilllib restore."),
2737
+ 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(", ")}.` : tidy2.plans.some((p) => p.steps.some((s) => s.kind === "replace" || s.kind === "remove")) ? ". Replaced copies are restorable with skilllib restore." : "."),
2257
2738
  at: rows(root, tidy2.plans.map((p) => p.name)),
2258
2739
  ...visible2.length ? {
2259
2740
  choices: [
@@ -2265,8 +2746,47 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2265
2746
  });
2266
2747
  }
2267
2748
  issues.push(...tidy2.conflicts.map(conflictIssue));
2268
- const loadsGlobally = (name, id) => globalCopies.some((m) => m.name === name && m.harnesses.includes(id));
2749
+ for (const s of tidy2.skipped.filter((x) => x.differs))
2750
+ issues.push({
2751
+ id: `linkdiffers:${root}:${s.name}`,
2752
+ severity: "problem",
2753
+ title: `${s.name} in ${where}: the copies differ`,
2754
+ detail: `${s.reason}, with other content than the copy beside it: your agents run different versions. Make them the same, or remove the link; skilllib doesn't change a link to another copy.`,
2755
+ at: rows(root, [s.name])
2756
+ });
2757
+ const loadsGlobally = (name, id) => {
2758
+ const here = status2.find((s) => s.name === name);
2759
+ return globalCopies.some((m) => m.name === name && m.harnesses.includes(id) && (!here || treeHash(m.path) === treeHash(here.path)));
2760
+ };
2269
2761
  const blindHere = status2.filter((s) => s.state !== "folder missing" && s.visibility.some((v) => v.paths === 0 && !loadsGlobally(s.name, v.id))).map((s) => ({ s, would: linkEverywhere(root, s.name, s.location, { dryRun: true }) }));
2762
+ const enabledIds = enabledHarnesses();
2763
+ const notFolders = /* @__PURE__ */ new Set();
2764
+ for (const dir of new Set(enabledIds.map((id) => harness(id).projectDirs[0]))) {
2765
+ const blocker = notWritable(root, dir);
2766
+ if (!blocker || notFolders.has(blocker)) continue;
2767
+ notFolders.add(blocker);
2768
+ const who = agentNames(enabledIds.filter((id) => notWritable(root, harness(id).projectDirs[0]) === blocker));
2769
+ const out2 = linksOut(root, blocker);
2770
+ issues.push({
2771
+ id: `notdir:${root}:${blocker}`,
2772
+ severity: out2 ? "suggestion" : "problem",
2773
+ title: out2 ? `${where}: ${blocker} links out of the repo` : `${where}: ${blocker} isn't a folder`,
2774
+ detail: out2 ? `${blockerSaid(root, blocker)}. ${who} ${who.includes(",") || who.includes(" and ") ? "load" : "loads"} what's there, but skilllib treats it as outside ${where}: it adds, links and removes nothing there.` : `${blockerSaid(root, blocker)}: ${who} can't load skills from ${where}, and skilllib adds none there. Move it out of the way, then run skilllib link.`,
2775
+ at: [{ root, skill: "" }]
2776
+ // the repo itself: its row in Places
2777
+ });
2778
+ }
2779
+ for (const { s, would } of blindHere) {
2780
+ const way = would.inTheWay.filter((p) => !notFolders.has(p));
2781
+ if (!way.length || blockers.has(s.name)) continue;
2782
+ issues.push({
2783
+ id: `intheway:${root}:${s.name}`,
2784
+ severity: "problem",
2785
+ title: `${s.name} in ${where}: ${agentNames(s.visibility.filter((v) => v.paths === 0).map((v) => v.id))} can't use it here`,
2786
+ detail: `${inTheWaySaid(root, way)}. skilllib won't link past what's there: clear the way, then run skilllib link.`,
2787
+ at: rows(root, [s.name])
2788
+ });
2789
+ }
2270
2790
  for (const { s, would } of blindHere.filter(({ would: w }) => !w.created.length && !w.blocked.length && w.uncommitted.length))
2271
2791
  issues.push({
2272
2792
  id: `uncommitted:${root}:${s.name}`,
@@ -2279,20 +2799,25 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2279
2799
  if (partial.length) {
2280
2800
  const missing2 = [...new Set(partial.flatMap((s) => s.visibility.filter((v) => v.paths === 0).map((v) => v.id)))];
2281
2801
  const tracked = [...new Set(blindHere.flatMap(({ would }) => would.blocked))];
2802
+ const manifestShared = partial.some((s) => s.managed) && isShared(gitOf(root)?.of(MANIFEST_FILE));
2282
2803
  const run = (allowTracked) => () => {
2283
2804
  if (allowTracked) allowTrackedLinks(root);
2284
- const r = linkAll(root, { allowTracked });
2805
+ const r = linkAll(root, { allowTracked, names: partial.map((s) => s.name) });
2285
2806
  const notes = `${r.blocked.length ? `; skipped ${r.blocked.join(", ")} (git tracks ${r.blocked.length === 1 ? "it" : "them"})` : ""}${r.uncommitted.length ? `; ${r.uncommitted.join(", ")} not linked into folders git tracks (not committed, so teammates would get broken links)` : ""}`;
2286
- if (!r.linked.length) throw new Error(`${where}: nothing linked${notes}`);
2807
+ if (!r.linked.length) {
2808
+ const still = partial.filter((s) => visibilityOf(root, s.name).some((v) => v.paths === 0 && !loadsGlobally(s.name, v.id)));
2809
+ if (!still.length) return `${where}: already usable by ${agentNames(missing2)}`;
2810
+ throw new Error(`${where}: nothing linked${notes}`);
2811
+ }
2287
2812
  return `${where}: linked ${plural(r.linked.length, "skill")} for ${agentNames(missing2)}${notes}`;
2288
2813
  };
2289
2814
  issues.push({
2290
2815
  id: `usable:${root}`,
2291
2816
  severity: "suggestion",
2292
2817
  title: `${where}: ${plural(partial.length, "skill")} not usable by ${agentNames(missing2)}`,
2293
- 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.` : ""}`,
2818
+ 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.` : ""}${manifestShared ? " The new links are recorded in skilllib.json, which git tracks." : ""}`,
2294
2819
  at: rows(root, partial.map((s) => s.name)),
2295
- ...tracked.length ? {
2820
+ ...manifestShared && !tracked.length ? { choices: [{ label: "Add the links", hint: "git will see skilllib.json change", run: run(false) }] } : tracked.length ? {
2296
2821
  choices: [
2297
2822
  // Only when some link goes where git doesn't track: otherwise this one would link nothing.
2298
2823
  ...blindHere.some(({ would }) => would.created.length) ? [{ label: "Add links, but not where git tracks the folder", hint: `skips ${tracked.join(", ")}`, run: run(false) }] : [],
@@ -2302,16 +2827,17 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2302
2827
  });
2303
2828
  }
2304
2829
  const differing = new Set(tidy2.conflicts.map((c) => c.name));
2305
- const git2 = gitInfo(root);
2306
- const committed = (s) => ["committed", "changed"].includes(git2?.of(relativeTo(root, s.path)) ?? "");
2307
- for (const s of status2) {
2830
+ const git2 = gitOf(root);
2831
+ const committed = (s) => isShared(git2?.of(relativeTo(root, s.path)));
2832
+ for (const s of unstuck) {
2308
2833
  if (isLink(s.path) || differing.has(s.name)) continue;
2309
- if (s.state === "local only" && committed(s)) {
2834
+ if (s.state === "local only" && (committed(s) || untrackable(s))) {
2835
+ const why = committed(s) ? "it's the team's (committed)" : `${untrackable(s)}, so skilllib can't track it here`;
2310
2836
  issues.push({
2311
2837
  id: `local:${root}:${s.name}`,
2312
2838
  severity: "suggestion",
2313
2839
  title: `${s.name} in ${where} isn't in your library`,
2314
- detail: "Copy it into the library to reuse it in other projects. The repo's copy stays as it is: it's the team's (committed).",
2840
+ detail: `Copy it into the library to reuse it in other projects. The repo's copy stays as it is: ${why}.`,
2315
2841
  at: rows(root, [s.name]),
2316
2842
  choices: [
2317
2843
  {
@@ -2334,7 +2860,8 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2334
2860
  choices: [
2335
2861
  {
2336
2862
  label: "Import into the library",
2337
- hint: "copies it into Your skills",
2863
+ // Tracking it writes skilllib.json.
2864
+ hint: `copies it into Your skills${gitNote(root, [join13(root, MANIFEST_FILE)])}`,
2338
2865
  run: () => {
2339
2866
  intoLibrary(s.path);
2340
2867
  track(root, s.name);
@@ -2343,7 +2870,7 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2343
2870
  }
2344
2871
  ]
2345
2872
  });
2346
- } else if (s.state === "untracked copy of library skill") {
2873
+ } else if (s.state === "untracked copy of library skill" && !committed(s) && !untrackable(s)) {
2347
2874
  issues.push({
2348
2875
  id: `adopt:${root}:${s.name}`,
2349
2876
  severity: "suggestion",
@@ -2353,12 +2880,13 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2353
2880
  choices: [{ label: "Track it", hint: `adds it to ${where}'s skilllib.json`, run: () => `${track(root, s.name).name} tracked in ${where}` }]
2354
2881
  });
2355
2882
  } else if (s.state === "untracked, differs from library" || s.state === "repo skill, differs from library") {
2356
- const teams = s.state.startsWith("repo skill") || committed(s);
2883
+ const teams = s.state.startsWith("repo skill") || committed(s) || !!untrackable(s);
2884
+ const whose = s.state.startsWith("repo skill") || committed(s) ? "It's the team's copy (committed, or in the repo's own .agents/skills or .grok/skills)" : `${untrackable(s)}, so skilllib can't track it here`;
2357
2885
  issues.push({
2358
2886
  id: `differs:${root}:${s.name}`,
2359
2887
  severity: "suggestion",
2360
2888
  title: `${s.name} in ${where} differs from your library`,
2361
- detail: teams ? `It's the team's copy (committed, or in the repo's own .agents/skills), and no version in Your skills matches it. Update your library from it if it's the one to keep; ${where}'s copy stays as it is.` : `No version in Your skills matches it. Update your library from it (${where} then tracks it), or replace it with your library's.`,
2889
+ detail: teams ? `${whose}, and no version in Your skills matches it. Update your library from it if it's the one to keep; ${where}'s copy stays as it is.` : `No version in Your skills matches it. Update your library from it (${where} then tracks it), or replace it with your library's.`,
2362
2890
  at: rows(root, [s.name]),
2363
2891
  choices: [
2364
2892
  {
@@ -2384,13 +2912,13 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2384
2912
  ]
2385
2913
  ]
2386
2914
  });
2387
- } else if (s.state === "from npx skills") {
2388
- const teams = committed(s);
2915
+ } else if (s.state === "from npx skills" && !((committed(s) || untrackable(s)) && versionForHash(s.name, treeHash(s.path) ?? "") !== null)) {
2916
+ const teams = committed(s) || !!untrackable(s);
2389
2917
  issues.push({
2390
2918
  id: `npx:${root}:${s.name}`,
2391
2919
  severity: "suggestion",
2392
2920
  title: `${s.name} in ${where} came from npx skills, not your library`,
2393
- detail: `Installed with \`npx skills add\`${s.source ? ` (${s.source})` : ""}. Import it into your library to reuse it in other projects and keep it backed up${teams ? `; ${where}'s copy stays as it is (committed)` : `; ${where} then tracks it in skilllib.json`}.`,
2921
+ detail: `Installed with \`npx skills add\`${s.source ? ` (${s.source})` : ""}. Import it into your library to reuse it in other projects and keep it backed up${teams ? `; ${where}'s copy stays as it is (${committed(s) ? "committed" : `${untrackable(s)}, so skilllib can't track it here`})` : `; ${where} then tracks it in skilllib.json`}.`,
2394
2922
  at: rows(root, [s.name]),
2395
2923
  choices: [
2396
2924
  {
@@ -2431,12 +2959,21 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2431
2959
  id: `edited:${root}:${s.name}`,
2432
2960
  severity: "suggestion",
2433
2961
  title: `${s.name} in ${where} has local edits`,
2434
- detail: `It no longer matches ${pinned} from Your skills${newer2 ? `, and v${s.latest} is out` : ""}. Save the edits as a new version so your other repos can get them, or discard them. Keeping them is fine too: skilllib never overwrites edits.`,
2962
+ detail: `It no longer matches ${pinned} from Your skills${newer2 ? `, and v${s.latest} is out` : ""}. ${untrackable(s) ? `${untrackable(s)}, so skilllib can't re-pin or discard them here; copy them into Your skills to keep them for your other repos.` : "Save the edits as a new version so your other repos can get them, or discard them. Keeping them is fine too: skilllib never overwrites edits."}`,
2435
2963
  at: rows(root, [s.name]),
2436
- choices: [
2964
+ choices: untrackable(s) ? [
2965
+ {
2966
+ label: "Copy the edits into Your skills",
2967
+ hint: `${newer2 ? `becomes v${s.latest + 1}; v${s.latest}'s changes aren't merged in` : "a new version there"}; ${where}'s copy and pin stay as they are (skilllib sync there would put the pinned version back)`,
2968
+ run: () => {
2969
+ intoLibrary(s.path, true);
2970
+ return `${s.name}: the edits are in Your skills as v${latestVersion(s.name)?.version}; ${where} stays as it is`;
2971
+ }
2972
+ }
2973
+ ] : [
2437
2974
  {
2438
2975
  label: "Save as a new version in Your skills",
2439
- hint: newer2 ? `becomes v${s.latest + 1}; v${s.latest}'s changes aren't merged in` : `becomes v${(s.latest ?? 0) + 1}; ${where} uses it`,
2976
+ hint: `${newer2 ? `becomes v${s.latest + 1}; v${s.latest}'s changes aren't merged in` : `becomes v${(s.latest ?? 0) + 1}; ${where} uses it`}${gitNote(root, [join13(root, MANIFEST_FILE)])}`,
2440
2977
  run: () => {
2441
2978
  importSkill(s.path, { force: true });
2442
2979
  const c = addSkill(root, s.name);
@@ -2446,7 +2983,7 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2446
2983
  },
2447
2984
  {
2448
2985
  label: "Discard the edits",
2449
- hint: `back to ${pinned}; the edited copy is restorable with skilllib restore`,
2986
+ hint: `back to ${pinned}; the edited copy is restorable with skilllib restore${gitNote(root, [s.path, join13(root, MANIFEST_FILE)])}`,
2450
2987
  run: () => {
2451
2988
  const c = discardEdits(root, s.name);
2452
2989
  if (c.action === "skipped") throw new Error(`${s.name}: ${c.reason}`);
@@ -2465,26 +3002,42 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2465
3002
  if (alsoLoadedGlobally(n) && !copies.some((c) => c.root === root)) alsoGlobal.set(n.name, [...copies, { root, managed: false, nested: true }]);
2466
3003
  }
2467
3004
  }
3005
+ const everyRepo = [...projects2, ...knownProjects()];
3006
+ const nestedMemo = /* @__PURE__ */ new Map();
3007
+ const subsMemo = /* @__PURE__ */ new Map();
3008
+ const subsOf = (root) => subsMemo.get(root) ?? (subsMemo.set(root, packageDirs(root)), subsMemo.get(root));
3009
+ const entriesOf2 = (root) => nestedMemo.get(root) ?? (nestedMemo.set(root, nestedEntries(root, { subs: subsOf(root) })), nestedMemo.get(root));
2468
3010
  for (const [name, copies] of alsoGlobal) {
2469
3011
  const wheres = copies.map((c) => basename6(c.root)).join(", ");
2470
- const removable = copies.filter((c) => c.managed).map((c) => c.root);
3012
+ const removable = copies.filter((c) => c.managed && statuses.get(c.root)?.some((s) => s.name === name && removesClean(c.root, s))).map((c) => c.root);
2471
3013
  const repoOwn = copies.filter((c) => !c.managed).map((c) => basename6(c.root));
2472
3014
  const at = [...copies.flatMap((c) => rows(c.root, [name])), ...rows(null, [name])];
2473
3015
  if (kept.has(name)) {
2474
- const removeWheres = removable.map((r) => basename6(r)).join(", ");
3016
+ const globalLoaders = new Set(globalCopies.filter((m) => m.name === name).flatMap((m) => m.harnesses));
3017
+ const onlyHere = (root) => visibilityOf(root, name).filter((v) => v.paths > 0 && !globalLoaders.has(v.id)).map((v) => harness(v.id).name);
3018
+ const needed = removable.filter((root) => onlyHere(root).length);
3019
+ const extra = removable.filter((root) => !onlyHere(root).length);
3020
+ const removeWheres = extra.map((r) => basename6(r)).join(", ");
2475
3021
  issues.push({
2476
3022
  id: `twice:${name}`,
2477
3023
  severity: "suggestion",
2478
3024
  title: `${name}: in ${wheres}, and you keep it global`,
2479
- detail: `The global copy already loads there, so the project copies are extra. Claude Code runs the global one anyway.${repoOwn.length ? ` ${repoOwn.join(", ")} ${repoOwn.length === 1 ? "has its own copy" : "have their own copies"}: remove ${repoOwn.length === 1 ? "it" : "them"} in the repo, or stop keeping ${name} global.` : ""}`,
3025
+ detail: [
3026
+ extra.length ? `The global copy already loads in ${removeWheres} for every agent that reads the project copy, so those are extra.` : "",
3027
+ needed.length ? `In ${needed.map((r) => `${basename6(r)} (${onlyHere(r).join(", ")})`).join(", ")}, some agents read only the repo's copy: keep it there, or link the global one for them.` : "",
3028
+ repoOwn.length ? `${repoOwn.join(", ")} ${repoOwn.length === 1 ? "has its own copy" : "have their own copies"}: remove ${repoOwn.length === 1 ? "it" : "them"} in the repo, or stop keeping ${name} global.` : ""
3029
+ ].filter(Boolean).join(" "),
2480
3030
  at,
2481
- choices: removable.length ? [
3031
+ choices: extra.length ? [
2482
3032
  {
2483
3033
  label: `Remove the copies in ${removeWheres}`,
2484
- hint: "the global copy stays",
3034
+ // Removing a managed copy edits its skilllib.json too.
3035
+ hint: `the global copy stays${extra.some((r) => gitNote(r, [...statuses.get(r).filter((s) => s.name === name).map((s) => s.path), join13(r, MANIFEST_FILE)])) ? "; git will see it" : ""}`,
2485
3036
  run: () => {
2486
- const removed = removable.filter((root) => removeSkill(root, name).action === "removed").map((r) => basename6(r));
2487
- if (!removed.length) throw new Error(`${name}: removed from no project (local edits?)`);
3037
+ const results = extra.map((root) => [root, removeSkill(root, name)]);
3038
+ const removed = results.filter(([, c]) => c.action === "removed").map(([r]) => basename6(r));
3039
+ const kept2 = results.filter(([, c]) => c.action !== "removed").map(([r, c]) => `${basename6(r)} (${c.reason})`);
3040
+ if (kept2.length) throw new Error(`${name}: ${removed.length ? `removed from ${removed.join(", ")}; ` : ""}kept in ${kept2.join(", ")}`);
2488
3041
  return `${name}: removed from ${removed.join(", ")}; the global copy stays`;
2489
3042
  }
2490
3043
  }
@@ -2492,26 +3045,31 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2492
3045
  });
2493
3046
  continue;
2494
3047
  }
2495
- const movable = machine.find((m) => m.name === name && m.movable);
3048
+ const movable = machine.find((m) => m.name === name && m.movable && !m.broken);
3049
+ const doomed = machine.filter((x) => x.name === name && x.movable);
3050
+ const differ = copiesDiffer(doomed.map((m) => m.path));
3051
+ const libraryDiffers = !!movable && libraryNames.has(name) && versionForHash(name, treeHash(movable.path) ?? "") === null;
3052
+ const blind = movable && !differ && !libraryDiffers ? blindWithoutGlobal(name, doomed, everyRepo, entriesOf2, subsOf) : [];
2496
3053
  issues.push({
2497
3054
  at,
2498
3055
  id: `twice:${name}`,
2499
3056
  severity: "suggestion",
2500
3057
  title: `${name}: in ${wheres} and also loaded globally`,
2501
- detail: `${repoOwn.length === copies.length ? `Agents there load both the repo's copy and the global one.` : "The project copies are enough; the global one loads it in every other project too."} ${movable ? "Stop loading it globally, or keep it global on purpose (skilllib global keep)." : `The global copy comes from ${machine.find((m) => m.name === name && !m.movable)?.origin ?? "a vendor"}: turn it off there.`}`,
2502
- choices: movable ? [
3058
+ detail: `${repoOwn.length === copies.length ? `Agents there load both the repo's copy and the global one.` : "The project copies are enough; the global one loads it in every other project too."} ${!movable ? `The global copy comes from ${machine.find((m) => m.name === name && !m.movable)?.origin ?? "a vendor"}: turn it off there.` : differ ? `Your global copies of ${name} differ: keep one first (skilllib tidy --global), then stop loading it globally.` : libraryDiffers ? `The global copy isn't a version in Your skills (agents loading it globally run different content than the repo's): update your library from it first (skilllib import ${tildify(movable.path)} --force), or keep it global (skilllib global keep).` : blind.length ? `${blindSaid(name, blind)}.` : "Stop loading it globally, or keep it global on purpose (skilllib global keep)."}`,
3059
+ choices: movable && !differ && !libraryDiffers && !blind.length ? [
2503
3060
  {
2504
3061
  label: "Stop loading it globally",
2505
3062
  hint: "restorable with skilllib restore",
2506
3063
  run: () => {
2507
- const blind = copies.filter((c) => !c.nested).flatMap((c) => visibilityOf(c.root, name).flatMap((v) => v.paths === 0 ? [`${harness(v.id).name} in ${basename6(c.root)}`] : []));
2508
- if (blind.length) throw new Error(`${name}: ${blind.join(", ")} only see the global copy; link the repo copies first (skilllib link)`);
3064
+ const now = blindWithoutGlobal(name, doomed, everyRepo);
3065
+ if (now.length) throw new Error(`${name}: ${blindSaid(name, now)}`);
2509
3066
  if (!libraryNames.has(name)) importSkill(movable.path);
3067
+ const left = strandedLinks(name, doomed, everyRepo);
2510
3068
  for (const m of machine.filter((x) => x.name === name && x.movable)) {
2511
3069
  const r = unloadGlobal(m.path, m.links);
2512
3070
  if (!r.ok) throw new Error(`${name}: ${r.reason}`);
2513
3071
  }
2514
- return `${name} no longer loads globally`;
3072
+ return `${name} no longer loads globally${left.length ? `; ${left.join(", ")} now point${left.length === 1 ? "s" : ""} at nothing: remove ${left.length === 1 ? "it" : "them"}` : ""}`;
2515
3073
  }
2516
3074
  }
2517
3075
  ] : void 0
@@ -2548,6 +3106,7 @@ function addedAt(path) {
2548
3106
  }
2549
3107
  }
2550
3108
  function usageIssues(projects2, machine, libraryNames, uses, statusOf = projectStatus) {
3109
+ gitMemo = /* @__PURE__ */ new Map();
2551
3110
  if (!enabledHarnesses().includes("claude-code")) return [];
2552
3111
  const issues = [];
2553
3112
  const since = Date.now() - uses.days * 24 * 60 * 60 * 1e3;
@@ -2555,23 +3114,23 @@ function usageIssues(projects2, machine, libraryNames, uses, statusOf = projectS
2555
3114
  const where = basename6(root);
2556
3115
  const unused = statusOf(root).filter((s) => s.state !== "folder missing" && uses.inProject(root, s.name) === 0 && addedAt(s.path) <= since);
2557
3116
  if (!unused.length) continue;
2558
- const removable = unused.filter((s) => s.managed);
3117
+ const removable = unused.filter((s) => removesClean(root, s));
2559
3118
  issues.push({
2560
3119
  id: `unused:${root}`,
2561
3120
  severity: "suggestion",
2562
- title: `${where}: ${plural(unused.length, "skill")} unused in ${uses.days} days`,
2563
- detail: `${unused.map((s) => s.name).join(", ")}: no Claude Code session in ${where} used ${unused.length === 1 ? "it" : "them"}. Each one's name and description still takes context in every session there.${unused.length > removable.length ? " The repo's own skills are the team's: remove those in the repo if nobody needs them." : ""}`,
3121
+ title: `${where}: ${plural(unused.length, "skill")} unused in ${plural(uses.days, "day")}`,
3122
+ detail: `${unused.map((s) => s.name).join(", ")}: no Claude Code session in ${where} used ${unused.length === 1 ? "it" : "them"}. Each one's name and description still takes context in every session there.${unused.some((s) => !s.managed) ? " The repo's own skills are the team's: remove those in the repo if nobody needs them." : ""}${unused.some((s) => s.managed && !removable.includes(s)) ? ` Not offered: ${unused.filter((s) => s.managed && !removable.includes(s)).map((s) => `${s.name} (${s.state.startsWith("edited") ? "edited" : s.state === "not in library" ? "not in your library" : "something is in sync's way"})`).join(", ")}; their own issues say what to do first.` : ""}`,
2564
3123
  at: rows(root, unused.map((s) => s.name)),
2565
3124
  choices: removable.length ? [
2566
3125
  {
2567
3126
  label: `Remove ${removable.length === unused.length ? "them" : `the ${plural(removable.length, "skill")} skilllib installed`} from ${where}`,
2568
- hint: "Your skills keeps them; edited ones stay",
3127
+ hint: `Your skills keeps them; edited ones stay${gitNote(root, [...removable.map((s) => s.path), join13(root, MANIFEST_FILE)])}`,
2569
3128
  run: () => {
2570
3129
  const changes = removable.map((s) => removeSkill(root, s.name));
2571
3130
  const removed = changes.filter((c) => c.action === "removed").map((c) => c.name);
2572
3131
  const kept = changes.filter((c) => c.action !== "removed").map((c) => c.name);
2573
- if (!removed.length) throw new Error(`${where}: removed nothing; kept ${kept.join(", ")} (local edits)`);
2574
- return `${where}: removed ${removed.join(", ")}${kept.length ? `; kept ${kept.join(", ")} (local edits)` : ""}`;
3132
+ if (kept.length) throw new Error(`${where}: ${removed.length ? `removed ${removed.join(", ")}; ` : "removed nothing; "}kept ${kept.join(", ")} (${changes.find((c) => c.action !== "removed")?.reason ?? "skipped"})`);
3133
+ return `${where}: removed ${removed.join(", ")}`;
2575
3134
  }
2576
3135
  }
2577
3136
  ] : void 0
@@ -2579,16 +3138,16 @@ function usageIssues(projects2, machine, libraryNames, uses, statusOf = projectS
2579
3138
  }
2580
3139
  if (uses.asUsed)
2581
3140
  for (const p of installedPlugins().filter((x) => x.agent === "claude-code" && x.on && x.skills.length)) {
2582
- const name = p.id.split("@")[0];
3141
+ const name = pluginNameOf(p.id);
2583
3142
  if (p.skills.some((path) => uses.asUsed(`${name}:${basename6(path)}`) > 0)) continue;
2584
3143
  issues.push({
2585
3144
  id: `plugin-unused:${installId(p)}`,
2586
3145
  severity: "suggestion",
2587
- title: `${p.id}${installedIn(p)}: its skills weren't used in ${uses.days} days`,
3146
+ title: `${p.id}${installedIn(p)}: its skills weren't used in ${plural(uses.days, "day")}`,
2588
3147
  detail: `No Claude Code session used ${p.skills.map((path) => `/${name}:${basename6(path)}`).join(", ")}. Each one's name and description still takes context in every session.${p.extras.length ? ` It also brings ${p.extras.join(", ")}: turning it off pauses those too.` : ""}`,
2589
3148
  choices: [
2590
- { label: `Turn the plugin ${p.id} off`, hint: "stays installed", run: () => orThrow(setPluginOn(p, false)) },
2591
- ...p.synced || p.settingsOnly ? [] : [{ label: `Uninstall the plugin ${p.id}`, hint: "its data stays; restorable with skilllib restore", run: () => orThrow(uninstallPlugin(p)) }]
3149
+ { label: `Turn the plugin ${p.id} off`, hint: p.scope === "project" ? `stays installed; edits ${basename6(p.projectPath ?? "the repo")}'s committed .claude/settings.json` : "stays installed", run: () => orThrow(setPluginOn(p, false)) },
3150
+ ...p.synced || p.settingsOnly ? [] : [{ label: `Uninstall the plugin ${p.id}`, hint: `its data stays; restorable with skilllib restore${p.scope === "project" ? `; edits ${basename6(p.projectPath ?? "the repo")}'s committed .claude/settings.json` : ""}`, run: () => orThrow(uninstallPlugin(p)) }]
2592
3151
  ],
2593
3152
  plugin: p.id,
2594
3153
  pluginAt: p.projectPath ?? null
@@ -2603,7 +3162,7 @@ function usageIssues(projects2, machine, libraryNames, uses, statusOf = projectS
2603
3162
  issues.push({
2604
3163
  id: "unused:library",
2605
3164
  severity: "suggestion",
2606
- title: `${plural(idle.length, "skill")} in Your skills: in no repo, unused in ${uses.days} days`,
3165
+ title: `${plural(idle.length, "skill")} in Your skills: in no repo, unused in ${plural(uses.days, "day")}`,
2607
3166
  detail: `${idle.sort().join(", ")}. They load nowhere, so they take no context: add the ones you want to a repo, or delete the ones you've outgrown.`,
2608
3167
  // Your skills' rows (none loads globally, so none is in Global).
2609
3168
  at: rows(null, idle),
@@ -2636,13 +3195,12 @@ function groupBy(items) {
2636
3195
  }
2637
3196
  var sameSet = (a, b) => a.length === b.length && a.every((x) => b.includes(x));
2638
3197
  function globalFrom(kind, origin, path) {
2639
- if (kind === "global" || kind === "skilllib") return tildify(path.replace(/[\\/][^\\/]+$/, "")).replace(/\\/g, "/");
3198
+ if (kind === "global" || kind === "skilllib") return tildify(path.replace(/[\\/][^\\/]+$/, ""));
2640
3199
  return origin;
2641
3200
  }
2642
- var SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "target", "vendor", "coverage"]);
2643
3201
  function topLevel(root) {
2644
3202
  try {
2645
- return readdirSync8(root, { withFileTypes: true }).filter((d) => !d.name.startsWith(".") && !SKIP.has(d.name)).map((d) => d.isDirectory() ? `${d.name}/` : d.name).sort().slice(0, 60);
3203
+ return readdirSync8(root, { withFileTypes: true }).filter((d) => !d.name.startsWith(".") && !BUILD_DIRS.has(d.name)).map((d) => d.isDirectory() ? `${d.name}/` : d.name).sort().slice(0, 60);
2646
3204
  } catch {
2647
3205
  return [];
2648
3206
  }
@@ -2660,11 +3218,11 @@ function usableHere(root, uses = /* @__PURE__ */ new Map()) {
2660
3218
  const loadedBy = s.visibility.filter((v) => v.paths > 0).map((v) => v.id);
2661
3219
  const behind = s.state.endsWith("update available");
2662
3220
  const gitState = git2?.of(relativeTo(root, s.path));
2663
- const repo = !s.managed && (s.state.startsWith("repo skill") || gitState === "committed" || gitState === "changed");
3221
+ const repo = !s.managed && (s.state.startsWith("repo skill") || isShared(gitState));
2664
3222
  const usual = s.state === "ok" || s.state === "local only" || repo && s.state !== "from npx skills";
2665
3223
  const attrs = {
2666
3224
  source: s.managed ? "library" : repo ? "repo" : "local",
2667
- dir: s.location !== ".claude/skills" ? s.location : void 0,
3225
+ dir: s.location !== PROJECT_SKILLS_DIR ? s.location : void 0,
2668
3226
  version: s.version ?? void 0,
2669
3227
  state: usual ? void 0 : s.state,
2670
3228
  latest: behind ? s.latest : void 0,
@@ -2674,7 +3232,14 @@ function usableHere(root, uses = /* @__PURE__ */ new Map()) {
2674
3232
  return [JSON.stringify(attrs), s.name, () => ({ ...attrs, skills: [] })];
2675
3233
  })
2676
3234
  );
2677
- const skills = group(inRepo ? projectStatus(root) : []);
3235
+ const own = (() => {
3236
+ try {
3237
+ return inRepo ? projectStatus(root) : [];
3238
+ } catch {
3239
+ return [];
3240
+ }
3241
+ })();
3242
+ const skills = group(own);
2678
3243
  const nested = group(nestedHere);
2679
3244
  const machine = machineSkills(agents);
2680
3245
  const loaded = inRepo ? skillsLoadedIn(root, machine, agents) : machine;
@@ -2688,7 +3253,7 @@ function usableHere(root, uses = /* @__PURE__ */ new Map()) {
2688
3253
  for (const i of statusIssues(root, machine)) {
2689
3254
  const choices = i.choices?.map((c) => c.label);
2690
3255
  const key = `${i.detail}\0${i.fix?.label ?? ""}\0${choices?.join() ?? ""}`;
2691
- const issue = issues.get(key) ?? { problems: [], ids: [], detail: i.detail, fix: i.fix?.label, choices };
3256
+ const issue = issues.get(key) ?? { problems: [], ids: [], detail: detailWithGit(i), fix: i.fix?.label, choices };
2692
3257
  issue.problems.push(i.title);
2693
3258
  issue.ids.push(i.id);
2694
3259
  issues.set(key, issue);
@@ -2807,29 +3372,48 @@ function usesFromTranscriptLine(line) {
2807
3372
  function transcriptFiles(sinceMs) {
2808
3373
  const base = join14(claudeDir(), "projects");
2809
3374
  if (!existsSync13(base)) return [];
2810
- return readdirSync9(base).flatMap((project) => {
3375
+ const list2 = (dir) => {
3376
+ try {
3377
+ return readdirSync9(dir);
3378
+ } catch {
3379
+ return [];
3380
+ }
3381
+ };
3382
+ return list2(base).flatMap((project) => {
2811
3383
  const dir = join14(base, project);
2812
- if (!statSync5(dir).isDirectory()) return [];
2813
- return readdirSync9(dir).filter((f) => f.endsWith(".jsonl")).map((f) => join14(dir, f)).filter((f) => statSync5(f).mtimeMs >= sinceMs);
3384
+ if (!statSync5(dir, { throwIfNoEntry: false })?.isDirectory()) return [];
3385
+ return list2(dir).filter((f) => f.endsWith(".jsonl")).map((f) => join14(dir, f)).filter((f) => {
3386
+ try {
3387
+ const st = statSync5(f);
3388
+ return st.isFile() && st.mtimeMs >= sinceMs;
3389
+ } catch {
3390
+ return false;
3391
+ }
3392
+ });
2814
3393
  });
2815
3394
  }
2816
3395
  async function scanUsage(days) {
2817
3396
  const sinceMs = Date.now() - days * 24 * 60 * 60 * 1e3;
2818
3397
  const seen = /* @__PURE__ */ new Map();
2819
3398
  for (const file of transcriptFiles(sinceMs)) {
2820
- const lines = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
2821
- for await (const line of lines) {
2822
- for (const use of usesFromTranscriptLine(line)) {
2823
- if (Date.parse(use.at) >= sinceMs) seen.set(use.key, use);
3399
+ try {
3400
+ const lines = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
3401
+ for await (const line of lines) {
3402
+ for (const use of usesFromTranscriptLine(line)) {
3403
+ if (Date.parse(use.at) >= sinceMs) seen.set(use.key, use);
3404
+ }
2824
3405
  }
3406
+ } catch {
3407
+ continue;
2825
3408
  }
2826
3409
  }
2827
3410
  return [...seen.values()];
2828
3411
  }
3412
+ var skillOf = (key) => key.split(":").pop();
2829
3413
  function summarize2(uses, projectOf = () => null) {
2830
3414
  const bySkill = /* @__PURE__ */ new Map();
2831
3415
  for (const use of uses) {
2832
- const skill = use.skill.split(":").pop();
3416
+ const skill = skillOf(use.skill);
2833
3417
  const summary = bySkill.get(skill) ?? { skill, uses: 0, lastUsed: "", projects: /* @__PURE__ */ new Set() };
2834
3418
  summary.uses += 1;
2835
3419
  if (use.at > summary.lastUsed) summary.lastUsed = use.at;
@@ -2845,7 +3429,8 @@ function usesByProject(uses, projectOf) {
2845
3429
  const root = projectOf(use.cwd);
2846
3430
  if (!root) continue;
2847
3431
  const counts = byProject.get(root) ?? /* @__PURE__ */ new Map();
2848
- counts.set(use.skill, (counts.get(use.skill) ?? 0) + 1);
3432
+ const skill = skillOf(use.skill);
3433
+ counts.set(skill, (counts.get(skill) ?? 0) + 1);
2849
3434
  byProject.set(root, counts);
2850
3435
  }
2851
3436
  return byProject;
@@ -2857,28 +3442,39 @@ function moveGlobalSkills(skills, roots, {
2857
3442
  group,
2858
3443
  allowTracked = false,
2859
3444
  label = basename8,
2860
- allowBlind = false
3445
+ allowTrackedHint = "re-run with --allow-tracked"
2861
3446
  } = {}) {
2862
3447
  const byName = /* @__PURE__ */ new Map();
2863
3448
  for (const m of skills) byName.set(m.name, [...byName.get(m.name) ?? [], m]);
2864
3449
  return [...byName].map(([name, copies]) => {
2865
- if (new Set(copies.map((m) => treeHash(m.path))).size > 1)
3450
+ if (copiesDiffer(copies.map((m) => m.path)))
2866
3451
  return { name, moved: false, reason: `your global copies of ${name} differ; nothing moved (keep one first: skilllib tidy --global)`, changes: [] };
2867
- if (importSkill(copies[0].path).status === "exists")
3452
+ const elsewhere = blindWithoutGlobal(name, copies, knownProjects().filter((p) => !roots.some((r) => realPath(r) === realPath(p))));
3453
+ if (elsewhere.length) return { name, moved: false, reason: `nothing moved: ${blindSaid(name, elsewhere, label)}`, changes: [] };
3454
+ const hash = treeHash(copies[0].path) ?? "";
3455
+ const cant = roots.flatMap((root) => ((b) => b ? [`${label(root)} (${withoutForceHint(b)})`] : [])(trackBlocker(root, name) ?? addRefusal(root, name, hash)));
3456
+ if (cant.length) return { name, moved: false, reason: `nothing moved: it can't be added to ${cant.join(", ")}`, changes: [] };
3457
+ const imported = importSkill(copies[0].path).status;
3458
+ if (imported === "exists")
2868
3459
  return { name, moved: false, reason: `your library has a different ${name}; nothing moved (update your library from this copy first)`, changes: [] };
2869
3460
  if (group) recordOrigin(name, `group: ${group}`);
2870
3461
  const changes = roots.map((root) => [root, addSkill(root, name, { allowTracked })]);
2871
3462
  const skipped = changes.filter(([, c]) => c.action === "skipped");
2872
- const why = (reason = "") => reason.replace(/ \(use --force to replace it\)$/, ": move or remove it there first");
2873
- if (skipped.length) return { name, moved: false, reason: `not added to ${skipped.map(([r, c]) => `${label(r)} (${why(c.reason)})`).join(", ")}, so it still loads globally`, changes };
2874
- const held = changes.filter(([, c]) => c.blocked?.length || c.uncommitted?.length);
2875
- const blind = held.flatMap(([root]) => visibilityOf(root, name).flatMap((v) => v.paths === 0 ? [`${harness(v.id).name} in ${label(root)}`] : []));
2876
- if (blind.length && !allowBlind) {
2877
- const next = held.some(([, c]) => c.uncommitted?.length) ? "commit the skill's copy in the repo, then run skilllib link --allow-tracked there" : "re-run with --allow-tracked";
2878
- return { name, moved: false, reason: `added, but ${blind.join(", ")} couldn't see it (git tracks the folder its link goes in), so it still loads globally; ${next}`, changes };
3463
+ const why = (reason = "") => withoutForceHint(reason) === reason ? reason : `${withoutForceHint(reason)}: move or remove it there first`;
3464
+ const kept = imported === "added" ? `; it's in Your skills now` : "";
3465
+ if (skipped.length) return { name, moved: false, reason: `not added to ${skipped.map(([r, c]) => `${label(r)} (${why(c.reason)})`).join(", ")}, so it still loads globally${kept}`, changes };
3466
+ const blindIn = changes.flatMap(([root, c]) => ((b) => b.length ? [{ root, c, b }] : [])(blindWithoutGlobal(name, copies, [root])));
3467
+ if (blindIn.length) {
3468
+ const per = blindIn.map(({ root, c, b }) => {
3469
+ const who = b.map((x) => `${harness(x.id).name}${realPath(x.root) === realPath(root) ? "" : ` in ${label(x.root)}`}`).join(", ");
3470
+ const next = c.inTheWay?.length ? `${inTheWaySaid(root, c.inTheWay)}: clear the way, then run skilllib link there` : c.uncommitted?.length ? `git tracks ${c.uncommitted.join(", ")} but not the skill's copy: commit it first, then allow the link there` : c.blocked?.length ? `git tracks ${c.blocked.join(", ")}: ${allowTrackedHint}` : blindSaid(name, b, label);
3471
+ return `${who} ${b.every((x) => realPath(x.root) === realPath(root)) ? `in ${label(root)} ` : `(${label(root)}) `}couldn't see it (${next})`;
3472
+ });
3473
+ return { name, moved: false, reason: `added, but ${per.join("; ")}, so it still loads globally`, changes };
2879
3474
  }
3475
+ const left = strandedLinks(name, copies, [...roots, ...knownProjects()], label);
2880
3476
  const failed = copies.map((m) => unloadGlobal(m.path, m.links)).find((r) => !r.ok);
2881
- return failed && !failed.ok ? { name, moved: false, reason: failed.reason, changes } : { name, moved: true, changes };
3477
+ return failed && !failed.ok ? { name, moved: false, reason: failed.reason, changes } : { name, moved: true, changes, ...left.length ? { left } : {} };
2882
3478
  });
2883
3479
  }
2884
3480
 
@@ -2897,6 +3493,7 @@ var FLAGS = {
2897
3493
  keep: { type: "string" },
2898
3494
  to: { type: "string", multiple: true }
2899
3495
  };
3496
+ var VALUE_FLAGS = Object.entries(FLAGS).flatMap(([name, f]) => f.type === "string" ? [`--${name}`] : []);
2900
3497
  function parseArgs(argv) {
2901
3498
  const { values: v, positionals } = parseFlags({ args: argv, options: FLAGS, allowPositionals: true, strict: true });
2902
3499
  const days = Number(v.days ?? USAGE_DAYS);
@@ -2925,26 +3522,28 @@ function colorState(state) {
2925
3522
  function withDirs(root, changes) {
2926
3523
  const { skills } = readManifest(root);
2927
3524
  return changes.map((c) => {
2928
- const dep = c.action === "installed" || c.action === "updated" || c.action === "reset" || c.action === "relinked" ? skills[c.name] : void 0;
2929
- return dep ? { ...c, dirs: [dep.dir ?? ".claude/skills", ...dep.links ?? []] } : c;
3525
+ const dep = c.action !== "removed" && c.action !== "skipped" ? skills[c.name] : void 0;
3526
+ return dep ? { ...c, dirs: [dep.dir ?? PROJECT_SKILLS_DIR, ...(dep.links ?? []).filter((d) => !c.inTheWay?.includes(`${d}/${c.name}`))] } : c;
2930
3527
  });
2931
3528
  }
2932
- var incomplete = (c) => c.action === "skipped" || !!c.blocked?.length || !!c.uncommitted?.length;
2933
- function warnHeldLinks(c, where = "here") {
3529
+ var incomplete = (c) => c.action === "skipped" || !!c.blocked?.length || !!c.uncommitted?.length || !!c.inTheWay?.length;
3530
+ function warnHeldLinks(root, c, where = "here") {
2934
3531
  if (c.blocked?.length) warn(`${c.name}: git tracks ${c.blocked.join(", ")} ${where}, so no link was added there. Re-run with --allow-tracked to add it.`);
2935
3532
  if (c.uncommitted?.length)
2936
3533
  warn(`${c.name}: git tracks ${c.uncommitted.join(", ")} ${where} but not ${c.name}'s real copy, so no link was added there (teammates would get a broken link). Commit ${c.name} first, then run skilllib link --allow-tracked.`);
3534
+ if (c.inTheWay?.length) warn(`${c.name}: not linked everywhere ${where}: ${inTheWaySaid(root, c.inTheWay)}. Clear the way, then run skilllib link.`);
2937
3535
  }
2938
3536
  function printChanges(root, changes, args) {
2939
3537
  if (changes.some(incomplete)) process.exitCode = 1;
2940
3538
  if (args.json) return json("changes", withDirs(root, changes));
2941
3539
  for (const c of changes) {
2942
- warnHeldLinks(c);
3540
+ warnHeldLinks(root, c);
2943
3541
  if (c.action === "skipped") error(`${c.name}: ${c.reason}`);
2944
3542
  else {
2945
- const icon = c.action === "removed" ? red("-") : c.action === "reset" ? yellow("\u21BA") : green(c.action === "installed" ? "+" : c.action === "relinked" ? "\u21E2" : "\u2191");
3543
+ const icon = c.action === "removed" ? red("-") : c.action === "reset" ? yellow("\u21BA") : c.action === "unchanged" ? dim("=") : green(c.action === "installed" ? "+" : c.action === "relinked" ? "\u21E2" : "\u2191");
2946
3544
  const versions = c.action === "removed" ? "" : c.from && c.from !== c.to ? ` v${c.from} \u2192 v${c.to}` : c.to ? ` v${c.to}` : "";
2947
3545
  info(` ${icon} ${c.name}${versions} ${dim(c.action)}`);
3546
+ if (c.reason) info(dim(` ${c.reason}`));
2948
3547
  }
2949
3548
  if (c.backedUp) info(dim(` local edits saved to ${tildify(c.backedUp)}; \`skilllib restore ${c.name}\` brings them back`));
2950
3549
  }
@@ -2957,9 +3556,9 @@ function projectOfFactory(projects2) {
2957
3556
  const sorted = [...projects2].sort((a, b) => b.length - a.length);
2958
3557
  return (cwd) => {
2959
3558
  if (!cwd) return null;
2960
- const known = sorted.find((p) => cwd === p || cwd.startsWith(p + sep6));
3559
+ const known = sorted.find((p) => cwd === p || cwd.startsWith(p + sep8));
2961
3560
  if (known) return known;
2962
- const repo = cwd.split(`${sep6}.claude${sep6}worktrees${sep6}`)[0];
3561
+ const repo = cwd.split(`${sep8}.claude${sep8}worktrees${sep8}`)[0];
2963
3562
  return isProjectCandidate(repo) ? repo : null;
2964
3563
  };
2965
3564
  }
@@ -2983,6 +3582,8 @@ function requireProject() {
2983
3582
  const root = projectHere();
2984
3583
  if (root) return root;
2985
3584
  if (!isProjectCandidate(findProjectRoot())) {
3585
+ const top = findProjectRoot();
3586
+ if (realPath(top) !== realPath(userHome())) throw new Error(`${tildify(top)} is skilllib's or an agent's own folder, not a project. Run this inside a project.`);
2986
3587
  const stray = existsSync14(join15(userHome(), MANIFEST_FILE)) ? `, though ${tildify(join15(userHome(), MANIFEST_FILE))} makes it look like one` : "";
2987
3588
  throw new Error(`Your home folder isn't a project${stray}: skills added here would load in every repo. Run this inside a project.`);
2988
3589
  }
@@ -2996,7 +3597,10 @@ async function status(args) {
2996
3597
  const root2 = findProjectRoot();
2997
3598
  const usage3 = await usageBySkill(args.days, root2);
2998
3599
  json("status", usableHere(root2, new Map([...usage3].map(([name, u]) => [name, u.uses]))));
2999
- if (projectHere()) rememberProjects([root2]);
3600
+ try {
3601
+ if (projectHere()) rememberProjects([root2]);
3602
+ } catch {
3603
+ }
3000
3604
  return;
3001
3605
  }
3002
3606
  const root = requireProject();
@@ -3035,17 +3639,19 @@ async function status(args) {
3035
3639
  }))
3036
3640
  );
3037
3641
  }
3038
- const states = new Set(skills.map((s) => s.state));
3642
+ const states = new Set(skills.map((s) => teamState(s)));
3039
3643
  const hints = [];
3040
3644
  if (states.has("update available")) hints.push("`skilllib update` to install the newest versions");
3041
3645
  const missing = skills.filter((s) => s.state === "folder missing");
3042
- if (missing.some((s) => s.latest !== null)) hints.push("`skilllib sync` to restore missing skills");
3646
+ if (missing.some((s) => s.latest !== null && !syncBlocker(root, s.name))) hints.push("`skilllib sync` to restore missing skills");
3647
+ if (missing.some((s) => syncBlocker(root, s.name))) hints.push("`skilllib doctor` says what's in sync's way");
3043
3648
  const notInLibrary = missing.filter((s) => s.latest === null).map((s) => s.name);
3044
3649
  if (notInLibrary.length)
3045
- hints.push(`${notInLibrary.join(", ")} ${notInLibrary.length === 1 ? "isn't" : "aren't"} in your library (${tildify(libraryDir())}): \`skilllib import <dir>\` to add, or set SKILLLIB_HOME to the library that has ${notInLibrary.length === 1 ? "it" : "them"}`);
3046
- if (states.has("edited locally") || states.has("edited locally, update available"))
3047
- hints.push("`skilllib import .claude/skills/<name> --force` to save local edits to the library");
3048
- if (states.has("local only")) hints.push("`skilllib import .claude/skills/<name>` to add a local skill to the library");
3650
+ hints.push(`${notInLibrary.join(", ")} ${notInLibrary.length === 1 ? "isn't" : "aren't"} in your library (${tildify(libraryDir())}): \`skilllib import <dir>\` to add, or set SKILLLIB_HOME to the skilllib folder whose library has ${notInLibrary.length === 1 ? "it" : "them"}`);
3651
+ const at = (s) => relativeTo(root, s.path);
3652
+ for (const s of skills.filter((x) => x.state === "edited locally" || x.state === "edited locally, update available"))
3653
+ hints.push(`\`skilllib import ${at(s)} --force\` to save ${s.name}'s local edits to the library`);
3654
+ for (const s of skills.filter((x) => x.state === "local only")) hints.push(`\`skilllib import ${at(s)}\` to add ${s.name} to the library`);
3049
3655
  if (states.has("untracked copy of library skill") || states.has("untracked, differs from library"))
3050
3656
  hints.push("`skilllib add <name>` to manage an untracked skill (--force if it differs)");
3051
3657
  if (hints.length > 0) info("\n" + hints.map((h) => dim(`\u2192 ${h}`)).join("\n"));
@@ -3084,14 +3690,14 @@ async function list(args) {
3084
3690
  skills.map((s) => ({
3085
3691
  Skill: s.name,
3086
3692
  Description: truncate(s.description, 50),
3087
- From: dim(truncate(origins[s.name] ?? "", 30)),
3693
+ From: dim(truncate(tildify(origins[s.name] ?? ""), 30)),
3088
3694
  Projects: (installs.get(s.name) ?? []).join(", ") || dim("none"),
3089
3695
  [`Uses (${args.days}d)`]: String(usage2.get(s.name)?.uses ?? 0)
3090
3696
  }))
3091
3697
  );
3092
3698
  }
3093
3699
  const libraryNames = new Set(skills.map((s) => s.name));
3094
- const globals = [...new Set(yourGlobals().map((m) => m.name))];
3700
+ const globals = [...new Set(yourGlobals().filter((m) => m.harnesses.length).map((m) => m.name))];
3095
3701
  if (globals.length > 0) {
3096
3702
  info("");
3097
3703
  warn(`${globals.length} of your global ${globals.length === 1 ? "skill still loads" : "skills still load"} in every project`);
@@ -3105,7 +3711,7 @@ async function list(args) {
3105
3711
  }
3106
3712
  function teamStateIn(root, extraDirs = []) {
3107
3713
  const git2 = gitInfo(root, [...new Set(extraDirs)]);
3108
- const committed = (path) => ["committed", "changed"].includes(git2?.of(relativeTo(root, path)) ?? "");
3714
+ const committed = (path) => isShared(git2?.of(relativeTo(root, path)));
3109
3715
  const asRepo = {
3110
3716
  "local only": "repo skill",
3111
3717
  "untracked copy of library skill": "repo skill, in library",
@@ -3139,13 +3745,14 @@ function projects(args) {
3139
3745
  table(
3140
3746
  roots.map((root) => {
3141
3747
  const skills = projectStatus(root);
3142
- const managed = skills.filter((s) => s.managed);
3143
- const attention = skills.filter((s) => s.state !== "ok" && s.state !== "local only").length;
3748
+ const teamState = teamStateIn(root);
3749
+ const usual = /* @__PURE__ */ new Set(["ok", "local only", "repo skill", "repo skill, in library", "from npx skills"]);
3750
+ const attention = skills.filter((s) => !usual.has(teamState(s))).length;
3144
3751
  return {
3145
3752
  Project: basename9(root),
3146
3753
  Path: dim(tildify(root)),
3147
- Skills: managed.map((s) => s.name).join(", ") || dim("none"),
3148
- "Local only": String(skills.filter((s) => s.state === "local only").length),
3754
+ Skills: skills.map((s) => s.name).join(", ") || dim("none"),
3755
+ "Local only": String(skills.filter((s) => teamState(s) === "local only").length),
3149
3756
  Status: attention === 0 ? green("ok") : yellow(`${attention} need attention`)
3150
3757
  };
3151
3758
  })
@@ -3156,8 +3763,7 @@ function add(args) {
3156
3763
  throw new Error("Usage: skilllib add <skill-name>... [--force]");
3157
3764
  }
3158
3765
  const root = requireProject();
3159
- if (args.allowTracked) allowTrackedLinks(root);
3160
- printChanges(root, args.positional.map((name) => addSkill(root, name, { force: args.force })), args);
3766
+ printChanges(root, args.positional.map((name) => addSkill(root, name, { force: args.force, allowTracked: args.allowTracked })), args);
3161
3767
  rememberProjects([root]);
3162
3768
  }
3163
3769
  function remove(args) {
@@ -3170,7 +3776,6 @@ function remove(args) {
3170
3776
  function changeEach(args, change, nothing) {
3171
3777
  const roots = projectsFor(args);
3172
3778
  const results = roots.map((root) => {
3173
- if (args.allowTracked) allowTrackedLinks(root);
3174
3779
  const changes = change(root);
3175
3780
  if (changes.some(incomplete)) process.exitCode = 1;
3176
3781
  if (!args.json) {
@@ -3197,7 +3802,7 @@ function update(args) {
3197
3802
  changeEach(
3198
3803
  args,
3199
3804
  (root) => {
3200
- const changes = updateProject(root, names, { force: args.force });
3805
+ const changes = updateProject(root, names, { force: args.force, allowTracked: args.allowTracked });
3201
3806
  if (!args.all) {
3202
3807
  for (const name of names ?? []) if (!(name in readManifest(root).skills)) changes.push({ name, action: "skipped", reason: "not managed by skilllib in this project" });
3203
3808
  }
@@ -3220,7 +3825,7 @@ function outdated(args) {
3220
3825
  }
3221
3826
  function importSkills(args) {
3222
3827
  if (args.global && args.positional.length) throw new Error("skilllib import --global takes no folders: it imports all your global skills");
3223
- const dirs = args.global ? yourGlobals().map((m) => m.path) : args.positional.map((p) => resolve7(p));
3828
+ const dirs = args.global ? yourGlobals().map((m) => m.path) : args.positional.map((p) => resolve8(p));
3224
3829
  if (dirs.length === 0) {
3225
3830
  throw new Error("Usage: skilllib import <skill-dir>... [--force] | skilllib import --global");
3226
3831
  }
@@ -3240,7 +3845,7 @@ function importSkills(args) {
3240
3845
  continue;
3241
3846
  }
3242
3847
  success(`${name} ${dim(result === "unchanged" ? "already in library" : result === "added" ? "added to library" : "library updated")}`);
3243
- const root = resolve7(dir, "..", "..", "..");
3848
+ const root = resolve8(dir, "..", "..", "..");
3244
3849
  const dep = projectHere(root) === root ? readManifest(root).skills[name] : void 0;
3245
3850
  if (dep && realpathOrNull(join15(root, dep.dir ?? PROJECT_SKILLS_DIR, name)) === realpathOrNull(dir) && dep.hash !== treeHash(dir)) {
3246
3851
  const change = addSkill(root, name);
@@ -3259,7 +3864,8 @@ function scan(args) {
3259
3864
  const found = discoverProjects();
3260
3865
  const library = new Set(librarySkills().map((s) => s.name));
3261
3866
  const skillNames = (root) => [
3262
- .../* @__PURE__ */ new Set([...ALL_PROJECT_DIRS.flatMap((d) => skillDirsIn(join15(root, d))).map((d) => basename9(d)), ...nestedSkills(root).map((s) => s.name)])
3867
+ // Not a folder that links out of the repo: what's in it isn't the repo's (as in status).
3868
+ .../* @__PURE__ */ new Set([...ALL_PROJECT_DIRS.filter((d) => !notWritable(root, d)).flatMap((d) => skillDirsIn(join15(root, d))).map((d) => basename9(d)), ...nestedSkills(root).map((s) => s.name)])
3263
3869
  ];
3264
3870
  const withSkills = found.filter((root) => skillNames(root).length > 0);
3265
3871
  info(`Scanned ${roots.map((r) => tildify(r)).join(", ")}: ${found.length} project${found.length === 1 ? "" : "s"}, ${withSkills.length} with skills
@@ -3277,47 +3883,42 @@ ${yellow("Yellow")} skills aren't in your library yet (\`skilllib import <skill-
3277
3883
  function folders(args) {
3278
3884
  const [action, ...paths] = args.positional;
3279
3885
  if (action && !["add", "remove", "rm"].includes(action)) throw new Error("Usage: skilllib folders [add|remove <folder>...]");
3280
- let failed = false;
3886
+ const failures = [];
3281
3887
  if (action === "add") {
3282
3888
  for (const p of paths) {
3283
3889
  try {
3284
3890
  success(`Added ${tildify(addRoot(p))}`);
3285
3891
  } catch (e) {
3286
- error(e.message);
3287
- failed = true;
3892
+ failures.push(e.message);
3288
3893
  }
3289
3894
  }
3290
3895
  discoverProjects();
3291
3896
  } else if (action === "remove" || action === "rm") {
3292
3897
  for (const p of paths) {
3293
3898
  if (removeRoot(p)) success(`Removed ${tildify(expandHome(p))}`);
3294
- else {
3295
- warn(`${tildify(expandHome(p))} wasn't a project folder`);
3296
- failed = true;
3297
- }
3899
+ else failures.push(`${tildify(expandHome(p))} wasn't a project folder`);
3298
3900
  }
3299
3901
  }
3300
3902
  const { roots, hidden } = readConfig();
3301
3903
  info(roots.length ? `Project folders:
3302
3904
  ${roots.map((r) => ` ${tildify(r)}`).join("\n")}` : "No project folders. Add one: skilllib folders add ~/Projects");
3303
3905
  if (hidden.length) info(dim(`Hidden projects: ${hidden.map((h) => tildify(h)).join(", ")} (skilllib unhide <path>)`));
3304
- if (failed) process.exitCode = 1;
3906
+ if (failures.length) throw new Error(failures.join("; "));
3305
3907
  }
3306
3908
  function unhide(args) {
3307
3909
  if (args.positional.length === 0) {
3308
3910
  throw new Error("Usage: skilllib unhide <path>...");
3309
3911
  }
3310
- let failed = false;
3912
+ const failures = [];
3311
3913
  for (const p of args.positional) {
3312
3914
  const path = expandHome(p);
3313
3915
  if (setHidden(path, false)) success(`${tildify(path)} is visible again`);
3314
3916
  else {
3315
3917
  const { hidden } = readConfig();
3316
- error(`${tildify(path)} wasn't hidden. ${hidden.length ? `Hidden projects: ${hidden.map((h) => tildify(h)).join(", ")}` : "No projects are hidden."}`);
3317
- failed = true;
3918
+ failures.push(`${tildify(path)} wasn't hidden. ${hidden.length ? `Hidden projects: ${hidden.map((h) => tildify(h)).join(", ")}` : "No projects are hidden."}`);
3318
3919
  }
3319
3920
  }
3320
- if (failed) process.exitCode = 1;
3921
+ if (failures.length) throw new Error(failures.join("; "));
3321
3922
  }
3322
3923
  function newSkill(args) {
3323
3924
  const [name, ...rest] = args.positional;
@@ -3341,16 +3942,13 @@ function restore(args) {
3341
3942
  }
3342
3943
  const newest = backups.find((b) => b.name === name);
3343
3944
  if (!newest) throw new Error(`No backup named ${name} (skilllib restore lists them; a skill removed from a repo that your library has comes back with skilllib add ${name})`);
3344
- const at = (b) => Date.parse(b.path.slice(-24).replace(/^(.{13})-(\d\d)-(\d\d)-(\d{3})Z$/, "$1:$2:$3.$4Z"));
3345
- const together = newest.kind === "plugin" ? [newest] : backups.filter((b) => b.name === name && b.kind === newest.kind && Math.abs(at(b) - at(newest)) <= 2e3);
3346
- for (const backup of together) {
3945
+ const failures = [];
3946
+ for (const backup of movedTogether(backups, newest)) {
3347
3947
  const r = backup.kind === "plugin" ? restorePlugin(backup) : restoreBackup(backup);
3348
3948
  if (r.ok) success(`Restored ${name} to ${tildify(r.to)}`);
3349
- else {
3350
- error(`Can't restore ${name}: ${r.reason}`);
3351
- process.exitCode = 1;
3352
- }
3949
+ else failures.push(r.reason);
3353
3950
  }
3951
+ if (failures.length) throw new Error(`Can't restore ${name}: ${failures.join("; ")}`);
3354
3952
  }
3355
3953
  async function allIssues(args, withUsage = true) {
3356
3954
  const projects2 = visibleProjects();
@@ -3390,7 +3988,7 @@ async function doctor(args) {
3390
3988
  id,
3391
3989
  severity,
3392
3990
  title,
3393
- detail,
3991
+ detail: detailWithGit({ detail, fix, choices }),
3394
3992
  fix: fix?.label,
3395
3993
  choices: choices?.map((c) => c.label),
3396
3994
  fixed: args.fix && fix ? fixed(fix) : void 0
@@ -3402,7 +4000,8 @@ async function doctor(args) {
3402
4000
  for (const issue of issues) {
3403
4001
  info(`${issue.severity === "problem" ? red("\u25CF") : yellow("\u25C6")} ${issue.title}`);
3404
4002
  info(dim(` ${issue.detail}${issue.fix ? ` \u2192 ${issue.fix.label}` : ""}`));
3405
- if (issue.choices) info(dim(` your call: ${issue.choices.map((c, i) => `${i + 1}) ${c.label}`).join(" ")} \u2192 skilllib apply ${issue.id} <n>`));
4003
+ const spans = new Set(issue.at?.map((a) => a.root)).size > 1;
4004
+ if (issue.choices) info(dim(` your call: ${issue.choices.map((c, i) => `${i + 1}) ${c.label}`).join(" ")} \u2192 skilllib apply ${issue.id} ${spans ? '"<choice>"' : "<n>"}`));
3406
4005
  if (args.fix && issue.fix) {
3407
4006
  const r = fixed(issue.fix);
3408
4007
  info(` ${r.ok ? green("\u2713") : red("\u2717")} ${r.message}`);
@@ -3420,7 +4019,7 @@ async function apply(args) {
3420
4019
  const byLabel = pick === void 0 ? void 0 : found.find((i) => optionsOf(i).some((c) => c.label.toLowerCase() === pick.toLowerCase()));
3421
4020
  if (!byLabel && found.some((i) => labels(i) !== labels(found[0]))) {
3422
4021
  const all = [...new Set(found.flatMap((i) => optionsOf(i).map((c) => c.label)))];
3423
- throw new Error(`${found[0].title}: its choices differ between this repo and all your repos; pick one by label: ${all.map((l) => `"${l}"`).join(" ")}`);
4022
+ throw new Error(`${found[0].title}: its choices here differ from doctor's (all your repos); pick one by label: ${all.map((l) => `"${l}"`).join(" ")}`);
3424
4023
  }
3425
4024
  const issue = byLabel ?? found[0];
3426
4025
  const options = optionsOf(issue);
@@ -3481,14 +4080,14 @@ function show(args) {
3481
4080
  path: dir,
3482
4081
  latest: latestVersion(name)?.version ?? null,
3483
4082
  origin: libraryOrigins()[name] ?? "",
3484
- projects: knownProjects().filter((p) => name in pinsIn(p)).map((p) => basename9(p)),
3485
- skillMd: readFileSync6(join15(dir, "SKILL.md"), "utf-8")
4083
+ projects: projectsUsing(name).map((p) => basename9(p)),
4084
+ skillMd: readFileSync7(join15(dir, "SKILL.md"), "utf-8")
3486
4085
  });
3487
4086
  }
3488
4087
  info(`${name} ${dim(tildify(dir))}`);
3489
4088
  if (description) info(`
3490
4089
  ${description}`);
3491
- const using = knownProjects().filter((p) => name in pinsIn(p));
4090
+ const using = projectsUsing(name);
3492
4091
  info(`
3493
4092
  Installed in: ${using.map((p) => tildify(p)).join(", ") || "no projects"}`);
3494
4093
  }
@@ -3512,25 +4111,33 @@ New installs go to ${installDirs(enabled).join(" (real copy) + ")}${installDirs(
3512
4111
  function link(args) {
3513
4112
  const roots = projectsFor(args);
3514
4113
  for (const root of roots) {
3515
- if (args.allowTracked) allowTrackedLinks(root);
3516
- const res = linkAll(root);
4114
+ const res = linkAll(root, { allowTracked: args.allowTracked });
3517
4115
  const label = args.all ? `${basename9(root)}: ` : "";
3518
4116
  if (res.linked.length) success(`${label}linked ${res.linked.length} skill(s) for your other agents`);
3519
- else if (!args.all && !res.blocked.length && !res.uncommitted.length) success("Every skill here is already usable by all your agents");
4117
+ else if (!args.all && !res.blocked.length && !res.uncommitted.length && !res.inTheWay.length) {
4118
+ const missing = projectStatus(root).filter((s) => s.state === "folder missing").map((s) => s.name);
4119
+ if (missing.length) warn(`Nothing to link; ${missing.join(", ")} ${missing.length === 1 ? "isn't" : "aren't"} installed here (skilllib sync, or skilllib doctor says what's in the way)`);
4120
+ else success("Every skill here is already usable by all your agents");
4121
+ }
4122
+ if (res.inTheWay.length) {
4123
+ process.exitCode = 1;
4124
+ error(`${label}some skills weren't linked everywhere: ${inTheWaySaid(root, res.inTheWay)}. Clear the way, then link again`);
4125
+ }
3520
4126
  if (res.blocked.length) {
3521
4127
  process.exitCode = 1;
3522
4128
  error(`${label}git tracks ${res.blocked.join(", ")}, so ${res.linked.length ? "some skills weren't" : "nothing was"} linked there; re-run with --allow-tracked to link there too`);
3523
4129
  }
3524
- if (res.uncommitted.length) process.exitCode = 1;
3525
- if (res.uncommitted.length)
4130
+ if (res.uncommitted.length) {
4131
+ process.exitCode = 1;
3526
4132
  warn(
3527
4133
  `${label}${res.uncommitted.join(", ")} ${res.uncommitted.length === 1 ? "isn't" : "aren't"} committed, so not linked into folders git tracks (teammates would get broken links); commit ${res.uncommitted.length === 1 ? "it" : "them"} first`
3528
4134
  );
4135
+ }
3529
4136
  }
3530
4137
  }
3531
4138
  function globalCommand(args) {
3532
4139
  const [action, ...names] = args.positional;
3533
- const yours = yourGlobals();
4140
+ const yours = yourGlobals().filter((m) => m.harnesses.length);
3534
4141
  if (action === "move") return moveGlobal(args, names, yours);
3535
4142
  if (action === "keep" || action === "unkeep") {
3536
4143
  const unknown = names.filter((n) => !yours.some((m) => m.name === n));
@@ -3572,10 +4179,11 @@ function moveGlobal(args, names, yours) {
3572
4179
  if (args.json) {
3573
4180
  return json(
3574
4181
  "global move",
3575
- moves.map(({ name, moved, reason, changes }) => ({
4182
+ moves.map(({ name, moved, reason, changes, left }) => ({
3576
4183
  skill: name,
3577
4184
  moved,
3578
4185
  ...reason ? { reason } : {},
4186
+ ...left ? { left } : {},
3579
4187
  projects: changes.map(([root, c]) => ({ project: basename9(root), path: root, change: withDirs(root, [c])[0] }))
3580
4188
  }))
3581
4189
  );
@@ -3583,13 +4191,14 @@ function moveGlobal(args, names, yours) {
3583
4191
  for (const m of moves) {
3584
4192
  if (m.moved) success(`${m.name} now loads only in ${m.changes.map(([r]) => basename9(r)).join(", ")} (the global copy is in skilllib restore)`);
3585
4193
  else error(`${m.name}: ${m.reason}`);
3586
- for (const [root, c] of m.changes) warnHeldLinks(c, `in ${basename9(root)}`);
4194
+ for (const [root, c] of m.changes) warnHeldLinks(root, c, `in ${basename9(root)}`);
4195
+ if (m.left) warn(`${m.name}: ${m.left.join(", ")} now point${m.left.length === 1 ? "s" : ""} at nothing (the repo's own copy serves that package): remove ${m.left.length === 1 ? "it" : "them"}`);
3587
4196
  }
3588
4197
  if (moves.some((m) => m.moved)) info(dim("Commit skilllib.json and the skill folders in each repo."));
3589
4198
  }
3590
4199
  function tidy(args) {
3591
4200
  const names = args.positional;
3592
- const keepFor = (base) => args.keep ? Object.fromEntries(names.map((n) => [n, base ? resolve7(base, args.keep) : expandHome(args.keep)])) : {};
4201
+ const keepFor = (base) => args.keep ? Object.fromEntries(names.map((n) => [n, base ? resolve8(base, args.keep) : expandHome(args.keep)])) : {};
3593
4202
  if (args.keep && !names.length) {
3594
4203
  throw new Error("Usage: skilllib tidy <name>... --keep <folder>");
3595
4204
  }
@@ -3621,7 +4230,9 @@ function tidy(args) {
3621
4230
  for (const { label, report } of reports) {
3622
4231
  const plans = report.plans.filter((p) => !names.length || names.includes(p.name));
3623
4232
  const conflicts = report.conflicts.filter((c) => !names.length || names.includes(c.name));
3624
- if (!plans.length && !conflicts.length && !args.all) success(`${label}: nothing to tidy (no extra copies or links)`);
4233
+ const skippedNamed = names.length ? report.skipped.filter((x) => names.includes(x.name) && x.differs) : [];
4234
+ if (skippedNamed.length) process.exitCode = 1;
4235
+ if (!plans.length && !conflicts.length && !skippedNamed.length && !args.all) success(`${label}: nothing to tidy (no extra copies or links)`);
3625
4236
  for (const plan of plans) {
3626
4237
  const wouldHold = args.allowGit ? [] : gitVisibleSteps(plan);
3627
4238
  const result = args.dryRun ? { applied: plan.steps.filter((s) => !wouldHold.includes(s)), held: wouldHold } : applyTidy(plan, { git: args.allowGit ? "go" : "keep" });
@@ -3630,21 +4241,25 @@ function tidy(args) {
3630
4241
  held += result.held.length;
3631
4242
  changed += result.applied.length;
3632
4243
  }
3633
- if (args.keep && conflicts.length) process.exitCode = 1;
4244
+ if (conflicts.length && (args.keep || names.length)) process.exitCode = 1;
3634
4245
  for (const c of conflicts) {
3635
4246
  warn(`${label} \xB7 ${c.name}: the copies differ`);
3636
4247
  for (const copy of c.copies) {
3637
4248
  const runs = copy.runs.map((id) => harness(id).name);
3638
- info(` ${copyLabel(c, copy.dir)}${runs.length ? dim(` run by ${runs.join(", ")}`) : ""}${c.managed === copy.dir ? dim(" (skilllib's copy)") : ""}`);
4249
+ info(` ${folderLabel(c.root, copy.dir)}${runs.length ? dim(` run by ${runs.join(", ")}`) : ""}${c.managed === copy.dir ? dim(" (skilllib's copy)") : ""}`);
3639
4250
  }
3640
- const pick = copyLabel(c, c.managed ?? c.copies[0].dir);
4251
+ const pick = folderLabel(c.root, c.managed ?? c.copies[0].dir);
3641
4252
  info(dim(` keep one: skilllib tidy ${c.name}${args.global ? " --global" : ""} --keep ${pick}`));
3642
4253
  }
3643
4254
  for (const s of report.skipped.filter((x) => !names.length || names.includes(x.name))) info(dim(`- ${label} \xB7 ${s.name}: skipped, ${s.reason}`));
3644
4255
  }
3645
4256
  if (args.dryRun) info(dim("\nNothing changed (--dry-run)."));
3646
- else if (changed) info(dim("\nReplaced copies are in ~/.skilllib/tidy-backup; `skilllib restore` puts them back."));
3647
- if (held) info(dim(`${held} change${held === 1 ? "" : "s"} held back because git would see them. Re-run with --allow-git to make them.`));
4257
+ else if (changed) info(dim(`
4258
+ Replaced copies are in ${tildify(join15(skilllibHome(), "tidy-backup"))}; \`skilllib restore\` puts them back.`));
4259
+ if (held) {
4260
+ process.exitCode = 1;
4261
+ info(dim(`${held} change${held === 1 ? "" : "s"} held back because git would see them. Re-run with --allow-git to make them.`));
4262
+ }
3648
4263
  }
3649
4264
  function agentSkill(args) {
3650
4265
  const action = args.positional[0];
@@ -3673,17 +4288,22 @@ function agentSkill(args) {
3673
4288
  }
3674
4289
 
3675
4290
  export {
4291
+ realPath,
3676
4292
  skilllibHome,
3677
4293
  PROJECT_SKILLS_DIR,
3678
4294
  HARNESSES,
3679
4295
  harness,
4296
+ installDirs,
3680
4297
  readSkillInfo,
3681
4298
  treeHash,
4299
+ isDir,
3682
4300
  versionHistory,
3683
4301
  forgetLatest,
3684
4302
  latestVersion,
3685
4303
  projectHere,
3686
4304
  readManifest,
4305
+ knownProjects,
4306
+ rememberProjects,
3687
4307
  readableRoots,
3688
4308
  setJsonMode,
3689
4309
  isJsonMode,
@@ -3709,10 +4329,19 @@ export {
3709
4329
  recordOrigin,
3710
4330
  librarySkills,
3711
4331
  librarySkillDir,
3712
- visibilityOf,
4332
+ notWritable,
4333
+ inTheWaySaid,
4334
+ entryExists,
4335
+ survives,
4336
+ blindWithoutGlobal,
4337
+ strandedLinks,
4338
+ blindSaid,
4339
+ trackBlocker,
3713
4340
  projectStatus,
3714
4341
  linkEverywhere,
3715
4342
  relinkDependency,
4343
+ withoutForceHint,
4344
+ addRefusal,
3716
4345
  addSkill,
3717
4346
  removeSkill,
3718
4347
  updateProject,
@@ -3723,7 +4352,9 @@ export {
3723
4352
  deleteGlobal,
3724
4353
  createSkill,
3725
4354
  listBackups,
4355
+ movedTogether,
3726
4356
  restoreBackup,
4357
+ isShared,
3727
4358
  gitInfo,
3728
4359
  relativeTo,
3729
4360
  installedPlugins,
@@ -3744,7 +4375,9 @@ export {
3744
4375
  findIssues,
3745
4376
  usageIssues,
3746
4377
  scanUsage,
4378
+ skillOf,
3747
4379
  moveGlobalSkills,
4380
+ VALUE_FLAGS,
3748
4381
  parseArgs,
3749
4382
  projectOfFactory,
3750
4383
  status,