skilllib 1.11.3 → 1.11.4

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,57 @@ 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 addRefusal(root, name, hash) {
1234
+ const plan = addPlan(root, name, hash, false);
1235
+ return "reason" in plan ? plan.reason : null;
1236
+ }
1237
+ function addSkill(root, name, { force = false, version, allowTracked = false } = {}) {
1238
+ if (!name || name === "." || name === ".." || /[\\/]/.test(name)) return { name, action: "skipped", reason: "not a skill name" };
1239
+ const target = version === void 0 ? latestVersion(name) : getVersion(name, version);
1240
+ if (!target) return { name, action: "skipped", reason: version === void 0 ? "not in the library" : `version ${version} doesn't exist` };
1241
+ const plan = addPlan(root, name, target.hash, force);
1242
+ if ("reason" in plan) return { name, action: "skipped", reason: plan.reason };
1243
+ const { manifest, recorded, primary, wantedLinks, to, local } = plan;
971
1244
  let backedUp;
972
- if (local !== target.hash || isLink(to)) {
1245
+ const copied = local !== target.hash || isLink(to);
1246
+ if (copied) {
973
1247
  mkdirSync4(join8(root, primary), { recursive: true });
974
1248
  if (isLink(to)) unlinkSync(to);
975
1249
  else if (local !== null && versionForHash(name, local) === null) backedUp = stash(to, "edit-backup");
@@ -978,14 +1252,21 @@ function addSkill(root, name, { force = false, version, allowTracked = false } =
978
1252
  const links = [];
979
1253
  const blocked = [];
980
1254
  const uncommitted = [];
1255
+ const inTheWay = [];
981
1256
  let shared;
982
- for (const dir of linkDirs) {
1257
+ let relinked = false;
1258
+ for (const dir of wantedLinks) {
983
1259
  const link2 = join8(root, dir, name);
984
- if (isLink(link2) && realpathOrNull(link2) === realpathOrNull(to)) {
1260
+ const slot = slotAt(link2, to);
1261
+ if (slot === "ok") {
985
1262
  links.push(dir);
986
1263
  continue;
987
1264
  }
988
- if (entryExists(link2)) continue;
1265
+ if (slot === "taken") {
1266
+ inTheWay.push(`${dir}/${name}`);
1267
+ if (recorded?.links?.includes(dir)) links.push(dir);
1268
+ continue;
1269
+ }
989
1270
  if (!recorded?.links?.includes(dir) && isGitTracked(root, dir)) {
990
1271
  if (!(shared ??= sharedByGit(root, primary, name))) {
991
1272
  uncommitted.push(dir);
@@ -996,9 +1277,11 @@ function addSkill(root, name, { force = false, version, allowTracked = false } =
996
1277
  continue;
997
1278
  }
998
1279
  }
1280
+ if (isLink(link2)) unlinkSync(link2);
999
1281
  mkdirSync4(join8(root, dir), { recursive: true });
1000
1282
  linkDir(to, link2);
1001
1283
  links.push(dir);
1284
+ relinked = true;
1002
1285
  }
1003
1286
  manifest.skills[name] = {
1004
1287
  version: target.version,
@@ -1007,13 +1290,21 @@ function addSkill(root, name, { force = false, version, allowTracked = false } =
1007
1290
  ...links.length ? { links } : {}
1008
1291
  };
1009
1292
  writeManifest(root, manifest);
1293
+ const from = recorded ? versionForHash(name, recorded.hash)?.version ?? recorded.version : void 0;
1010
1294
  return {
1011
1295
  name,
1012
- action: !recorded && local === null ? "installed" : backedUp && recorded?.version === target.version ? "reset" : "updated",
1013
- ...recorded ? { from: recorded.version } : {},
1296
+ action: (
1297
+ // Nothing of it was there (or only an identical untracked copy, now tracked): installed, not updated.
1298
+ !recorded || local === null ? "installed" : backedUp && from === target.version ? "reset" : recorded && !copied && from === target.version ? (
1299
+ // The same version, already there: only links (if any) were written.
1300
+ relinked ? "relinked" : "unchanged"
1301
+ ) : "updated"
1302
+ ),
1303
+ ...from !== void 0 ? { from } : {},
1014
1304
  to: target.version,
1015
1305
  ...blocked.length ? { blocked } : {},
1016
1306
  ...uncommitted.length ? { uncommitted } : {},
1307
+ ...inTheWay.length ? { inTheWay } : {},
1017
1308
  ...backedUp ? { backedUp } : {}
1018
1309
  };
1019
1310
  }
@@ -1021,37 +1312,79 @@ function removeSkill(root, name, { force = false } = {}) {
1021
1312
  const manifest = readManifest(root);
1022
1313
  const recorded = manifest.skills[name];
1023
1314
  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);
1315
+ const home2 = recorded.dir ?? PROJECT_SKILLS_DIR;
1316
+ const dir = join8(root, home2, name);
1317
+ const takenLinks = [];
1318
+ const unlinkOwn = () => {
1319
+ for (const linkDir2 of (recorded.links ?? []).filter((d) => writableDir(root, d))) {
1320
+ const link2 = join8(root, linkDir2, name);
1321
+ if (isLink(link2) && slotAt(link2, dir) !== "taken") unlinkSync(link2);
1322
+ else if (entryExists(link2) && slotAt(link2, dir) === "taken") takenLinks.push(`${linkDir2}/${name} is something else (another copy, or a file): left as it is`);
1323
+ }
1324
+ };
1325
+ const leftLinks = (recorded.links ?? []).flatMap(
1326
+ (d) => ((b) => b && entryExists(join8(root, d, name)) ? [`${blockerSaid(root, b)}: its link there was left alone`] : [])(notWritable(root, d))
1327
+ );
1328
+ const ownStuck = notWritable(root, home2);
1329
+ if (ownStuck || fileAt(dir)) {
1330
+ delete manifest.skills[name];
1331
+ writeManifest(root, manifest);
1332
+ const why = ownStuck ? blockerSaid(root, ownStuck) : `${home2}/${name} is a file, not a skill folder`;
1333
+ const keptLinks = (recorded.links ?? []).flatMap((d) => {
1334
+ const link2 = join8(root, d, name);
1335
+ if (!writableDir(root, d) || !entryExists(link2)) return [];
1336
+ 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`];
1337
+ });
1338
+ return { name, action: "removed", reason: [`${why}: only its pin was removed, and that copy was left as it is`, ...keptLinks, ...leftLinks].join("; ") };
1339
+ }
1025
1340
  const local = treeHash(dir);
1026
1341
  const inLibrary = local !== null && versionForHash(name, local) !== null;
1027
1342
  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
- }
1343
+ if (edited && !force)
1344
+ return {
1345
+ name,
1346
+ action: "skipped",
1347
+ // A pin from another library isn't an edit of yours: say what it is. Either way --force backs the copy up first.
1348
+ 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)"
1349
+ };
1350
+ unlinkOwn();
1033
1351
  const backedUp = local !== null && !inLibrary ? stash(dir, "edit-backup") : void 0;
1034
1352
  if (!backedUp) rmSync3(dir, { recursive: true, force: true });
1035
1353
  delete manifest.skills[name];
1036
1354
  writeManifest(root, manifest);
1037
- return { name, action: "removed", ...backedUp ? { backedUp } : {} };
1355
+ const left = [...leftLinks, ...takenLinks];
1356
+ return { name, action: "removed", ...left.length ? { reason: left.join("; ") } : {}, ...backedUp ? { backedUp } : {} };
1038
1357
  }
1039
- function syncProject(root, { force = false } = {}) {
1040
- return Object.keys(readManifest(root).skills).flatMap((name) => syncSkill(root, name, { force }));
1358
+ function syncProject(root, { force = false, names } = {}) {
1359
+ return Object.keys(readManifest(root).skills).filter((name) => !names || names.includes(name)).flatMap((name) => syncSkill(root, name, { force }));
1041
1360
  }
1042
1361
  function syncSkill(root, name, { force = false } = {}) {
1043
1362
  const dep = readManifest(root).skills[name];
1044
1363
  if (!dep) return [];
1045
1364
  const copy = join8(root, dep.dir ?? PROJECT_SKILLS_DIR, name);
1046
1365
  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 [];
1366
+ const blocker = syncBlocker(root, name, dep);
1367
+ if (blocker) return [{ name, action: "skipped", reason: `${blocker.said}: ${blocker.outside ? "skilllib only installs inside the repo" : "move it, then sync"}` }];
1368
+ if (syncDone(root, name, dep)) return [];
1049
1369
  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
1370
  const version = pinnedVersion(name, dep);
1052
1371
  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 }];
1372
+ if (local !== null && local !== dep.hash && versionForHash(name, local) === null && !force) return [{ name, action: "skipped", reason: "has local edits (use --force to reset them)" }];
1373
+ const backedUp = local !== null && versionForHash(name, local) === null ? stash(copy, "edit-backup") : void 0;
1374
+ mkdirSync4(dirname3(copy), { recursive: true });
1375
+ copySkill(versionDir(name, version), copy);
1376
+ const links = relinkPinned(root, name, dep);
1377
+ const from = (local !== null ? versionForHash(name, local)?.version : void 0) ?? version;
1378
+ return [
1379
+ {
1380
+ name,
1381
+ action: local === null ? "installed" : backedUp ? "reset" : "updated",
1382
+ from,
1383
+ to: version,
1384
+ ...backedUp ? { backedUp } : {},
1385
+ ...links.action === "skipped" ? { reason: links.reason } : {}
1386
+ }
1387
+ ];
1055
1388
  }
1056
1389
  function relinkPinned(root, name, dep) {
1057
1390
  const to = join8(root, dep.dir ?? PROJECT_SKILLS_DIR, name);
@@ -1063,7 +1396,7 @@ function relinkPinned(root, name, dep) {
1063
1396
  mkdirSync4(join8(root, dir), { recursive: true });
1064
1397
  linkDir(to, link2);
1065
1398
  }
1066
- return { name, action: "relinked", to: dep.version };
1399
+ return { name, action: "relinked", to: versionForHash(name, dep.hash)?.version ?? dep.version };
1067
1400
  }
1068
1401
  function pinnedVersion(name, dep) {
1069
1402
  if (!latestVersion(name)) return `not in your library (${tildify(libraryDir())}); import it, or set SKILLLIB_HOME to the library that has it`;
@@ -1074,20 +1407,18 @@ function discardEdits(root, name) {
1074
1407
  if (!dep) return { name, action: "skipped", reason: "not managed by skilllib in this project" };
1075
1408
  const version = pinnedVersion(name, dep);
1076
1409
  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
1410
  return addSkill(root, name, { force: true, version });
1080
1411
  }
1081
- function updateProject(root, names, { force = false } = {}) {
1412
+ function updateProject(root, names, { force = false, allowTracked = false } = {}) {
1082
1413
  const { skills } = readManifest(root);
1083
1414
  return Object.keys(skills).filter((name) => !names || names.includes(name)).flatMap((name) => {
1084
1415
  const latest = latestVersion(name);
1085
- if (!latest) return [{ name, action: "skipped", reason: "no longer in the library" }];
1416
+ if (!latest) return [{ name, action: "skipped", reason: "not in your library" }];
1086
1417
  const dep = skills[name];
1087
1418
  const local = treeHash(join8(root, dep.dir ?? PROJECT_SKILLS_DIR, name));
1088
1419
  if (dep.hash === latest.hash && local === latest.hash) return [];
1089
1420
  if (dep.hash === latest.hash && local !== null && !force) return [];
1090
- const change = addSkill(root, name, { force });
1421
+ const change = addSkill(root, name, { force, allowTracked });
1091
1422
  return change.action === "skipped" || change.from !== change.to || local !== latest.hash ? [change] : [];
1092
1423
  });
1093
1424
  }
@@ -1100,6 +1431,10 @@ function importSkill(dir, { force = false } = {}) {
1100
1431
  if (existing !== null && !force) return { name, status: "exists" };
1101
1432
  mkdirSync4(libraryDir(), { recursive: true });
1102
1433
  const origin = existing !== null && libraryOrigins()[name] ? null : originFor(dir);
1434
+ if (existing !== null) {
1435
+ forgetLatest(name);
1436
+ latestVersion(name);
1437
+ }
1103
1438
  copySkill(dir, to);
1104
1439
  if (origin) recordOrigin(name, origin);
1105
1440
  forgetLatest(name);
@@ -1134,11 +1469,14 @@ function stash(dir, bucket) {
1134
1469
  return target;
1135
1470
  }
1136
1471
  function removeUntracked(root, name, path) {
1137
- unlinkEverywhere(root, name, path);
1138
- return stash(path, "trash");
1472
+ const stuck = notWritable(root, relative2(root, dirname3(path)));
1473
+ if (stuck) throw new Error(`${stuck} isn't a folder in the repo: skilllib leaves what's in it alone`);
1474
+ const to = stash(path, "trash");
1475
+ unlinkEverywhere(root, name);
1476
+ return to;
1139
1477
  }
1140
1478
  function projectsUsing(name) {
1141
- return knownProjects().filter((p) => name in pinsIn(p));
1479
+ return knownProjects().filter((p) => pinsSkill(p, name));
1142
1480
  }
1143
1481
  function deleteLibrarySkill(name) {
1144
1482
  const using = projectsUsing(name);
@@ -1173,7 +1511,7 @@ function linkGlobal(path, agents) {
1173
1511
  continue;
1174
1512
  }
1175
1513
  mkdirSync4(dir, { recursive: true });
1176
- linkDir(path, link2);
1514
+ linkDir(realpathOrNull(path) ?? path, join8(realpathOrNull(dir) ?? dir, name));
1177
1515
  linked.push(link2);
1178
1516
  }
1179
1517
  return { linked, skipped };
@@ -1185,9 +1523,10 @@ function createSkill(name, description) {
1185
1523
  mkdirSync4(dir, { recursive: true });
1186
1524
  writeFileSync2(
1187
1525
  join8(dir, "SKILL.md"),
1526
+ // JSON strings are valid YAML: a description with ": " or "#" stays one value.
1188
1527
  `---
1189
1528
  name: ${name}
1190
- description: ${description || "Describe when an agent should use this skill."}
1529
+ description: ${JSON.stringify(description || "Describe when an agent should use this skill.")}
1191
1530
  ---
1192
1531
 
1193
1532
  # ${name}
@@ -1212,6 +1551,11 @@ function listBackups() {
1212
1551
  });
1213
1552
  }).sort((a, b) => b.path.slice(-24).localeCompare(a.path.slice(-24)));
1214
1553
  }
1554
+ function movedTogether(backups, backup) {
1555
+ if (backup.kind === "plugin") return [backup];
1556
+ const at = (b) => Date.parse(b.path.slice(-24).replace(/^(.{13})-(\d\d)-(\d\d)-(\d{3})Z$/, "$1:$2:$3.$4Z"));
1557
+ return backups.filter((b) => b.name === backup.name && b.kind === backup.kind && Math.abs(at(b) - at(backup)) <= 2e3);
1558
+ }
1215
1559
  function backupFrom(backup) {
1216
1560
  if (backup.kind === "plugin") return "Claude Code plugin";
1217
1561
  if (!backup.from) return "unknown";
@@ -1222,12 +1566,7 @@ function restoreBackup(backup) {
1222
1566
  if (!to) return { ok: false, reason: `skilllib doesn't know where it came from; its files are in ${tildify(backup.path)}` };
1223
1567
  if (backup.kind === "tidy-backup" && isLink(to)) unlinkSync(to);
1224
1568
  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` };
1569
+ if (entryExists(to)) return { ok: false, reason: `${backup.name} already exists there` };
1231
1570
  mkdirSync4(join8(to, ".."), { recursive: true });
1232
1571
  move(backup.path, to);
1233
1572
  forgetLatest(backup.name);
@@ -1237,45 +1576,39 @@ function restoreBackup(backup) {
1237
1576
  // src/agentSkill.ts
1238
1577
  var AGENT_SKILL_NAME = "skilllib";
1239
1578
  function shippedSkill() {
1240
- return readFileSync4(new URL("../skills/skilllib/SKILL.md", import.meta.url), "utf-8");
1579
+ return readFileSync5(new URL("../skills/skilllib/SKILL.md", import.meta.url), "utf-8");
1241
1580
  }
1242
1581
  function agentSkillDirs(enabled = enabledHarnesses()) {
1243
1582
  const claudeSkills = join9(claudeDir(), "skills");
1244
1583
  const agentsSkills = join9(userHome(), ".agents", "skills");
1245
1584
  const needsClaude = enabled.some((id) => harness(id).globalDirs().includes(claudeSkills));
1246
1585
  const needsAgents = enabled.some((id) => !harness(id).globalDirs().includes(claudeSkills));
1247
- return [...needsClaude ? [claudeSkills] : [], ...needsAgents ? [agentsSkills] : []].map((d) => join9(d, AGENT_SKILL_NAME));
1586
+ const dirs = [...needsClaude ? [claudeSkills] : [], ...needsAgents ? [agentsSkills] : []];
1587
+ return dirs.filter((d, i) => dirs.findIndex((o) => realPath(o) === realPath(d)) === i).map((d) => join9(d, AGENT_SKILL_NAME));
1248
1588
  }
1249
1589
  function isOurs(dir) {
1250
1590
  return existsSync8(join9(dir, OWN_SKILL_MARKER));
1251
1591
  }
1252
1592
  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
- }
1593
+ return entryExists(path) && lstatSync3(path).isSymbolicLink() && !existsSync8(path);
1262
1594
  }
1263
1595
  function agentSkillState(enabled = enabledHarnesses()) {
1264
1596
  const [primary, ...links] = agentSkillDirs(enabled);
1265
1597
  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";
1598
+ return [primary, ...links].every((d) => readFileSync5(join9(d, "SKILL.md"), "utf-8") === shippedSkill()) ? "installed" : "outdated";
1267
1599
  }
1268
1600
  function installAgentSkill(enabled = enabledHarnesses()) {
1269
1601
  const [primary, ...links] = agentSkillDirs(enabled);
1270
1602
  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));
1603
+ const foreign = [primary, ...links].find((d) => entryExists(d) && !isOurs(d) && !isDangling(d));
1272
1604
  if (foreign) return { ok: false, reason: `${tildify(foreign)} already exists and isn't skilllib's` };
1273
1605
  for (const d of [primary, ...links]) if (isDangling(d)) unlinkSync2(d);
1274
1606
  mkdirSync5(primary, { recursive: true });
1275
1607
  writeFileSync3(join9(primary, "SKILL.md"), shippedSkill());
1276
1608
  writeFileSync3(join9(primary, OWN_SKILL_MARKER), "Installed by skilllib. `skilllib agent-skill remove` removes it.\n");
1277
1609
  for (const link2 of links) {
1278
- if (entryExists2(link2)) continue;
1610
+ if (entryExists(link2) && !isLink(link2) && isOurs(link2)) rmSync4(link2, { recursive: true, force: true });
1611
+ if (entryExists(link2)) continue;
1279
1612
  mkdirSync5(join9(link2, ".."), { recursive: true });
1280
1613
  linkDir(primary, link2);
1281
1614
  }
@@ -1283,9 +1616,9 @@ function installAgentSkill(enabled = enabledHarnesses()) {
1283
1616
  }
1284
1617
  function removeAgentSkill() {
1285
1618
  const all = agentSkillDirs(["claude-code", "codex"]);
1286
- const removed = all.filter((d) => entryExists2(d) && (isOurs(d) || isDangling(d)));
1619
+ const removed = all.filter((d) => entryExists(d) && (isOurs(d) || isDangling(d)));
1287
1620
  for (const d of removed) {
1288
- if (!entryExists2(d)) continue;
1621
+ if (!entryExists(d)) continue;
1289
1622
  if (lstatSync3(d).isSymbolicLink()) unlinkSync2(d);
1290
1623
  else rmSync4(d, { recursive: true, force: true });
1291
1624
  }
@@ -1299,14 +1632,14 @@ function refreshAgentSkill() {
1299
1632
  }
1300
1633
 
1301
1634
  // src/commands.ts
1302
- import { existsSync as existsSync14, readFileSync as readFileSync6 } from "fs";
1635
+ import { existsSync as existsSync14, readFileSync as readFileSync7 } from "fs";
1303
1636
  import { parseArgs as parseFlags } from "util";
1304
- import { basename as basename9, join as join15, resolve as resolve7, sep as sep6 } from "path";
1637
+ import { basename as basename9, join as join15, resolve as resolve8, sep as sep8 } from "path";
1305
1638
 
1306
1639
  // src/git.ts
1307
1640
  import { execFileSync as execFileSync2 } from "child_process";
1308
- import { existsSync as existsSync9 } from "fs";
1309
- import { join as join10, relative as relative3 } from "path";
1641
+ import { existsSync as existsSync9, realpathSync as realpathSync4 } from "fs";
1642
+ import { isAbsolute as isAbsolute2, join as join10, relative as relative3, sep as sep5 } from "path";
1310
1643
  function git(root, args, input) {
1311
1644
  try {
1312
1645
  return execFileSync2("git", args, { cwd: root, encoding: "utf-8", input, stdio: ["pipe", "pipe", "ignore"], maxBuffer: 16 * 1024 * 1024 });
@@ -1316,11 +1649,32 @@ function git(root, args, input) {
1316
1649
  }
1317
1650
  }
1318
1651
  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)));
1652
+ let inRepo;
1653
+ try {
1654
+ inRepo = execFileSync2("git", ["rev-parse", "--show-prefix"], { cwd: root, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
1655
+ } catch {
1656
+ if (!existsSync9(join10(root, ".git"))) return null;
1657
+ inRepo = "";
1658
+ }
1659
+ const real = (p) => {
1660
+ try {
1661
+ return realpathSync4.native(p);
1662
+ } catch {
1663
+ return p;
1664
+ }
1665
+ };
1666
+ const realRoot = real(root);
1667
+ const places = new Map(
1668
+ [.../* @__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))
1669
+ );
1670
+ const dirs = [...new Set(places.values())];
1321
1671
  if (dirs.length === 0) return { of: () => "new" };
1672
+ const placeOf = (rel) => {
1673
+ const dir = [...places.keys()].find((d) => rel === d || rel.startsWith(d + "/"));
1674
+ return dir === void 0 ? rel : places.get(dir) + rel.slice(dir.length);
1675
+ };
1322
1676
  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));
1677
+ 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
1678
  const ignoredCache = /* @__PURE__ */ new Map();
1325
1679
  const isIgnored = (rel) => {
1326
1680
  if (!ignoredCache.has(rel)) {
@@ -1332,7 +1686,7 @@ function gitInfo(root, extraDirs = []) {
1332
1686
  const under = (list2, rel) => list2.some((p) => p === rel || p.startsWith(rel + "/"));
1333
1687
  return {
1334
1688
  of: (relPath) => {
1335
- const rel = relPath.replace(/\/+$/, "");
1689
+ const rel = placeOf(relPath.replace(/\/+$/, ""));
1336
1690
  if (under(tracked, rel)) return under(dirty, rel) ? "changed" : "committed";
1337
1691
  return isIgnored(rel) ? "ignored" : "new";
1338
1692
  }
@@ -1347,13 +1701,13 @@ import { readdirSync as readdirSync8 } from "fs";
1347
1701
  import { basename as basename7 } from "path";
1348
1702
 
1349
1703
  // 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";
1704
+ import { existsSync as existsSync12, lstatSync as lstatSync4, readdirSync as readdirSync7 } from "fs";
1705
+ import { basename as basename6, dirname as dirname6, isAbsolute as isAbsolute3, join as join13, relative as relative4, sep as sep7 } from "path";
1352
1706
 
1353
1707
  // src/plugins.ts
1354
1708
  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";
1709
+ import { existsSync as existsSync10, mkdirSync as mkdirSync6, readdirSync as readdirSync5, readFileSync as readFileSync6, statSync as statSync4, writeFileSync as writeFileSync4 } from "fs";
1710
+ import { basename as basename4, dirname as dirname4, join as join11, resolve as resolve7 } from "path";
1357
1711
  function nonEmptyDir(path) {
1358
1712
  try {
1359
1713
  return readdirSync5(path).some((f) => !f.startsWith("."));
@@ -1383,6 +1737,8 @@ function claudePlugins(machine, root) {
1383
1737
  scope: synced ? null : entry?.scope ?? "user",
1384
1738
  ...entry?.projectPath ? { projectPath: entry.projectPath } : {},
1385
1739
  synced,
1740
+ // Turned on in your settings with no install record (as installedPlugins finds it).
1741
+ ...!synced && !entries.length ? { settingsOnly: true } : {},
1386
1742
  skills,
1387
1743
  extras: extrasOf(dirname4(dirname4(skills[0])))
1388
1744
  };
@@ -1522,8 +1878,8 @@ function ignoreLocalSettings(root) {
1522
1878
  } catch {
1523
1879
  }
1524
1880
  try {
1525
- const exclude = resolve6(root, git2("rev-parse", "--git-path", "info/exclude"));
1526
- const before = existsSync10(exclude) ? readFileSync5(exclude, "utf-8") : "";
1881
+ const exclude = resolve7(root, git2("rev-parse", "--git-path", "info/exclude"));
1882
+ const before = existsSync10(exclude) ? readFileSync6(exclude, "utf-8") : "";
1527
1883
  mkdirSync6(dirname4(exclude), { recursive: true });
1528
1884
  writeFileSync4(exclude, `${before}${before && !before.endsWith("\n") ? "\n" : ""}/${git2("rev-parse", "--show-prefix")}${file}
1529
1885
  `);
@@ -1534,7 +1890,7 @@ function ignoreLocalSettings(root) {
1534
1890
  }
1535
1891
  function turnOffIn(plugin, root) {
1536
1892
  const done = `${plugin.id} turned off in ${basename4(root)}; other repos keep it`;
1537
- const bin = plugin.synced ? null : claudeBinary();
1893
+ const bin = plugin.synced || plugin.settingsOnly ? null : claudeBinary();
1538
1894
  if (!bin) {
1539
1895
  const r2 = setPluginIn(join11(root, ".claude", "settings.local.json"), plugin.id, false);
1540
1896
  return r2.ok ? { ok: true, message: `${done} (in .claude/settings.local.json${ignoreLocalSettings(root)})` } : { ok: false, message: `${plugin.id}: ${r2.reason}` };
@@ -1551,7 +1907,8 @@ function whereOf(p) {
1551
1907
  return p.projectPath ? ` in ${basename4(p.projectPath)} (${p.scope})` : "";
1552
1908
  }
1553
1909
  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"}` };
1910
+ 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" : "";
1911
+ 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}` };
1555
1912
  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` };
1556
1913
  const bin = p.synced || p.settingsOnly ? null : claudeBinary();
1557
1914
  if (bin) {
@@ -1560,7 +1917,8 @@ function setPluginOn(p, on) {
1560
1917
  }
1561
1918
  const r = setPluginIn(settingsFileOf(p), p.id, on);
1562
1919
  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;
1920
+ if (p.scope === "local" && p.projectPath) return { ...done, message: done.message + ignoreLocalSettings(p.projectPath) };
1921
+ return done;
1564
1922
  }
1565
1923
  function uninstallPlugin(p) {
1566
1924
  if (p.synced) return { ok: false, message: `${p.id} is synced from your claude.ai account: remove it there, or turn it off here` };
@@ -1590,10 +1948,11 @@ function removedFile() {
1590
1948
  return join11(skilllibHome(), "plugin-backup.json");
1591
1949
  }
1592
1950
  function removePlugin(plugin, how) {
1951
+ if (plugin.settingsOnly && how === "delete") return { ok: false, message: `${plugin.id} isn't installed, only turned on in your settings: turn it off instead` };
1593
1952
  if (plugin.scope && plugin.scope !== "user" && !plugin.projectPath) {
1594
1953
  return { ok: false, message: `${plugin.id}: installed for one project; run \`claude plugin ${how === "delete" ? "uninstall" : "disable"} ${plugin.id} --scope ${plugin.scope}\` there` };
1595
1954
  }
1596
- const viaSettings = plugin.synced || !claudeBinary() && how === "off" && !plugin.projectPath;
1955
+ const viaSettings = plugin.synced || plugin.settingsOnly || !claudeBinary() && how === "off" && !plugin.projectPath;
1597
1956
  if (!viaSettings && !claudeBinary()) {
1598
1957
  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
1958
  }
@@ -1602,7 +1961,7 @@ function removePlugin(plugin, how) {
1602
1961
  const imported = plugin.skills.filter((dir) => !yours.has(basename4(dir)) && latestVersion(basename4(dir)) === null).map((dir) => importSkill(dir).name);
1603
1962
  const scope = plugin.scope ? ["--scope", plugin.scope] : [];
1604
1963
  const r = viaSettings ? (
1605
- // Synced plugins aren't installed, so `claude plugin` can't touch them: their switch is enabledPlugins.
1964
+ // Synced and settings-only plugins aren't installed, so `claude plugin` can't touch them: their switch is enabledPlugins.
1606
1965
  setPluginIn(join11(claudeDir(), "settings.json"), plugin.id, false)
1607
1966
  ) : (
1608
1967
  // --keep-data, as uninstallPlugin: its saved data stays, so a restore brings it back as it was.
@@ -1611,7 +1970,9 @@ function removePlugin(plugin, how) {
1611
1970
  const saved = imported.length ? `; ${imported.join(", ")} copied into Your skills` : "";
1612
1971
  if (!r.ok) return { ok: false, message: `${plugin.id}: ${r.reason}${saved}` };
1613
1972
  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}` };
1973
+ const others = how === "delete" ? (installedPluginsJson()[plugin.id] ?? []).filter((e) => (e.scope ?? "user") !== (plugin.scope ?? "user") || e.projectPath !== plugin.projectPath) : [];
1974
+ const left = others.length ? `; its ${others.map((e) => `${e.scope ?? "user"} install${e.projectPath ? ` in ${basename4(e.projectPath)}` : ""}`).join(", ")} stays` : "";
1975
+ return { ok: true, message: `${plugin.id} ${how === "delete" && !plugin.synced ? `removed (${plugin.scope ?? "user"} install)` : "turned off"}${saved}${left}` };
1615
1976
  }
1616
1977
  function recordRemovedPlugin(id, scope, projectPath, on = true) {
1617
1978
  const list2 = readState(removedFile()) ?? [];
@@ -1647,7 +2008,7 @@ function cursorPluginSkills(plugins = cursorPlugins()) {
1647
2008
 
1648
2009
  // src/tidy.ts
1649
2010
  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";
2011
+ import { basename as basename5, dirname as dirname5, join as join12, sep as sep6 } from "path";
1651
2012
  function entriesOf(dirs, name) {
1652
2013
  return dirs.flatMap((dir) => {
1653
2014
  const path = join12(dir, name);
@@ -1665,12 +2026,13 @@ function hashReals(reals) {
1665
2026
  function namesIn(dirs) {
1666
2027
  const names = /* @__PURE__ */ new Set();
1667
2028
  for (const dir of dirs) {
1668
- if (!existsSync11(dir)) continue;
2029
+ if (!isDir(dir)) continue;
1669
2030
  for (const name of readdirSync6(dir)) if (!name.startsWith(".") && name !== "synced") names.add(name);
1670
2031
  }
1671
2032
  return [...names].sort();
1672
2033
  }
1673
2034
  var PROJECT_TIDY_DIRS = ALL_PROJECT_DIRS.filter((d) => d !== GROK_SKILLS_DIR);
2035
+ var repoTidyDirs = (root) => PROJECT_TIDY_DIRS.filter((d) => notWritable(root, d) === null);
1674
2036
  function globalTidyDirs() {
1675
2037
  const own = realPath(join12(grokDir(), "skills"));
1676
2038
  return globalSkillDirs().filter((d) => realPath(d) !== own);
@@ -1697,11 +2059,13 @@ function planProjectTidy(root, { keep = {}, enabled = enabledHarnesses() } = {})
1697
2059
  const readsOf = (id) => harness(id).projectDirs.map(abs);
1698
2060
  let git2;
1699
2061
  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));
2062
+ const dirs = repoTidyDirs(root).map(abs);
2063
+ for (const name of namesIn(dirs)) {
2064
+ const entries = entriesOf(dirs, name);
2065
+ const external = entries.find((e) => e.link && e.real && !(e.real + sep6).startsWith(realpathOrNull(root) + sep6));
1703
2066
  if (external) {
1704
- report.skipped.push({ name, reason: `${rel(external.path)} links outside the repo` });
2067
+ const differs = entries.some((e) => e !== external && e.real && existsSync11(join12(e.real, "SKILL.md")) && treeHash(e.real) !== treeHash(external.real));
2068
+ report.skipped.push({ name, reason: `${rel(external.path)} links outside the repo`, ...differs ? { differs } : {} });
1705
2069
  continue;
1706
2070
  }
1707
2071
  const reals = entries.filter((e, i, all) => !e.link && all.findIndex((o) => !o.link && realpathOrNull(o.path) === realpathOrNull(e.path)) === i);
@@ -1727,6 +2091,11 @@ function planProjectTidy(root, { keep = {}, enabled = enabledHarnesses() } = {})
1727
2091
  report.conflicts.push({ ...conflictOf(name, root, reals, enabled, readsOf), ...dep ? { managed: primary.dir } : {} });
1728
2092
  continue;
1729
2093
  }
2094
+ 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));
2095
+ if (elsewhere) {
2096
+ report.skipped.push({ name, reason: `${rel(elsewhere.path)} links to a different copy`, differs: true });
2097
+ continue;
2098
+ }
1730
2099
  const wanted = new Set([...installDirs(enabled, rel(primary.dir)), ...dep?.links ?? []].map(abs));
1731
2100
  const steps = [];
1732
2101
  for (const e of entries) {
@@ -1739,6 +2108,7 @@ function planProjectTidy(root, { keep = {}, enabled = enabledHarnesses() } = {})
1739
2108
  } else steps.push(needed ? { kind: "link", path: e.path, target: primary.path } : { kind: "unlink", path: e.path });
1740
2109
  } else {
1741
2110
  const differs = e.hash !== primary.hash ? { differs: true } : {};
2111
+ if (!needed && ["committed", "changed"].includes((git2 ??= gitInfo(root))?.of(rel(e.path)) ?? "")) continue;
1742
2112
  steps.push(needed ? { kind: "replace", path: e.path, target: primary.path, ...differs } : { kind: "remove", path: e.path, ...differs });
1743
2113
  }
1744
2114
  }
@@ -1778,10 +2148,10 @@ function planGlobalTidy({ keep = {}, enabled = enabledHarnesses() } = {}) {
1778
2148
  }
1779
2149
  function gitVisibleSteps(plan, git2 = plan.root ? gitInfo(plan.root) : null) {
1780
2150
  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
- });
2151
+ const shared = (rel) => ["committed", "changed"].includes(git2.of(rel));
2152
+ const dep = readManifest(plan.root).skills[plan.name];
2153
+ const recordsLink = (s) => !!dep && (s.kind === "link" || s.kind === "replace") && !dep.links?.includes(relativeTo(plan.root, dirname5(s.path)));
2154
+ return plan.steps.filter((s) => shared(relativeTo(plan.root, s.path)) || recordsLink(s) && shared(MANIFEST_FILE));
1785
2155
  }
1786
2156
  function applyTidy(plan, { git: git2 = "keep", info: info2 } = {}) {
1787
2157
  const held = git2 === "keep" ? gitVisibleSteps(plan, info2) : [];
@@ -1822,18 +2192,19 @@ function describeStep(step, root) {
1822
2192
  return `remove ${step.differs ? "differing" : "duplicate"} copy ${shown}`;
1823
2193
  }
1824
2194
  }
1825
- function copyLabel(conflict, dir) {
1826
- return folderLabel(conflict.root, dir);
1827
- }
1828
2195
  function folderLabel(root, dir) {
1829
2196
  return root ? relativeTo(root, dir) : tildify(dir);
1830
2197
  }
1831
2198
  function foldersOf(root, name) {
1832
- const entries = entriesOf(root ? PROJECT_TIDY_DIRS.map((d) => join12(root, d)) : globalTidyDirs(), name);
2199
+ const entries = entriesOf(root ? repoTidyDirs(root).map((d) => join12(root, d)) : globalTidyDirs(), name);
1833
2200
  return { all: entries.map((e) => e.dir), real: entries.filter((e) => !e.link).map((e) => e.dir) };
1834
2201
  }
1835
2202
 
1836
2203
  // src/health.ts
2204
+ function detailWithGit(issue) {
2205
+ const notes = [...issue.fix ? [issue.fix] : [], ...issue.choices ?? []].filter((c) => c.hint && /\bgit\b|committed/.test(c.hint)).map((c) => `"${c.label}": ${c.hint}.`);
2206
+ return notes.length ? `${issue.detail} ${notes.join(" ")}` : issue.detail;
2207
+ }
1837
2208
  function runFix(choice) {
1838
2209
  try {
1839
2210
  return { ok: true, message: choice.run() };
@@ -1865,6 +2236,16 @@ function summarize(where, verb, changes) {
1865
2236
  }
1866
2237
  var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
1867
2238
  var rows = (root, skills) => skills.map((skill) => ({ root, skill }));
2239
+ var removesClean = (root, s) => s.managed && !s.state.startsWith("edited") && s.state !== "not in library" && !syncBlocker(root, s.name);
2240
+ var gitMemo = /* @__PURE__ */ new Map();
2241
+ var gitOf = (root) => {
2242
+ if (!gitMemo.has(root)) gitMemo.set(root, gitInfo(root));
2243
+ return gitMemo.get(root);
2244
+ };
2245
+ var gitNote = (root, paths) => {
2246
+ const git2 = gitOf(root);
2247
+ return paths.some((p) => ["committed", "changed"].includes(git2?.of(relativeTo(root, p)) ?? "")) ? "; git will see it" : "";
2248
+ };
1868
2249
  var agentNames = (ids) => ids.map((id) => harness(id).name).join(", ");
1869
2250
  function conflictIssue(c) {
1870
2251
  const where = c.root ? basename6(c.root) : "your global folders";
@@ -1874,21 +2255,22 @@ function conflictIssue(c) {
1874
2255
  const plan = planFor(dir);
1875
2256
  const r = plan ? applyTidy(plan, { git: git2 }) : null;
1876
2257
  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` : ""}`;
2258
+ return `${c.name}: kept the ${folderLabel(c.root, dir)} copy${r.held.length ? `; held back ${plural(r.held.length, "change")} git would see` : ""}`;
1878
2259
  };
1879
2260
  return {
1880
2261
  id: `conflict:${c.root ?? "global"}:${c.name}`,
1881
2262
  severity: "problem",
1882
2263
  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).`,
2264
+ 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
2265
  at: rows(c.root, [c.name]),
1885
2266
  choices: winners.flatMap((x) => {
1886
2267
  const plan = planFor(x.dir);
1887
2268
  const visible2 = plan ? gitVisibleSteps(plan).map((s) => describeStep(s, c.root)) : [];
1888
- const label = `Keep the ${copyLabel(c, x.dir)} copy`;
2269
+ const label = `Keep the ${folderLabel(c.root, x.dir)} copy`;
1889
2270
  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
2271
  return [
1891
- { label: `${label}, but keep git as it is`, hint: `skips what git would see: ${visible2.join(", ")}`, run: keep(x.dir, "keep") },
2272
+ // "Keep git as it is" only when it leaves something to do.
2273
+ ...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
2274
  { label: `${label}, and change what git tracks`, hint: `git will see: ${visible2.join(", ")}`, run: keep(x.dir, "go") }
1893
2275
  ];
1894
2276
  })
@@ -1898,34 +2280,68 @@ function applyAll(plans, git2) {
1898
2280
  const results = plans.map((p) => applyTidy(p, { git: git2 }));
1899
2281
  return { skills: results.filter((r) => r.applied.length).length, held: results.reduce((n, r) => n + r.held.length, 0) };
1900
2282
  }
1901
- function brokenLinksIn(root) {
2283
+ function brokenLinksIn(root, opened) {
1902
2284
  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")));
2285
+ const tidyDirs = repoTidyDirs(root);
2286
+ const hasCopy = (name) => tidyDirs.some((d) => !isLink(join13(root, d, name)) && existsSync12(join13(root, d, name, "SKILL.md")));
2287
+ const mends = (project, dir, name) => {
2288
+ const dep = pinsIn(project)[name];
2289
+ return !!dep && (dep.links ?? []).includes(dir) && !syncBlocker(project, name, dep) && versionForHash(name, dep.hash) !== null;
2290
+ };
2291
+ const syncMends = (dir, name) => mends(root, dir, name);
1905
2292
  let git2;
1906
- return ALL_PROJECT_DIRS.flatMap((dir) => {
2293
+ const realRoot = realNative(root) ?? root;
2294
+ const ownerOf = (real) => {
2295
+ let nearest = null;
2296
+ for (let d = dirname6(dirname6(real)); d.startsWith(realRoot + sep7); d = dirname6(d)) {
2297
+ if (!existsSync12(join13(d, MANIFEST_FILE))) continue;
2298
+ if (opened.has(d)) return { opened: true, own: d };
2299
+ nearest ??= d;
2300
+ }
2301
+ return { opened: false, own: nearest };
2302
+ };
2303
+ const seen = /* @__PURE__ */ new Set();
2304
+ const aliased = (dir) => (realNative(join13(root, dir)) ?? "") !== join13(realRoot, dir);
2305
+ const packageFolders = packageSkillFolders(root).map((f) => ({ dir: f.location, top: false })).sort((a, b) => Number(aliased(a.dir)) - Number(aliased(b.dir)));
2306
+ const folders2 = [...ALL_PROJECT_DIRS.map((dir) => ({ dir, top: true })), ...packageFolders].flatMap(({ dir, top }) => {
2307
+ const real = realNative(join13(root, dir)) ?? join13(root, dir);
2308
+ if (seen.has(real)) return [];
2309
+ seen.add(real);
2310
+ const owner = ownerOf(real);
2311
+ return owner.opened ? [] : [{ dir, top: top && !owner.own, own: owner.own, real }];
2312
+ });
2313
+ return folders2.flatMap(({ dir, top, own, real }) => {
1907
2314
  const folder = join13(root, dir);
1908
- if (!existsSync12(folder)) return [];
2315
+ if (!isDir(folder) || notWritable(root, dir)) return [];
1909
2316
  return readdirSync7(folder).flatMap((name) => {
1910
2317
  const path = join13(folder, name);
1911
- if (!isLink(path) || existsSync12(path) || dir !== GROK_SKILLS_DIR && hasCopy(name)) return [];
2318
+ if (!isLink(path) || isDir(path) || top && (dir !== GROK_SKILLS_DIR && hasCopy(name) || syncMends(dir, name))) return [];
1912
2319
  const remove2 = {
1913
2320
  label: "Remove the link",
2321
+ hint: "restorable with skilllib restore",
1914
2322
  run: () => {
1915
- unlinkSync4(path);
1916
- return `Removed the broken link ${dir}/${name} in ${where}`;
2323
+ if (!isLink(path)) return `${dir}/${name} in ${where} is already gone`;
2324
+ stash(path, "tidy-backup");
2325
+ return `Removed the broken link ${dir}/${name} in ${where} (restorable with skilllib restore)`;
1917
2326
  }
1918
2327
  };
1919
- git2 ??= gitInfo(root);
1920
- const tracked = ["committed", "changed"].includes(git2?.of(relativeTo(root, path)) ?? "");
2328
+ const placeOf = (p) => relative4(realRoot, p).split(sep7).join("/");
2329
+ git2 ??= gitInfo(
2330
+ root,
2331
+ folders2.map((f) => placeOf(f.real)).filter((p) => p !== "" && p !== ".." && !p.startsWith("../") && !isAbsolute3(p))
2332
+ );
2333
+ const tracked = ["committed", "changed"].includes(git2?.of(placeOf(join13(real, name))) ?? "");
2334
+ const recorded = !!own && mends(own, relative4(own, real).split(sep7).join("/"), name);
2335
+ 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
2336
  return [
1922
2337
  {
1923
2338
  id: `broken:${root}:${dir}/${name}`,
1924
2339
  severity: "problem",
1925
2340
  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." : ""}`,
2341
+ 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
2342
  at: rows(root, [name]),
1928
- ...tracked ? { choices: [remove2] } : { fix: remove2 }
2343
+ // Removed by itself only at the repo's top, uncommitted: a package's link (or a committed one) is asked first.
2344
+ ...top && !tracked ? { fix: remove2 } : { choices: [remove2] }
1929
2345
  }
1930
2346
  ];
1931
2347
  });
@@ -1956,32 +2372,37 @@ function grokDiffers(projects2, machine, statusOf) {
1956
2372
  return [...global, ...repos];
1957
2373
  }
1958
2374
  function findIssues(roots, machine, libraryNames, statusOf = projectStatus, loadedIn = (root) => skillsLoadedIn(root, machine), nestedOf = nestedSkills) {
2375
+ gitMemo = /* @__PURE__ */ new Map();
1959
2376
  const { readable: projects2, unreadable } = readableRoots(roots);
1960
2377
  const issues = unreadable.map(({ root, reason }) => ({
1961
2378
  id: `manifest:${root}`,
1962
2379
  severity: "problem",
1963
2380
  title: `${basename6(root)}: skilllib.json can't be read`,
1964
2381
  detail: `${reason}. Until it's fixed, skilllib leaves ${basename6(root)} out.`,
1965
- at: []
2382
+ at: [{ root, skill: "" }]
1966
2383
  }));
1967
2384
  const statuses = new Map(projects2.map((root) => [root, statusOf(root)]));
2385
+ const openedProjects = new Set(projects2.map((p) => realNative(p) ?? p));
1968
2386
  issues.push(...grokDiffers(projects2, machine, (root) => statuses.get(root)));
1969
2387
  const nested = new Map(projects2.map((root) => [root, nestedOf(root)]));
1970
2388
  for (const skill of machine.filter((m) => m.broken)) {
2389
+ const reaches = isDir(skill.path) ? "folder" : existsSync12(skill.path) ? "file" : "nothing";
2390
+ const remove2 = {
2391
+ label: "Remove the link",
2392
+ hint: "restorable with skilllib restore",
2393
+ run: () => {
2394
+ const r = unloadGlobal(skill.path);
2395
+ if (!r.ok) throw new Error(`${skill.name}: ${r.reason}`);
2396
+ return `Removed broken link ${skill.name}`;
2397
+ }
2398
+ };
1971
2399
  issues.push({
1972
2400
  id: `broken:${skill.path}`,
1973
2401
  severity: "problem",
1974
2402
  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.",
2403
+ 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
2404
  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
- }
2405
+ ...reaches === "folder" ? { choices: [remove2] } : { fix: remove2 }
1985
2406
  });
1986
2407
  }
1987
2408
  const loaded = machine.filter((m) => !m.broken);
@@ -2006,18 +2427,21 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2006
2427
  ];
2007
2428
  const pluginAgentsIn = (plugin, root) => new Set(loadedHere.get(root).filter((m) => plugin.skills.includes(m.path)).flatMap((m) => m.harnesses));
2008
2429
  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)]);
2430
+ const installedYours = /* @__PURE__ */ new Set([...globalYours, ...[...statuses.values()].flat().map((s) => s.name), ...knownProjects().flatMap((p) => Object.keys(pinsIn(p)))]);
2010
2431
  const repoChoices = (plugin, root, names) => {
2011
2432
  const where = basename6(root);
2012
2433
  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));
2434
+ const allManaged = names.every((n) => copies.some((c) => c.name === n && removesClean(root, c)));
2435
+ const committed = gitNote(root, copies.map((s) => s.path)) !== "";
2436
+ const pluginAgents = pluginAgentsIn(plugin, root);
2437
+ const leftOut = copies.flatMap((s) => s.visibility.filter((v) => v.paths > 0 && !pluginAgents.has(v.id)).map((v) => v.id));
2014
2438
  return [
2015
2439
  { 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
2440
  // 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 ? [
2441
+ ...names.length && allManaged && !committed && !leftOut.length ? [
2018
2442
  {
2019
2443
  label: `Remove your ${copies.map((s) => s.name).join(", ")} from ${where}`,
2020
- hint: "the plugin's copy stays; Your skills keeps yours",
2444
+ hint: `the plugin's copy stays; Your skills keeps yours${gitNote(root, [join13(root, MANIFEST_FILE)])}`,
2021
2445
  run: () => {
2022
2446
  for (const s of copies) {
2023
2447
  const r = removeSkill(root, s.name);
@@ -2058,12 +2482,13 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2058
2482
  claudeBoth.length ? `Claude Code loads both (the plugin's as /${pluginName}:${claudeBoth[0]}). Your skill wins.` : "",
2059
2483
  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
2484
  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"}.` : "",
2485
+ others ? `Turning it off or removing it copies its other ${others === 1 ? "skill" : `${others} skills`} into Your skills first, so nothing is lost.` : "",
2486
+ 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
2487
  plugin.synced ? "It's synced from claude.ai, so it can only be turned off here." : "",
2488
+ plugin.settingsOnly ? "It's only turned on in your settings, not installed, so it can only be turned off here." : "",
2064
2489
  inRepos.length ? `Or keep it and turn it off only in ${inRepos.map(([root]) => basename6(root)).join(", ")}.` : ""
2065
2490
  ].filter(Boolean).join(" "),
2066
- choices: [...plugin.synced ? [off] : plugin.extras.length ? [off, remove2] : [remove2, off], ...inRepos.flatMap(([root, d]) => repoChoices(plugin, root, d))],
2491
+ choices: [...plugin.synced || plugin.settingsOnly ? [off] : plugin.extras.length ? [off, remove2] : [remove2, off], ...inRepos.flatMap(([root, d]) => repoChoices(plugin, root, d))],
2067
2492
  // Global's rows (yours and the plugin's), and the repos where both load.
2068
2493
  at: [...rows(null, dupes), ...[...inRepos, ...cursorRepos].flatMap(([root, d]) => rows(root, d))],
2069
2494
  plugin: plugin.id
@@ -2086,7 +2511,7 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2086
2511
  for (const root of projects2) {
2087
2512
  const where = basename6(root);
2088
2513
  const repoOnly = loadedHere.get(root).filter((m) => m.kind === "plugin" && !machinePluginSkills.has(m.path));
2089
- for (const plugin of claudePlugins(repoOnly, root)) {
2514
+ for (const plugin of enabledHarnesses().includes("claude-code") ? claudePlugins(repoOnly, root) : []) {
2090
2515
  const names = plugin.skills.map((p) => basename6(p));
2091
2516
  const inRepo = namesIn2(root).filter((n) => names.includes(n));
2092
2517
  const dupes = names.filter((n) => inRepo.includes(n) || globalYours.has(n));
@@ -2180,19 +2605,37 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2180
2605
  for (const root of projects2) {
2181
2606
  const where = basename6(root);
2182
2607
  const status2 = statuses.get(root);
2183
- issues.push(...brokenLinksIn(root));
2608
+ issues.push(...brokenLinksIn(root, openedProjects));
2184
2609
  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)))
2610
+ const blockedFor = /* @__PURE__ */ new Map();
2611
+ const untrackable = (s) => {
2612
+ if (!blockedFor.has(s.name)) blockedFor.set(s.name, trackBlocker(root, s.name));
2613
+ return blockedFor.get(s.name);
2614
+ };
2615
+ const blockers = new Map(status2.filter((s) => s.managed).flatMap((s) => ((b) => b ? [[s.name, b]] : [])(syncBlocker(root, s.name, pins[s.name]))));
2616
+ for (const [name, blocker] of blockers)
2617
+ issues.push({
2618
+ id: `stuck:${root}:${name}`,
2619
+ severity: "problem",
2620
+ title: `${name} in ${where}: sync can't put it back as skilllib.json pins it`,
2621
+ // What to do depends on what's there: a folder that links out of the repo is a setup of yours, and a pin
2622
+ // your library has no version of can't come back by sync anyway.
2623
+ 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."}`,
2624
+ at: rows(root, [name])
2625
+ });
2626
+ const unstuck = status2.filter((s) => !blockers.has(s.name));
2627
+ const missing = unstuck.filter((s) => s.state === "folder missing" && s.latest !== null);
2628
+ const knownPin = (s) => versionForHash(s.name, pins[s.name]?.hash ?? "") !== null;
2629
+ const restorable = missing.filter(knownPin);
2630
+ for (const s of unstuck.filter((m) => (m.state === "folder missing" || m.state === "out of sync") && m.latest !== null && !knownPin(m)))
2188
2631
  issues.push({
2189
2632
  id: `pin:${root}:${s.name}`,
2190
2633
  severity: "problem",
2191
2634
  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.`,
2635
+ 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
2636
  at: rows(root, [s.name]),
2194
2637
  // 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}` }]
2638
+ ...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
2639
  });
2197
2640
  if (restorable.length > 0) {
2198
2641
  issues.push({
@@ -2203,16 +2646,35 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2203
2646
  at: rows(root, restorable.map((s) => s.name)),
2204
2647
  fix: {
2205
2648
  label: "Sync the project",
2206
- run: () => summarize(where, "restored", syncProject(root))
2649
+ // Only the missing folders: an out-of-sync copy (maybe committed) is its own issue's choice.
2650
+ run: () => summarize(where, "restored", syncProject(root, { names: restorable.map((s) => s.name) }))
2207
2651
  }
2208
2652
  });
2209
2653
  }
2210
- for (const s of status2.filter((s2) => s2.state === "folder missing" && s2.latest === null))
2654
+ const drifted = unstuck.filter((s) => s.state === "out of sync" && knownPin(s));
2655
+ if (drifted.length) {
2656
+ const seen = drifted.some((s) => ["committed", "changed"].includes(gitOf(root)?.of(relativeTo(root, s.path)) ?? ""));
2657
+ const yours = drifted.some((s) => untrackable(s));
2658
+ const install = {
2659
+ label: "Install the pinned versions",
2660
+ hint: seen ? "git will see the copies change" : "your library keeps every version",
2661
+ run: () => summarize(where, "synced", syncProject(root, { names: drifted.map((s) => s.name) }))
2662
+ };
2663
+ issues.push({
2664
+ id: `out-of-sync:${root}`,
2665
+ severity: "suggestion",
2666
+ title: `${where}: ${plural(drifted.length, "skill")} not at the version skilllib.json pins`,
2667
+ 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.` : ""}`,
2668
+ at: rows(root, drifted.map((s) => s.name)),
2669
+ ...seen || yours ? { choices: [install] } : { fix: install }
2670
+ });
2671
+ }
2672
+ for (const s of unstuck.filter((s2) => s2.state === "folder missing" && s2.latest === null))
2211
2673
  issues.push({
2212
2674
  id: `unavailable:${root}:${s.name}`,
2213
2675
  severity: "problem",
2214
2676
  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.`,
2677
+ 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
2678
  at: rows(root, [s.name]),
2217
2679
  // Editing skilllib.json is the team's call: a choice, never run by `doctor --fix`.
2218
2680
  choices: [
@@ -2227,7 +2689,7 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2227
2689
  }
2228
2690
  ]
2229
2691
  });
2230
- const behind = status2.filter((s) => s.state === "update available");
2692
+ const behind = unstuck.filter((s) => s.state === "update available" && !untrackable(s));
2231
2693
  if (behind.length > 0)
2232
2694
  issues.push({
2233
2695
  id: `update:${root}`,
@@ -2235,14 +2697,18 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2235
2697
  title: `${where}: ${plural(behind.length, "skill")} out of date`,
2236
2698
  detail: behind.map((s) => `${s.name} (v${s.version} \u2192 v${s.latest})`).join(", "),
2237
2699
  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
- }
2700
+ // It changes what the repo pins and the copies its agents load (often committed): a choice, never `doctor --fix`.
2701
+ choices: [
2702
+ {
2703
+ label: "Update to the newest versions",
2704
+ hint: ["committed", "changed"].includes(gitOf(root)?.of(MANIFEST_FILE) ?? "") ? "git will see the new copies and skilllib.json" : "replaces each copy with your library's newest",
2705
+ run: () => summarize(where, "updated", updateProject(root, behind.map((s) => s.name)))
2706
+ }
2707
+ ]
2242
2708
  });
2243
2709
  const tidy2 = planProjectTidy(root);
2244
2710
  if (tidy2.plans.length) {
2245
- const git3 = gitInfo(root);
2711
+ const git3 = gitOf(root);
2246
2712
  const visible2 = tidy2.plans.flatMap((p) => gitVisibleSteps(p, git3).map((s) => describeStep(s, root)));
2247
2713
  const run = (mode) => () => {
2248
2714
  const r = applyAll(tidy2.plans, mode);
@@ -2253,7 +2719,7 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2253
2719
  id: `tidy:${root}`,
2254
2720
  severity: "suggestion",
2255
2721
  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."),
2722
+ 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
2723
  at: rows(root, tidy2.plans.map((p) => p.name)),
2258
2724
  ...visible2.length ? {
2259
2725
  choices: [
@@ -2265,8 +2731,47 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2265
2731
  });
2266
2732
  }
2267
2733
  issues.push(...tidy2.conflicts.map(conflictIssue));
2268
- const loadsGlobally = (name, id) => globalCopies.some((m) => m.name === name && m.harnesses.includes(id));
2734
+ for (const s of tidy2.skipped.filter((x) => x.differs))
2735
+ issues.push({
2736
+ id: `linkdiffers:${root}:${s.name}`,
2737
+ severity: "problem",
2738
+ title: `${s.name} in ${where}: the copies differ`,
2739
+ 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.`,
2740
+ at: rows(root, [s.name])
2741
+ });
2742
+ const loadsGlobally = (name, id) => {
2743
+ const here = status2.find((s) => s.name === name);
2744
+ return globalCopies.some((m) => m.name === name && m.harnesses.includes(id) && (!here || treeHash(m.path) === treeHash(here.path)));
2745
+ };
2269
2746
  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 }) }));
2747
+ const enabledIds = enabledHarnesses();
2748
+ const notFolders = /* @__PURE__ */ new Set();
2749
+ for (const dir of new Set(enabledIds.map((id) => harness(id).projectDirs[0]))) {
2750
+ const blocker = notWritable(root, dir);
2751
+ if (!blocker || notFolders.has(blocker)) continue;
2752
+ notFolders.add(blocker);
2753
+ const who = agentNames(enabledIds.filter((id) => notWritable(root, harness(id).projectDirs[0]) === blocker));
2754
+ const out2 = linksOut(root, blocker);
2755
+ issues.push({
2756
+ id: `notdir:${root}:${blocker}`,
2757
+ severity: out2 ? "suggestion" : "problem",
2758
+ title: out2 ? `${where}: ${blocker} links out of the repo` : `${where}: ${blocker} isn't a folder`,
2759
+ 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.`,
2760
+ at: [{ root, skill: "" }]
2761
+ // the repo itself: its row in Places
2762
+ });
2763
+ }
2764
+ for (const { s, would } of blindHere) {
2765
+ const way = would.inTheWay.filter((p) => !notFolders.has(p));
2766
+ if (!way.length || blockers.has(s.name)) continue;
2767
+ issues.push({
2768
+ id: `intheway:${root}:${s.name}`,
2769
+ severity: "problem",
2770
+ title: `${s.name} in ${where}: ${agentNames(s.visibility.filter((v) => v.paths === 0).map((v) => v.id))} can't use it here`,
2771
+ detail: `${inTheWaySaid(root, way)}. skilllib won't link past what's there: clear the way, then run skilllib link.`,
2772
+ at: rows(root, [s.name])
2773
+ });
2774
+ }
2270
2775
  for (const { s, would } of blindHere.filter(({ would: w }) => !w.created.length && !w.blocked.length && w.uncommitted.length))
2271
2776
  issues.push({
2272
2777
  id: `uncommitted:${root}:${s.name}`,
@@ -2279,20 +2784,25 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2279
2784
  if (partial.length) {
2280
2785
  const missing2 = [...new Set(partial.flatMap((s) => s.visibility.filter((v) => v.paths === 0).map((v) => v.id)))];
2281
2786
  const tracked = [...new Set(blindHere.flatMap(({ would }) => would.blocked))];
2787
+ const manifestShared = partial.some((s) => s.managed) && ["committed", "changed"].includes(gitOf(root)?.of(MANIFEST_FILE) ?? "");
2282
2788
  const run = (allowTracked) => () => {
2283
2789
  if (allowTracked) allowTrackedLinks(root);
2284
- const r = linkAll(root, { allowTracked });
2790
+ const r = linkAll(root, { allowTracked, names: partial.map((s) => s.name) });
2285
2791
  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}`);
2792
+ if (!r.linked.length) {
2793
+ const still = partial.filter((s) => visibilityOf(root, s.name).some((v) => v.paths === 0 && !loadsGlobally(s.name, v.id)));
2794
+ if (!still.length) return `${where}: already usable by ${agentNames(missing2)}`;
2795
+ throw new Error(`${where}: nothing linked${notes}`);
2796
+ }
2287
2797
  return `${where}: linked ${plural(r.linked.length, "skill")} for ${agentNames(missing2)}${notes}`;
2288
2798
  };
2289
2799
  issues.push({
2290
2800
  id: `usable:${root}`,
2291
2801
  severity: "suggestion",
2292
2802
  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.` : ""}`,
2803
+ 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
2804
  at: rows(root, partial.map((s) => s.name)),
2295
- ...tracked.length ? {
2805
+ ...manifestShared && !tracked.length ? { choices: [{ label: "Add the links", hint: "git will see skilllib.json change", run: run(false) }] } : tracked.length ? {
2296
2806
  choices: [
2297
2807
  // Only when some link goes where git doesn't track: otherwise this one would link nothing.
2298
2808
  ...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 +2812,17 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2302
2812
  });
2303
2813
  }
2304
2814
  const differing = new Set(tidy2.conflicts.map((c) => c.name));
2305
- const git2 = gitInfo(root);
2815
+ const git2 = gitOf(root);
2306
2816
  const committed = (s) => ["committed", "changed"].includes(git2?.of(relativeTo(root, s.path)) ?? "");
2307
- for (const s of status2) {
2817
+ for (const s of unstuck) {
2308
2818
  if (isLink(s.path) || differing.has(s.name)) continue;
2309
- if (s.state === "local only" && committed(s)) {
2819
+ if (s.state === "local only" && (committed(s) || untrackable(s))) {
2820
+ const why = committed(s) ? "it's the team's (committed)" : `${untrackable(s)}, so skilllib can't track it here`;
2310
2821
  issues.push({
2311
2822
  id: `local:${root}:${s.name}`,
2312
2823
  severity: "suggestion",
2313
2824
  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).",
2825
+ detail: `Copy it into the library to reuse it in other projects. The repo's copy stays as it is: ${why}.`,
2315
2826
  at: rows(root, [s.name]),
2316
2827
  choices: [
2317
2828
  {
@@ -2334,7 +2845,8 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2334
2845
  choices: [
2335
2846
  {
2336
2847
  label: "Import into the library",
2337
- hint: "copies it into Your skills",
2848
+ // Tracking it writes skilllib.json.
2849
+ hint: `copies it into Your skills${gitNote(root, [join13(root, MANIFEST_FILE)])}`,
2338
2850
  run: () => {
2339
2851
  intoLibrary(s.path);
2340
2852
  track(root, s.name);
@@ -2343,7 +2855,7 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2343
2855
  }
2344
2856
  ]
2345
2857
  });
2346
- } else if (s.state === "untracked copy of library skill") {
2858
+ } else if (s.state === "untracked copy of library skill" && !committed(s) && !untrackable(s)) {
2347
2859
  issues.push({
2348
2860
  id: `adopt:${root}:${s.name}`,
2349
2861
  severity: "suggestion",
@@ -2353,12 +2865,13 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2353
2865
  choices: [{ label: "Track it", hint: `adds it to ${where}'s skilllib.json`, run: () => `${track(root, s.name).name} tracked in ${where}` }]
2354
2866
  });
2355
2867
  } 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);
2868
+ const teams = s.state.startsWith("repo skill") || committed(s) || !!untrackable(s);
2869
+ const whose = s.state.startsWith("repo skill") || committed(s) ? "It's the team's copy (committed, or in the repo's own .agents/skills)" : `${untrackable(s)}, so skilllib can't track it here`;
2357
2870
  issues.push({
2358
2871
  id: `differs:${root}:${s.name}`,
2359
2872
  severity: "suggestion",
2360
2873
  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.`,
2874
+ 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
2875
  at: rows(root, [s.name]),
2363
2876
  choices: [
2364
2877
  {
@@ -2384,13 +2897,13 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2384
2897
  ]
2385
2898
  ]
2386
2899
  });
2387
- } else if (s.state === "from npx skills") {
2388
- const teams = committed(s);
2900
+ } else if (s.state === "from npx skills" && !((committed(s) || untrackable(s)) && versionForHash(s.name, treeHash(s.path) ?? "") !== null)) {
2901
+ const teams = committed(s) || !!untrackable(s);
2389
2902
  issues.push({
2390
2903
  id: `npx:${root}:${s.name}`,
2391
2904
  severity: "suggestion",
2392
2905
  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`}.`,
2906
+ 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
2907
  at: rows(root, [s.name]),
2395
2908
  choices: [
2396
2909
  {
@@ -2431,12 +2944,21 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2431
2944
  id: `edited:${root}:${s.name}`,
2432
2945
  severity: "suggestion",
2433
2946
  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.`,
2947
+ 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
2948
  at: rows(root, [s.name]),
2436
- choices: [
2949
+ choices: untrackable(s) ? [
2950
+ {
2951
+ label: "Copy the edits into Your skills",
2952
+ 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)`,
2953
+ run: () => {
2954
+ intoLibrary(s.path, true);
2955
+ return `${s.name}: the edits are in Your skills as v${latestVersion(s.name)?.version}; ${where} stays as it is`;
2956
+ }
2957
+ }
2958
+ ] : [
2437
2959
  {
2438
2960
  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`,
2961
+ 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
2962
  run: () => {
2441
2963
  importSkill(s.path, { force: true });
2442
2964
  const c = addSkill(root, s.name);
@@ -2446,7 +2968,7 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2446
2968
  },
2447
2969
  {
2448
2970
  label: "Discard the edits",
2449
- hint: `back to ${pinned}; the edited copy is restorable with skilllib restore`,
2971
+ hint: `back to ${pinned}; the edited copy is restorable with skilllib restore${gitNote(root, [s.path, join13(root, MANIFEST_FILE)])}`,
2450
2972
  run: () => {
2451
2973
  const c = discardEdits(root, s.name);
2452
2974
  if (c.action === "skipped") throw new Error(`${s.name}: ${c.reason}`);
@@ -2465,26 +2987,42 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2465
2987
  if (alsoLoadedGlobally(n) && !copies.some((c) => c.root === root)) alsoGlobal.set(n.name, [...copies, { root, managed: false, nested: true }]);
2466
2988
  }
2467
2989
  }
2990
+ const everyRepo = [...projects2, ...knownProjects()];
2991
+ const nestedMemo = /* @__PURE__ */ new Map();
2992
+ const subsMemo = /* @__PURE__ */ new Map();
2993
+ const subsOf = (root) => subsMemo.get(root) ?? (subsMemo.set(root, packageDirs(root)), subsMemo.get(root));
2994
+ const entriesOf2 = (root) => nestedMemo.get(root) ?? (nestedMemo.set(root, nestedEntries(root, { subs: subsOf(root) })), nestedMemo.get(root));
2468
2995
  for (const [name, copies] of alsoGlobal) {
2469
2996
  const wheres = copies.map((c) => basename6(c.root)).join(", ");
2470
- const removable = copies.filter((c) => c.managed).map((c) => c.root);
2997
+ 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
2998
  const repoOwn = copies.filter((c) => !c.managed).map((c) => basename6(c.root));
2472
2999
  const at = [...copies.flatMap((c) => rows(c.root, [name])), ...rows(null, [name])];
2473
3000
  if (kept.has(name)) {
2474
- const removeWheres = removable.map((r) => basename6(r)).join(", ");
3001
+ const globalLoaders = new Set(globalCopies.filter((m) => m.name === name).flatMap((m) => m.harnesses));
3002
+ const onlyHere = (root) => visibilityOf(root, name).filter((v) => v.paths > 0 && !globalLoaders.has(v.id)).map((v) => harness(v.id).name);
3003
+ const needed = removable.filter((root) => onlyHere(root).length);
3004
+ const extra = removable.filter((root) => !onlyHere(root).length);
3005
+ const removeWheres = extra.map((r) => basename6(r)).join(", ");
2475
3006
  issues.push({
2476
3007
  id: `twice:${name}`,
2477
3008
  severity: "suggestion",
2478
3009
  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.` : ""}`,
3010
+ detail: [
3011
+ extra.length ? `The global copy already loads in ${removeWheres} for every agent that reads the project copy, so those are extra.` : "",
3012
+ 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.` : "",
3013
+ 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.` : ""
3014
+ ].filter(Boolean).join(" "),
2480
3015
  at,
2481
- choices: removable.length ? [
3016
+ choices: extra.length ? [
2482
3017
  {
2483
3018
  label: `Remove the copies in ${removeWheres}`,
2484
- hint: "the global copy stays",
3019
+ // Removing a managed copy edits its skilllib.json too.
3020
+ 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
3021
  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?)`);
3022
+ const results = extra.map((root) => [root, removeSkill(root, name)]);
3023
+ const removed = results.filter(([, c]) => c.action === "removed").map(([r]) => basename6(r));
3024
+ const kept2 = results.filter(([, c]) => c.action !== "removed").map(([r, c]) => `${basename6(r)} (${c.reason})`);
3025
+ if (kept2.length) throw new Error(`${name}: ${removed.length ? `removed from ${removed.join(", ")}; ` : ""}kept in ${kept2.join(", ")}`);
2488
3026
  return `${name}: removed from ${removed.join(", ")}; the global copy stays`;
2489
3027
  }
2490
3028
  }
@@ -2492,26 +3030,31 @@ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, load
2492
3030
  });
2493
3031
  continue;
2494
3032
  }
2495
- const movable = machine.find((m) => m.name === name && m.movable);
3033
+ const movable = machine.find((m) => m.name === name && m.movable && !m.broken);
3034
+ const doomed = machine.filter((x) => x.name === name && x.movable);
3035
+ const differ = copiesDiffer(doomed.map((m) => m.path));
3036
+ const libraryDiffers = !!movable && libraryNames.has(name) && versionForHash(name, treeHash(movable.path) ?? "") === null;
3037
+ const blind = movable && !differ && !libraryDiffers ? blindWithoutGlobal(name, doomed, everyRepo, entriesOf2, subsOf) : [];
2496
3038
  issues.push({
2497
3039
  at,
2498
3040
  id: `twice:${name}`,
2499
3041
  severity: "suggestion",
2500
3042
  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 ? [
3043
+ 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)."}`,
3044
+ choices: movable && !differ && !libraryDiffers && !blind.length ? [
2503
3045
  {
2504
3046
  label: "Stop loading it globally",
2505
3047
  hint: "restorable with skilllib restore",
2506
3048
  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)`);
3049
+ const now = blindWithoutGlobal(name, doomed, everyRepo);
3050
+ if (now.length) throw new Error(`${name}: ${blindSaid(name, now)}`);
2509
3051
  if (!libraryNames.has(name)) importSkill(movable.path);
3052
+ const left = strandedLinks(name, doomed, everyRepo);
2510
3053
  for (const m of machine.filter((x) => x.name === name && x.movable)) {
2511
3054
  const r = unloadGlobal(m.path, m.links);
2512
3055
  if (!r.ok) throw new Error(`${name}: ${r.reason}`);
2513
3056
  }
2514
- return `${name} no longer loads globally`;
3057
+ 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
3058
  }
2516
3059
  }
2517
3060
  ] : void 0
@@ -2548,6 +3091,7 @@ function addedAt(path) {
2548
3091
  }
2549
3092
  }
2550
3093
  function usageIssues(projects2, machine, libraryNames, uses, statusOf = projectStatus) {
3094
+ gitMemo = /* @__PURE__ */ new Map();
2551
3095
  if (!enabledHarnesses().includes("claude-code")) return [];
2552
3096
  const issues = [];
2553
3097
  const since = Date.now() - uses.days * 24 * 60 * 60 * 1e3;
@@ -2555,23 +3099,23 @@ function usageIssues(projects2, machine, libraryNames, uses, statusOf = projectS
2555
3099
  const where = basename6(root);
2556
3100
  const unused = statusOf(root).filter((s) => s.state !== "folder missing" && uses.inProject(root, s.name) === 0 && addedAt(s.path) <= since);
2557
3101
  if (!unused.length) continue;
2558
- const removable = unused.filter((s) => s.managed);
3102
+ const removable = unused.filter((s) => removesClean(root, s));
2559
3103
  issues.push({
2560
3104
  id: `unused:${root}`,
2561
3105
  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." : ""}`,
3106
+ title: `${where}: ${plural(unused.length, "skill")} unused in ${plural(uses.days, "day")}`,
3107
+ 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
3108
  at: rows(root, unused.map((s) => s.name)),
2565
3109
  choices: removable.length ? [
2566
3110
  {
2567
3111
  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",
3112
+ hint: `Your skills keeps them; edited ones stay${gitNote(root, [...removable.map((s) => s.path), join13(root, MANIFEST_FILE)])}`,
2569
3113
  run: () => {
2570
3114
  const changes = removable.map((s) => removeSkill(root, s.name));
2571
3115
  const removed = changes.filter((c) => c.action === "removed").map((c) => c.name);
2572
3116
  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)` : ""}`;
3117
+ 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"})`);
3118
+ return `${where}: removed ${removed.join(", ")}`;
2575
3119
  }
2576
3120
  }
2577
3121
  ] : void 0
@@ -2584,11 +3128,11 @@ function usageIssues(projects2, machine, libraryNames, uses, statusOf = projectS
2584
3128
  issues.push({
2585
3129
  id: `plugin-unused:${installId(p)}`,
2586
3130
  severity: "suggestion",
2587
- title: `${p.id}${installedIn(p)}: its skills weren't used in ${uses.days} days`,
3131
+ title: `${p.id}${installedIn(p)}: its skills weren't used in ${plural(uses.days, "day")}`,
2588
3132
  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
3133
  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)) }]
3134
+ { 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)) },
3135
+ ...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
3136
  ],
2593
3137
  plugin: p.id,
2594
3138
  pluginAt: p.projectPath ?? null
@@ -2603,7 +3147,7 @@ function usageIssues(projects2, machine, libraryNames, uses, statusOf = projectS
2603
3147
  issues.push({
2604
3148
  id: "unused:library",
2605
3149
  severity: "suggestion",
2606
- title: `${plural(idle.length, "skill")} in Your skills: in no repo, unused in ${uses.days} days`,
3150
+ title: `${plural(idle.length, "skill")} in Your skills: in no repo, unused in ${plural(uses.days, "day")}`,
2607
3151
  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
3152
  // Your skills' rows (none loads globally, so none is in Global).
2609
3153
  at: rows(null, idle),
@@ -2636,13 +3180,12 @@ function groupBy(items) {
2636
3180
  }
2637
3181
  var sameSet = (a, b) => a.length === b.length && a.every((x) => b.includes(x));
2638
3182
  function globalFrom(kind, origin, path) {
2639
- if (kind === "global" || kind === "skilllib") return tildify(path.replace(/[\\/][^\\/]+$/, "")).replace(/\\/g, "/");
3183
+ if (kind === "global" || kind === "skilllib") return tildify(path.replace(/[\\/][^\\/]+$/, ""));
2640
3184
  return origin;
2641
3185
  }
2642
- var SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "target", "vendor", "coverage"]);
2643
3186
  function topLevel(root) {
2644
3187
  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);
3188
+ 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
3189
  } catch {
2647
3190
  return [];
2648
3191
  }
@@ -2664,7 +3207,7 @@ function usableHere(root, uses = /* @__PURE__ */ new Map()) {
2664
3207
  const usual = s.state === "ok" || s.state === "local only" || repo && s.state !== "from npx skills";
2665
3208
  const attrs = {
2666
3209
  source: s.managed ? "library" : repo ? "repo" : "local",
2667
- dir: s.location !== ".claude/skills" ? s.location : void 0,
3210
+ dir: s.location !== PROJECT_SKILLS_DIR ? s.location : void 0,
2668
3211
  version: s.version ?? void 0,
2669
3212
  state: usual ? void 0 : s.state,
2670
3213
  latest: behind ? s.latest : void 0,
@@ -2674,7 +3217,14 @@ function usableHere(root, uses = /* @__PURE__ */ new Map()) {
2674
3217
  return [JSON.stringify(attrs), s.name, () => ({ ...attrs, skills: [] })];
2675
3218
  })
2676
3219
  );
2677
- const skills = group(inRepo ? projectStatus(root) : []);
3220
+ const own = (() => {
3221
+ try {
3222
+ return inRepo ? projectStatus(root) : [];
3223
+ } catch {
3224
+ return [];
3225
+ }
3226
+ })();
3227
+ const skills = group(own);
2678
3228
  const nested = group(nestedHere);
2679
3229
  const machine = machineSkills(agents);
2680
3230
  const loaded = inRepo ? skillsLoadedIn(root, machine, agents) : machine;
@@ -2688,7 +3238,7 @@ function usableHere(root, uses = /* @__PURE__ */ new Map()) {
2688
3238
  for (const i of statusIssues(root, machine)) {
2689
3239
  const choices = i.choices?.map((c) => c.label);
2690
3240
  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 };
3241
+ const issue = issues.get(key) ?? { problems: [], ids: [], detail: detailWithGit(i), fix: i.fix?.label, choices };
2692
3242
  issue.problems.push(i.title);
2693
3243
  issue.ids.push(i.id);
2694
3244
  issues.set(key, issue);
@@ -2807,29 +3357,41 @@ function usesFromTranscriptLine(line) {
2807
3357
  function transcriptFiles(sinceMs) {
2808
3358
  const base = join14(claudeDir(), "projects");
2809
3359
  if (!existsSync13(base)) return [];
2810
- return readdirSync9(base).flatMap((project) => {
3360
+ const list2 = (dir) => {
3361
+ try {
3362
+ return readdirSync9(dir);
3363
+ } catch {
3364
+ return [];
3365
+ }
3366
+ };
3367
+ return list2(base).flatMap((project) => {
2811
3368
  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);
3369
+ if (!statSync5(dir, { throwIfNoEntry: false })?.isDirectory()) return [];
3370
+ return list2(dir).filter((f) => f.endsWith(".jsonl")).map((f) => join14(dir, f)).filter((f) => (statSync5(f, { throwIfNoEntry: false })?.mtimeMs ?? 0) >= sinceMs);
2814
3371
  });
2815
3372
  }
2816
3373
  async function scanUsage(days) {
2817
3374
  const sinceMs = Date.now() - days * 24 * 60 * 60 * 1e3;
2818
3375
  const seen = /* @__PURE__ */ new Map();
2819
3376
  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);
3377
+ try {
3378
+ const lines = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
3379
+ for await (const line of lines) {
3380
+ for (const use of usesFromTranscriptLine(line)) {
3381
+ if (Date.parse(use.at) >= sinceMs) seen.set(use.key, use);
3382
+ }
2824
3383
  }
3384
+ } catch {
3385
+ continue;
2825
3386
  }
2826
3387
  }
2827
3388
  return [...seen.values()];
2828
3389
  }
3390
+ var skillOf = (key) => key.split(":").pop();
2829
3391
  function summarize2(uses, projectOf = () => null) {
2830
3392
  const bySkill = /* @__PURE__ */ new Map();
2831
3393
  for (const use of uses) {
2832
- const skill = use.skill.split(":").pop();
3394
+ const skill = skillOf(use.skill);
2833
3395
  const summary = bySkill.get(skill) ?? { skill, uses: 0, lastUsed: "", projects: /* @__PURE__ */ new Set() };
2834
3396
  summary.uses += 1;
2835
3397
  if (use.at > summary.lastUsed) summary.lastUsed = use.at;
@@ -2845,7 +3407,8 @@ function usesByProject(uses, projectOf) {
2845
3407
  const root = projectOf(use.cwd);
2846
3408
  if (!root) continue;
2847
3409
  const counts = byProject.get(root) ?? /* @__PURE__ */ new Map();
2848
- counts.set(use.skill, (counts.get(use.skill) ?? 0) + 1);
3410
+ const skill = skillOf(use.skill);
3411
+ counts.set(skill, (counts.get(skill) ?? 0) + 1);
2849
3412
  byProject.set(root, counts);
2850
3413
  }
2851
3414
  return byProject;
@@ -2857,28 +3420,39 @@ function moveGlobalSkills(skills, roots, {
2857
3420
  group,
2858
3421
  allowTracked = false,
2859
3422
  label = basename8,
2860
- allowBlind = false
3423
+ allowTrackedHint = "re-run with --allow-tracked"
2861
3424
  } = {}) {
2862
3425
  const byName = /* @__PURE__ */ new Map();
2863
3426
  for (const m of skills) byName.set(m.name, [...byName.get(m.name) ?? [], m]);
2864
3427
  return [...byName].map(([name, copies]) => {
2865
- if (new Set(copies.map((m) => treeHash(m.path))).size > 1)
3428
+ if (copiesDiffer(copies.map((m) => m.path)))
2866
3429
  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")
3430
+ const elsewhere = blindWithoutGlobal(name, copies, knownProjects().filter((p) => !roots.some((r) => realPath(r) === realPath(p))));
3431
+ if (elsewhere.length) return { name, moved: false, reason: `nothing moved: ${blindSaid(name, elsewhere, label)}`, changes: [] };
3432
+ const hash = treeHash(copies[0].path) ?? "";
3433
+ const cant = roots.flatMap((root) => ((b) => b ? [`${label(root)} (${b.replace(/ \(use --force to replace (it|the link)\)$/, "")})`] : [])(trackBlocker(root, name) ?? addRefusal(root, name, hash)));
3434
+ if (cant.length) return { name, moved: false, reason: `nothing moved: it can't be added to ${cant.join(", ")}`, changes: [] };
3435
+ const imported = importSkill(copies[0].path).status;
3436
+ if (imported === "exists")
2868
3437
  return { name, moved: false, reason: `your library has a different ${name}; nothing moved (update your library from this copy first)`, changes: [] };
2869
3438
  if (group) recordOrigin(name, `group: ${group}`);
2870
3439
  const changes = roots.map((root) => [root, addSkill(root, name, { allowTracked })]);
2871
3440
  const skipped = changes.filter(([, c]) => c.action === "skipped");
2872
3441
  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 };
3442
+ const kept = imported === "added" ? `; it's in Your skills now` : "";
3443
+ 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 };
3444
+ const blindIn = changes.flatMap(([root, c]) => ((b) => b.length ? [{ root, c, b }] : [])(blindWithoutGlobal(name, copies, [root])));
3445
+ if (blindIn.length) {
3446
+ const per = blindIn.map(({ root, c, b }) => {
3447
+ const who = b.map((x) => `${harness(x.id).name}${realPath(x.root) === realPath(root) ? "" : ` in ${label(x.root)}`}`).join(", ");
3448
+ 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);
3449
+ return `${who} ${b.every((x) => realPath(x.root) === realPath(root)) ? `in ${label(root)} ` : `(${label(root)}) `}couldn't see it (${next})`;
3450
+ });
3451
+ return { name, moved: false, reason: `added, but ${per.join("; ")}, so it still loads globally`, changes };
2879
3452
  }
3453
+ const left = strandedLinks(name, copies, [...roots, ...knownProjects()], label);
2880
3454
  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 };
3455
+ return failed && !failed.ok ? { name, moved: false, reason: failed.reason, changes } : { name, moved: true, changes, ...left.length ? { left } : {} };
2882
3456
  });
2883
3457
  }
2884
3458
 
@@ -2925,26 +3499,28 @@ function colorState(state) {
2925
3499
  function withDirs(root, changes) {
2926
3500
  const { skills } = readManifest(root);
2927
3501
  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;
3502
+ const dep = c.action !== "removed" && c.action !== "skipped" ? skills[c.name] : void 0;
3503
+ return dep ? { ...c, dirs: [dep.dir ?? PROJECT_SKILLS_DIR, ...(dep.links ?? []).filter((d) => !c.inTheWay?.includes(`${d}/${c.name}`))] } : c;
2930
3504
  });
2931
3505
  }
2932
- var incomplete = (c) => c.action === "skipped" || !!c.blocked?.length || !!c.uncommitted?.length;
2933
- function warnHeldLinks(c, where = "here") {
3506
+ var incomplete = (c) => c.action === "skipped" || !!c.blocked?.length || !!c.uncommitted?.length || !!c.inTheWay?.length;
3507
+ function warnHeldLinks(root, c, where = "here") {
2934
3508
  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
3509
  if (c.uncommitted?.length)
2936
3510
  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.`);
3511
+ if (c.inTheWay?.length) warn(`${c.name}: not linked everywhere ${where}: ${inTheWaySaid(root, c.inTheWay)}. Clear the way, then run skilllib link.`);
2937
3512
  }
2938
3513
  function printChanges(root, changes, args) {
2939
3514
  if (changes.some(incomplete)) process.exitCode = 1;
2940
3515
  if (args.json) return json("changes", withDirs(root, changes));
2941
3516
  for (const c of changes) {
2942
- warnHeldLinks(c);
3517
+ warnHeldLinks(root, c);
2943
3518
  if (c.action === "skipped") error(`${c.name}: ${c.reason}`);
2944
3519
  else {
2945
- const icon = c.action === "removed" ? red("-") : c.action === "reset" ? yellow("\u21BA") : green(c.action === "installed" ? "+" : c.action === "relinked" ? "\u21E2" : "\u2191");
3520
+ 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
3521
  const versions = c.action === "removed" ? "" : c.from && c.from !== c.to ? ` v${c.from} \u2192 v${c.to}` : c.to ? ` v${c.to}` : "";
2947
3522
  info(` ${icon} ${c.name}${versions} ${dim(c.action)}`);
3523
+ if (c.reason) info(dim(` ${c.reason}`));
2948
3524
  }
2949
3525
  if (c.backedUp) info(dim(` local edits saved to ${tildify(c.backedUp)}; \`skilllib restore ${c.name}\` brings them back`));
2950
3526
  }
@@ -2957,9 +3533,9 @@ function projectOfFactory(projects2) {
2957
3533
  const sorted = [...projects2].sort((a, b) => b.length - a.length);
2958
3534
  return (cwd) => {
2959
3535
  if (!cwd) return null;
2960
- const known = sorted.find((p) => cwd === p || cwd.startsWith(p + sep6));
3536
+ const known = sorted.find((p) => cwd === p || cwd.startsWith(p + sep8));
2961
3537
  if (known) return known;
2962
- const repo = cwd.split(`${sep6}.claude${sep6}worktrees${sep6}`)[0];
3538
+ const repo = cwd.split(`${sep8}.claude${sep8}worktrees${sep8}`)[0];
2963
3539
  return isProjectCandidate(repo) ? repo : null;
2964
3540
  };
2965
3541
  }
@@ -2983,6 +3559,8 @@ function requireProject() {
2983
3559
  const root = projectHere();
2984
3560
  if (root) return root;
2985
3561
  if (!isProjectCandidate(findProjectRoot())) {
3562
+ const top = findProjectRoot();
3563
+ 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
3564
  const stray = existsSync14(join15(userHome(), MANIFEST_FILE)) ? `, though ${tildify(join15(userHome(), MANIFEST_FILE))} makes it look like one` : "";
2987
3565
  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
3566
  }
@@ -2996,7 +3574,10 @@ async function status(args) {
2996
3574
  const root2 = findProjectRoot();
2997
3575
  const usage3 = await usageBySkill(args.days, root2);
2998
3576
  json("status", usableHere(root2, new Map([...usage3].map(([name, u]) => [name, u.uses]))));
2999
- if (projectHere()) rememberProjects([root2]);
3577
+ try {
3578
+ if (projectHere()) rememberProjects([root2]);
3579
+ } catch {
3580
+ }
3000
3581
  return;
3001
3582
  }
3002
3583
  const root = requireProject();
@@ -3035,17 +3616,19 @@ async function status(args) {
3035
3616
  }))
3036
3617
  );
3037
3618
  }
3038
- const states = new Set(skills.map((s) => s.state));
3619
+ const states = new Set(skills.map((s) => teamState(s)));
3039
3620
  const hints = [];
3040
3621
  if (states.has("update available")) hints.push("`skilllib update` to install the newest versions");
3041
3622
  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");
3623
+ if (missing.some((s) => s.latest !== null && !syncBlocker(root, s.name))) hints.push("`skilllib sync` to restore missing skills");
3624
+ if (missing.some((s) => syncBlocker(root, s.name))) hints.push("`skilllib doctor` says what's in sync's way");
3043
3625
  const notInLibrary = missing.filter((s) => s.latest === null).map((s) => s.name);
3044
3626
  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");
3627
+ 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"}`);
3628
+ const at = (s) => relativeTo(root, s.path);
3629
+ for (const s of skills.filter((x) => x.state === "edited locally" || x.state === "edited locally, update available"))
3630
+ hints.push(`\`skilllib import ${at(s)} --force\` to save ${s.name}'s local edits to the library`);
3631
+ 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
3632
  if (states.has("untracked copy of library skill") || states.has("untracked, differs from library"))
3050
3633
  hints.push("`skilllib add <name>` to manage an untracked skill (--force if it differs)");
3051
3634
  if (hints.length > 0) info("\n" + hints.map((h) => dim(`\u2192 ${h}`)).join("\n"));
@@ -3084,14 +3667,14 @@ async function list(args) {
3084
3667
  skills.map((s) => ({
3085
3668
  Skill: s.name,
3086
3669
  Description: truncate(s.description, 50),
3087
- From: dim(truncate(origins[s.name] ?? "", 30)),
3670
+ From: dim(truncate(tildify(origins[s.name] ?? ""), 30)),
3088
3671
  Projects: (installs.get(s.name) ?? []).join(", ") || dim("none"),
3089
3672
  [`Uses (${args.days}d)`]: String(usage2.get(s.name)?.uses ?? 0)
3090
3673
  }))
3091
3674
  );
3092
3675
  }
3093
3676
  const libraryNames = new Set(skills.map((s) => s.name));
3094
- const globals = [...new Set(yourGlobals().map((m) => m.name))];
3677
+ const globals = [...new Set(yourGlobals().filter((m) => m.harnesses.length).map((m) => m.name))];
3095
3678
  if (globals.length > 0) {
3096
3679
  info("");
3097
3680
  warn(`${globals.length} of your global ${globals.length === 1 ? "skill still loads" : "skills still load"} in every project`);
@@ -3139,13 +3722,14 @@ function projects(args) {
3139
3722
  table(
3140
3723
  roots.map((root) => {
3141
3724
  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;
3725
+ const teamState = teamStateIn(root);
3726
+ const usual = /* @__PURE__ */ new Set(["ok", "local only", "repo skill", "repo skill, in library", "from npx skills"]);
3727
+ const attention = skills.filter((s) => !usual.has(teamState(s))).length;
3144
3728
  return {
3145
3729
  Project: basename9(root),
3146
3730
  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),
3731
+ Skills: skills.map((s) => s.name).join(", ") || dim("none"),
3732
+ "Local only": String(skills.filter((s) => teamState(s) === "local only").length),
3149
3733
  Status: attention === 0 ? green("ok") : yellow(`${attention} need attention`)
3150
3734
  };
3151
3735
  })
@@ -3156,8 +3740,7 @@ function add(args) {
3156
3740
  throw new Error("Usage: skilllib add <skill-name>... [--force]");
3157
3741
  }
3158
3742
  const root = requireProject();
3159
- if (args.allowTracked) allowTrackedLinks(root);
3160
- printChanges(root, args.positional.map((name) => addSkill(root, name, { force: args.force })), args);
3743
+ printChanges(root, args.positional.map((name) => addSkill(root, name, { force: args.force, allowTracked: args.allowTracked })), args);
3161
3744
  rememberProjects([root]);
3162
3745
  }
3163
3746
  function remove(args) {
@@ -3170,7 +3753,6 @@ function remove(args) {
3170
3753
  function changeEach(args, change, nothing) {
3171
3754
  const roots = projectsFor(args);
3172
3755
  const results = roots.map((root) => {
3173
- if (args.allowTracked) allowTrackedLinks(root);
3174
3756
  const changes = change(root);
3175
3757
  if (changes.some(incomplete)) process.exitCode = 1;
3176
3758
  if (!args.json) {
@@ -3197,7 +3779,7 @@ function update(args) {
3197
3779
  changeEach(
3198
3780
  args,
3199
3781
  (root) => {
3200
- const changes = updateProject(root, names, { force: args.force });
3782
+ const changes = updateProject(root, names, { force: args.force, allowTracked: args.allowTracked });
3201
3783
  if (!args.all) {
3202
3784
  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
3785
  }
@@ -3220,7 +3802,7 @@ function outdated(args) {
3220
3802
  }
3221
3803
  function importSkills(args) {
3222
3804
  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));
3805
+ const dirs = args.global ? yourGlobals().map((m) => m.path) : args.positional.map((p) => resolve8(p));
3224
3806
  if (dirs.length === 0) {
3225
3807
  throw new Error("Usage: skilllib import <skill-dir>... [--force] | skilllib import --global");
3226
3808
  }
@@ -3240,7 +3822,7 @@ function importSkills(args) {
3240
3822
  continue;
3241
3823
  }
3242
3824
  success(`${name} ${dim(result === "unchanged" ? "already in library" : result === "added" ? "added to library" : "library updated")}`);
3243
- const root = resolve7(dir, "..", "..", "..");
3825
+ const root = resolve8(dir, "..", "..", "..");
3244
3826
  const dep = projectHere(root) === root ? readManifest(root).skills[name] : void 0;
3245
3827
  if (dep && realpathOrNull(join15(root, dep.dir ?? PROJECT_SKILLS_DIR, name)) === realpathOrNull(dir) && dep.hash !== treeHash(dir)) {
3246
3828
  const change = addSkill(root, name);
@@ -3259,7 +3841,8 @@ function scan(args) {
3259
3841
  const found = discoverProjects();
3260
3842
  const library = new Set(librarySkills().map((s) => s.name));
3261
3843
  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)])
3844
+ // Not a folder that links out of the repo: what's in it isn't the repo's (as in status).
3845
+ .../* @__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
3846
  ];
3264
3847
  const withSkills = found.filter((root) => skillNames(root).length > 0);
3265
3848
  info(`Scanned ${roots.map((r) => tildify(r)).join(", ")}: ${found.length} project${found.length === 1 ? "" : "s"}, ${withSkills.length} with skills
@@ -3277,47 +3860,42 @@ ${yellow("Yellow")} skills aren't in your library yet (\`skilllib import <skill-
3277
3860
  function folders(args) {
3278
3861
  const [action, ...paths] = args.positional;
3279
3862
  if (action && !["add", "remove", "rm"].includes(action)) throw new Error("Usage: skilllib folders [add|remove <folder>...]");
3280
- let failed = false;
3863
+ const failures = [];
3281
3864
  if (action === "add") {
3282
3865
  for (const p of paths) {
3283
3866
  try {
3284
3867
  success(`Added ${tildify(addRoot(p))}`);
3285
3868
  } catch (e) {
3286
- error(e.message);
3287
- failed = true;
3869
+ failures.push(e.message);
3288
3870
  }
3289
3871
  }
3290
3872
  discoverProjects();
3291
3873
  } else if (action === "remove" || action === "rm") {
3292
3874
  for (const p of paths) {
3293
3875
  if (removeRoot(p)) success(`Removed ${tildify(expandHome(p))}`);
3294
- else {
3295
- warn(`${tildify(expandHome(p))} wasn't a project folder`);
3296
- failed = true;
3297
- }
3876
+ else failures.push(`${tildify(expandHome(p))} wasn't a project folder`);
3298
3877
  }
3299
3878
  }
3300
3879
  const { roots, hidden } = readConfig();
3301
3880
  info(roots.length ? `Project folders:
3302
3881
  ${roots.map((r) => ` ${tildify(r)}`).join("\n")}` : "No project folders. Add one: skilllib folders add ~/Projects");
3303
3882
  if (hidden.length) info(dim(`Hidden projects: ${hidden.map((h) => tildify(h)).join(", ")} (skilllib unhide <path>)`));
3304
- if (failed) process.exitCode = 1;
3883
+ if (failures.length) throw new Error(failures.join("; "));
3305
3884
  }
3306
3885
  function unhide(args) {
3307
3886
  if (args.positional.length === 0) {
3308
3887
  throw new Error("Usage: skilllib unhide <path>...");
3309
3888
  }
3310
- let failed = false;
3889
+ const failures = [];
3311
3890
  for (const p of args.positional) {
3312
3891
  const path = expandHome(p);
3313
3892
  if (setHidden(path, false)) success(`${tildify(path)} is visible again`);
3314
3893
  else {
3315
3894
  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;
3895
+ failures.push(`${tildify(path)} wasn't hidden. ${hidden.length ? `Hidden projects: ${hidden.map((h) => tildify(h)).join(", ")}` : "No projects are hidden."}`);
3318
3896
  }
3319
3897
  }
3320
- if (failed) process.exitCode = 1;
3898
+ if (failures.length) throw new Error(failures.join("; "));
3321
3899
  }
3322
3900
  function newSkill(args) {
3323
3901
  const [name, ...rest] = args.positional;
@@ -3341,16 +3919,13 @@ function restore(args) {
3341
3919
  }
3342
3920
  const newest = backups.find((b) => b.name === name);
3343
3921
  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) {
3922
+ const failures = [];
3923
+ for (const backup of movedTogether(backups, newest)) {
3347
3924
  const r = backup.kind === "plugin" ? restorePlugin(backup) : restoreBackup(backup);
3348
3925
  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
- }
3926
+ else failures.push(r.reason);
3353
3927
  }
3928
+ if (failures.length) throw new Error(`Can't restore ${name}: ${failures.join("; ")}`);
3354
3929
  }
3355
3930
  async function allIssues(args, withUsage = true) {
3356
3931
  const projects2 = visibleProjects();
@@ -3390,7 +3965,7 @@ async function doctor(args) {
3390
3965
  id,
3391
3966
  severity,
3392
3967
  title,
3393
- detail,
3968
+ detail: detailWithGit({ detail, fix, choices }),
3394
3969
  fix: fix?.label,
3395
3970
  choices: choices?.map((c) => c.label),
3396
3971
  fixed: args.fix && fix ? fixed(fix) : void 0
@@ -3402,7 +3977,8 @@ async function doctor(args) {
3402
3977
  for (const issue of issues) {
3403
3978
  info(`${issue.severity === "problem" ? red("\u25CF") : yellow("\u25C6")} ${issue.title}`);
3404
3979
  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>`));
3980
+ const spans = new Set(issue.at?.map((a) => a.root)).size > 1;
3981
+ 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
3982
  if (args.fix && issue.fix) {
3407
3983
  const r = fixed(issue.fix);
3408
3984
  info(` ${r.ok ? green("\u2713") : red("\u2717")} ${r.message}`);
@@ -3420,7 +3996,7 @@ async function apply(args) {
3420
3996
  const byLabel = pick === void 0 ? void 0 : found.find((i) => optionsOf(i).some((c) => c.label.toLowerCase() === pick.toLowerCase()));
3421
3997
  if (!byLabel && found.some((i) => labels(i) !== labels(found[0]))) {
3422
3998
  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(" ")}`);
3999
+ 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
4000
  }
3425
4001
  const issue = byLabel ?? found[0];
3426
4002
  const options = optionsOf(issue);
@@ -3481,14 +4057,14 @@ function show(args) {
3481
4057
  path: dir,
3482
4058
  latest: latestVersion(name)?.version ?? null,
3483
4059
  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")
4060
+ projects: projectsUsing(name).map((p) => basename9(p)),
4061
+ skillMd: readFileSync7(join15(dir, "SKILL.md"), "utf-8")
3486
4062
  });
3487
4063
  }
3488
4064
  info(`${name} ${dim(tildify(dir))}`);
3489
4065
  if (description) info(`
3490
4066
  ${description}`);
3491
- const using = knownProjects().filter((p) => name in pinsIn(p));
4067
+ const using = projectsUsing(name);
3492
4068
  info(`
3493
4069
  Installed in: ${using.map((p) => tildify(p)).join(", ") || "no projects"}`);
3494
4070
  }
@@ -3512,25 +4088,33 @@ New installs go to ${installDirs(enabled).join(" (real copy) + ")}${installDirs(
3512
4088
  function link(args) {
3513
4089
  const roots = projectsFor(args);
3514
4090
  for (const root of roots) {
3515
- if (args.allowTracked) allowTrackedLinks(root);
3516
- const res = linkAll(root);
4091
+ const res = linkAll(root, { allowTracked: args.allowTracked });
3517
4092
  const label = args.all ? `${basename9(root)}: ` : "";
3518
4093
  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");
4094
+ else if (!args.all && !res.blocked.length && !res.uncommitted.length && !res.inTheWay.length) {
4095
+ const missing = projectStatus(root).filter((s) => s.state === "folder missing").map((s) => s.name);
4096
+ 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)`);
4097
+ else success("Every skill here is already usable by all your agents");
4098
+ }
4099
+ if (res.inTheWay.length) {
4100
+ process.exitCode = 1;
4101
+ error(`${label}some skills weren't linked everywhere: ${inTheWaySaid(root, res.inTheWay)}. Clear the way, then link again`);
4102
+ }
3520
4103
  if (res.blocked.length) {
3521
4104
  process.exitCode = 1;
3522
4105
  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
4106
  }
3524
- if (res.uncommitted.length) process.exitCode = 1;
3525
- if (res.uncommitted.length)
4107
+ if (res.uncommitted.length) {
4108
+ process.exitCode = 1;
3526
4109
  warn(
3527
4110
  `${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
4111
  );
4112
+ }
3529
4113
  }
3530
4114
  }
3531
4115
  function globalCommand(args) {
3532
4116
  const [action, ...names] = args.positional;
3533
- const yours = yourGlobals();
4117
+ const yours = yourGlobals().filter((m) => m.harnesses.length);
3534
4118
  if (action === "move") return moveGlobal(args, names, yours);
3535
4119
  if (action === "keep" || action === "unkeep") {
3536
4120
  const unknown = names.filter((n) => !yours.some((m) => m.name === n));
@@ -3572,10 +4156,11 @@ function moveGlobal(args, names, yours) {
3572
4156
  if (args.json) {
3573
4157
  return json(
3574
4158
  "global move",
3575
- moves.map(({ name, moved, reason, changes }) => ({
4159
+ moves.map(({ name, moved, reason, changes, left }) => ({
3576
4160
  skill: name,
3577
4161
  moved,
3578
4162
  ...reason ? { reason } : {},
4163
+ ...left ? { left } : {},
3579
4164
  projects: changes.map(([root, c]) => ({ project: basename9(root), path: root, change: withDirs(root, [c])[0] }))
3580
4165
  }))
3581
4166
  );
@@ -3583,13 +4168,14 @@ function moveGlobal(args, names, yours) {
3583
4168
  for (const m of moves) {
3584
4169
  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
4170
  else error(`${m.name}: ${m.reason}`);
3586
- for (const [root, c] of m.changes) warnHeldLinks(c, `in ${basename9(root)}`);
4171
+ for (const [root, c] of m.changes) warnHeldLinks(root, c, `in ${basename9(root)}`);
4172
+ 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
4173
  }
3588
4174
  if (moves.some((m) => m.moved)) info(dim("Commit skilllib.json and the skill folders in each repo."));
3589
4175
  }
3590
4176
  function tidy(args) {
3591
4177
  const names = args.positional;
3592
- const keepFor = (base) => args.keep ? Object.fromEntries(names.map((n) => [n, base ? resolve7(base, args.keep) : expandHome(args.keep)])) : {};
4178
+ const keepFor = (base) => args.keep ? Object.fromEntries(names.map((n) => [n, base ? resolve8(base, args.keep) : expandHome(args.keep)])) : {};
3593
4179
  if (args.keep && !names.length) {
3594
4180
  throw new Error("Usage: skilllib tidy <name>... --keep <folder>");
3595
4181
  }
@@ -3621,7 +4207,9 @@ function tidy(args) {
3621
4207
  for (const { label, report } of reports) {
3622
4208
  const plans = report.plans.filter((p) => !names.length || names.includes(p.name));
3623
4209
  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)`);
4210
+ const skippedNamed = names.length ? report.skipped.filter((x) => names.includes(x.name) && x.differs) : [];
4211
+ if (skippedNamed.length) process.exitCode = 1;
4212
+ if (!plans.length && !conflicts.length && !skippedNamed.length && !args.all) success(`${label}: nothing to tidy (no extra copies or links)`);
3625
4213
  for (const plan of plans) {
3626
4214
  const wouldHold = args.allowGit ? [] : gitVisibleSteps(plan);
3627
4215
  const result = args.dryRun ? { applied: plan.steps.filter((s) => !wouldHold.includes(s)), held: wouldHold } : applyTidy(plan, { git: args.allowGit ? "go" : "keep" });
@@ -3630,21 +4218,24 @@ function tidy(args) {
3630
4218
  held += result.held.length;
3631
4219
  changed += result.applied.length;
3632
4220
  }
3633
- if (args.keep && conflicts.length) process.exitCode = 1;
4221
+ if (conflicts.length && (args.keep || names.length)) process.exitCode = 1;
3634
4222
  for (const c of conflicts) {
3635
4223
  warn(`${label} \xB7 ${c.name}: the copies differ`);
3636
4224
  for (const copy of c.copies) {
3637
4225
  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)") : ""}`);
4226
+ info(` ${folderLabel(c.root, copy.dir)}${runs.length ? dim(` run by ${runs.join(", ")}`) : ""}${c.managed === copy.dir ? dim(" (skilllib's copy)") : ""}`);
3639
4227
  }
3640
- const pick = copyLabel(c, c.managed ?? c.copies[0].dir);
4228
+ const pick = folderLabel(c.root, c.managed ?? c.copies[0].dir);
3641
4229
  info(dim(` keep one: skilllib tidy ${c.name}${args.global ? " --global" : ""} --keep ${pick}`));
3642
4230
  }
3643
4231
  for (const s of report.skipped.filter((x) => !names.length || names.includes(x.name))) info(dim(`- ${label} \xB7 ${s.name}: skipped, ${s.reason}`));
3644
4232
  }
3645
4233
  if (args.dryRun) info(dim("\nNothing changed (--dry-run)."));
3646
4234
  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.`));
4235
+ if (held) {
4236
+ process.exitCode = 1;
4237
+ info(dim(`${held} change${held === 1 ? "" : "s"} held back because git would see them. Re-run with --allow-git to make them.`));
4238
+ }
3648
4239
  }
3649
4240
  function agentSkill(args) {
3650
4241
  const action = args.positional[0];
@@ -3673,17 +4264,22 @@ function agentSkill(args) {
3673
4264
  }
3674
4265
 
3675
4266
  export {
4267
+ realPath,
3676
4268
  skilllibHome,
3677
4269
  PROJECT_SKILLS_DIR,
3678
4270
  HARNESSES,
3679
4271
  harness,
4272
+ installDirs,
3680
4273
  readSkillInfo,
3681
4274
  treeHash,
4275
+ isDir,
3682
4276
  versionHistory,
3683
4277
  forgetLatest,
3684
4278
  latestVersion,
3685
4279
  projectHere,
3686
4280
  readManifest,
4281
+ knownProjects,
4282
+ rememberProjects,
3687
4283
  readableRoots,
3688
4284
  setJsonMode,
3689
4285
  isJsonMode,
@@ -3709,10 +4305,18 @@ export {
3709
4305
  recordOrigin,
3710
4306
  librarySkills,
3711
4307
  librarySkillDir,
3712
- visibilityOf,
4308
+ notWritable,
4309
+ inTheWaySaid,
4310
+ entryExists,
4311
+ survives,
4312
+ blindWithoutGlobal,
4313
+ strandedLinks,
4314
+ blindSaid,
4315
+ trackBlocker,
3713
4316
  projectStatus,
3714
4317
  linkEverywhere,
3715
4318
  relinkDependency,
4319
+ addRefusal,
3716
4320
  addSkill,
3717
4321
  removeSkill,
3718
4322
  updateProject,
@@ -3723,6 +4327,7 @@ export {
3723
4327
  deleteGlobal,
3724
4328
  createSkill,
3725
4329
  listBackups,
4330
+ movedTogether,
3726
4331
  restoreBackup,
3727
4332
  gitInfo,
3728
4333
  relativeTo,
@@ -3744,6 +4349,7 @@ export {
3744
4349
  findIssues,
3745
4350
  usageIssues,
3746
4351
  scanUsage,
4352
+ skillOf,
3747
4353
  moveGlobalSkills,
3748
4354
  parseArgs,
3749
4355
  projectOfFactory,