skilllib 1.11.2 → 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.
@@ -2,9 +2,9 @@
2
2
  import { resolve as resolve2, sep } from "path";
3
3
 
4
4
  // src/paths.ts
5
- import { realpathSync } from "fs";
5
+ import { mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "fs";
6
6
  import { homedir } from "os";
7
- import { join, resolve } from "path";
7
+ import { dirname, join, resolve } from "path";
8
8
  function userHome() {
9
9
  return process.env.HOME || homedir();
10
10
  }
@@ -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");
@@ -41,6 +41,47 @@ function isRepoSkillLocation(location) {
41
41
  return location === AGENTS_SKILLS_DIR || location === GROK_SKILLS_DIR;
42
42
  }
43
43
  var OWN_SKILL_MARKER = ".managed-by-skilllib";
44
+ function readJson(path) {
45
+ try {
46
+ return JSON.parse(readFileSync(path, "utf-8"));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+ function realpathOrNull(path) {
52
+ try {
53
+ return realpathSync(path);
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+ function readState(path) {
59
+ let text;
60
+ try {
61
+ text = readFileSync(path, "utf-8");
62
+ } catch (err) {
63
+ if (err.code === "ENOENT") return null;
64
+ throw err;
65
+ }
66
+ try {
67
+ return JSON.parse(text);
68
+ } catch {
69
+ throw new Error(`${path} isn't valid JSON: fix it, or move it away to start that file over`);
70
+ }
71
+ }
72
+ function writeState(path, data) {
73
+ const target = realpathOrNull(path) ?? path;
74
+ mkdirSync(dirname(target), { recursive: true });
75
+ const temp = `${target}.${process.pid}.tmp`;
76
+ try {
77
+ writeFileSync(temp, JSON.stringify(data, null, 2) + "\n", { mode: statSync(target, { throwIfNoEntry: false })?.mode ?? 420 });
78
+ renameSync(temp, target);
79
+ } catch (err) {
80
+ rmSync(temp, { force: true });
81
+ throw err;
82
+ }
83
+ }
84
+ var BUILD_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "out", "vendor", "target", "coverage"]);
44
85
 
45
86
  // src/output.ts
46
87
  var jsonMode = false;
@@ -63,30 +104,29 @@ var warn = (msg) => out(`${yellow("!")} ${msg}`);
63
104
  var error = (msg) => console.error(`${red("\u2717")} ${msg}`);
64
105
  function tildify(path) {
65
106
  for (const home2 of /* @__PURE__ */ new Set([resolve2(userHome()), realPath(userHome())]))
66
- if (path === home2 || path.startsWith(home2 + sep)) return ("~" + path.slice(home2.length)).replace(/\\/g, "/");
67
- return path;
107
+ if (path === home2 || path.startsWith(home2 + sep)) path = "~" + path.slice(home2.length);
108
+ return path.replace(/\\/g, "/");
68
109
  }
69
110
  function truncate(s, max) {
70
111
  const line = s.replace(/\s+/g, " ");
71
112
  return line.length > max ? line.slice(0, max - 1) + "\u2026" : line;
72
113
  }
73
114
  var visible = (s) => s.replace(/\x1b\[\d+m/g, "");
74
- function table(rows) {
75
- if (rows.length === 0) return;
76
- const keys = Object.keys(rows[0]);
77
- const widths = keys.map((k) => Math.max(k.length, ...rows.map((r) => visible(r[k] ?? "").length)));
115
+ function table(rows2) {
116
+ if (rows2.length === 0) return;
117
+ const keys = Object.keys(rows2[0]);
118
+ const widths = keys.map((k) => Math.max(k.length, ...rows2.map((r) => visible(r[k] ?? "").length)));
78
119
  const pad = (s, w) => s + " ".repeat(Math.max(0, w - visible(s).length));
79
120
  info(dim(keys.map((k, i) => pad(k, widths[i])).join(" ")));
80
- for (const row of rows) info(keys.map((k, i) => pad(row[k] ?? "", widths[i])).join(" ").trimEnd());
121
+ for (const row of rows2) info(keys.map((k, i) => pad(row[k] ?? "", widths[i])).join(" ").trimEnd());
122
+ }
123
+ function localStamp(ms) {
124
+ const at = new Date(ms);
125
+ return new Date(ms - at.getTimezoneOffset() * 6e4).toISOString().slice(0, 16).replace("T", " ");
81
126
  }
82
-
83
- // src/agentSkill.ts
84
- import { existsSync as existsSync8, lstatSync as lstatSync3, mkdirSync as mkdirSync6, readFileSync as readFileSync7, rmSync as rmSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync6 } from "fs";
85
- import { join as join9 } from "path";
86
127
 
87
128
  // src/config.ts
88
- import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync2, writeFileSync as writeFileSync3 } from "fs";
89
- import { homedir as homedir2 } from "os";
129
+ import { existsSync as existsSync5, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
90
130
  import { join as join6, resolve as resolve4 } from "path";
91
131
 
92
132
  // src/harnesses.ts
@@ -185,16 +225,16 @@ function installDirs(enabled, keep) {
185
225
  }
186
226
 
187
227
  // src/project.ts
188
- import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
189
- import { dirname, join as join5, resolve as resolve3 } from "path";
228
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
229
+ import { dirname as dirname2, join as join5, resolve as resolve3 } from "path";
190
230
 
191
231
  // src/versions.ts
192
- import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
232
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2 } from "fs";
193
233
  import { join as join4 } from "path";
194
234
 
195
235
  // src/skills.ts
196
236
  import { createHash } from "crypto";
197
- import { cpSync, existsSync as existsSync2, readdirSync, readFileSync, realpathSync as realpathSync2, rmSync, statSync } from "fs";
237
+ import { cpSync, existsSync as existsSync2, readdirSync, readFileSync as readFileSync2, realpathSync as realpathSync2, rmSync as rmSync2, statSync as statSync2 } from "fs";
198
238
  import { basename, join as join3, relative, sep as sep2 } from "path";
199
239
  import { parse as parseYaml } from "yaml";
200
240
  var IGNORED = /* @__PURE__ */ new Set([".DS_Store", ".git", "node_modules"]);
@@ -202,7 +242,7 @@ function readSkillInfo(dir) {
202
242
  const skillMd = join3(dir, "SKILL.md");
203
243
  const fallback = { name: basename(dir), description: "" };
204
244
  if (!existsSync2(skillMd)) return fallback;
205
- const match = readFileSync(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---/);
206
246
  if (!match) return fallback;
207
247
  try {
208
248
  const fm = parseYaml(match[1]);
@@ -211,35 +251,47 @@ function readSkillInfo(dir) {
211
251
  description: typeof fm?.description === "string" ? fm.description.trim() : ""
212
252
  };
213
253
  } catch {
214
- 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") };
215
256
  }
216
257
  }
217
258
  function isSkillDir(dir) {
218
259
  return existsSync2(join3(dir, "SKILL.md"));
219
260
  }
220
- 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);
221
265
  return readdirSync(dir).filter((entry) => !IGNORED.has(entry)).flatMap((entry) => {
222
266
  const full = join3(dir, entry);
223
- const st = statSync(full, { throwIfNoEntry: false });
224
- return !st ? [] : st.isDirectory() ? listFiles(full) : [full];
267
+ const st = statSync2(full, { throwIfNoEntry: false });
268
+ return !st ? [] : st.isDirectory() ? listFiles(full, seen) : [full];
225
269
  });
226
270
  }
227
271
  function treeHash(dir) {
228
- if (!existsSync2(dir)) return null;
272
+ if (!isDir(dir)) return null;
229
273
  const hash = createHash("sha256");
230
- const files = listFiles(dir).map((f) => ({ path: relative(dir, f).split(sep2).join("/"), full: f })).sort((a, b) => a.path.localeCompare(b.path));
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"));
231
275
  for (const file of files) {
232
- hash.update(file.path).update("\0").update(createHash("sha256").update(readFileSync(file.full)).digest("hex")).update("\n");
276
+ hash.update(file.path).update("\0").update(createHash("sha256").update(readFileSync2(file.full)).digest("hex")).update("\n");
233
277
  }
234
278
  return hash.digest("hex").slice(0, 16);
235
279
  }
236
280
  function copySkill(from, to) {
237
281
  const source = realpathSync2(from);
238
- rmSync(to, { recursive: true, force: true });
239
- cpSync(source, to, { recursive: true, dereference: true, filter: (src) => !IGNORED.has(basename(src)) && existsSync2(src) });
282
+ rmSync2(to, { recursive: true, force: true });
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
+ }
240
292
  }
241
293
  function skillDirsIn(dir) {
242
- if (!existsSync2(dir)) return [];
294
+ if (!isDir(dir)) return [];
243
295
  return readdirSync(dir, { withFileTypes: true }).filter((d) => (d.isDirectory() || d.isSymbolicLink()) && isSkillDir(join3(dir, d.name))).map((d) => join3(dir, d.name)).sort();
244
296
  }
245
297
 
@@ -251,11 +303,7 @@ function indexFile(name) {
251
303
  return join4(storeDir(name), "versions.json");
252
304
  }
253
305
  function versionHistory(name) {
254
- try {
255
- return JSON.parse(readFileSync2(indexFile(name), "utf-8"));
256
- } catch {
257
- return [];
258
- }
306
+ return readState(indexFile(name)) ?? [];
259
307
  }
260
308
  function versionDir(name, version) {
261
309
  return join4(storeDir(name), String(version));
@@ -277,9 +325,10 @@ function latestVersion(name) {
277
325
  result = last;
278
326
  } else {
279
327
  const next = { version: (last?.version ?? 0) + 1, hash, date: (/* @__PURE__ */ new Date()).toISOString() };
280
- mkdirSync(storeDir(name), { recursive: true });
328
+ if (existsSync3(versionDir(name, next.version))) throw new Error(`${versionDir(name, next.version)} exists but ${indexFile(name)} doesn't list it: fix the index before adding versions`);
329
+ mkdirSync2(storeDir(name), { recursive: true });
281
330
  copySkill(source, versionDir(name, next.version));
282
- writeFileSync(indexFile(name), JSON.stringify([...history, next], null, 2) + "\n");
331
+ writeState(indexFile(name), [...history, next]);
283
332
  result = next;
284
333
  }
285
334
  }
@@ -298,7 +347,7 @@ function findProjectRoot(from = process.cwd()) {
298
347
  let dir = resolve3(from);
299
348
  while (true) {
300
349
  if (existsSync4(join5(dir, MANIFEST_FILE)) || existsSync4(join5(dir, ".git"))) return dir;
301
- const parent = dirname(dir);
350
+ const parent = dirname2(dir);
302
351
  if (parent === dir) return resolve3(from);
303
352
  dir = parent;
304
353
  }
@@ -310,13 +359,23 @@ function projectHere(from = process.cwd()) {
310
359
  function readManifest(root) {
311
360
  const path = join5(root, MANIFEST_FILE);
312
361
  if (!existsSync4(path)) return { skills: {} };
313
- const data = JSON.parse(readFileSync3(path, "utf-8"));
362
+ let data;
363
+ try {
364
+ data = JSON.parse(readFileSync3(path, "utf-8"));
365
+ } catch {
366
+ throw new Error(`${path} isn't valid JSON (a merge conflict?): fix it, then run skilllib again`);
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 })`);
314
370
  const skills = Object.fromEntries(
315
371
  Object.entries(data.skills ?? {}).flatMap(([name, value]) => {
316
- if (name.startsWith("@")) return [];
372
+ if (name.startsWith("@") || !name || name === "." || name === ".." || /[\\/]/.test(name)) return [];
317
373
  if (typeof value === "string") return [[name, { version: versionForHash(name, value)?.version ?? 0, hash: value }]];
318
374
  if (value && typeof value === "object" && "hash" in value) {
319
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(", ")})`);
320
379
  return [
321
380
  [
322
381
  name,
@@ -329,14 +388,14 @@ function readManifest(root) {
329
388
  ]
330
389
  ];
331
390
  }
332
- return [];
391
+ throw new Error(`${path} isn't a valid skilllib.json: ${name}'s pin has no hash`);
333
392
  })
334
393
  );
335
394
  return { ...data, skills };
336
395
  }
337
396
  function writeManifest(root, manifest) {
338
- const skills = Object.fromEntries(Object.entries(manifest.skills).sort(([a], [b]) => a.localeCompare(b)));
339
- writeFileSync2(join5(root, MANIFEST_FILE), JSON.stringify({ ...manifest, skills }, null, 2) + "\n");
397
+ const skills = Object.fromEntries(Object.entries(manifest.skills).sort(([a], [b]) => a.localeCompare(b, "en")));
398
+ writeState(join5(root, MANIFEST_FILE), { ...manifest, skills });
340
399
  }
341
400
  function projectsFile() {
342
401
  return join5(skilllibHome(), "projects.json");
@@ -344,7 +403,7 @@ function projectsFile() {
344
403
  function knownProjects() {
345
404
  const path = projectsFile();
346
405
  if (!existsSync4(path)) return [];
347
- const list2 = JSON.parse(readFileSync3(path, "utf-8"));
406
+ const list2 = readState(path) ?? [];
348
407
  return onePerFolder(list2.filter((p) => existsSync4(p) && isProjectCandidate(p) && !isAgentWorktree(p)));
349
408
  }
350
409
  function isAgentWorktree(path) {
@@ -360,13 +419,34 @@ function onePerFolder(paths) {
360
419
  return [...new Set(byReal.values())];
361
420
  }
362
421
  function isProjectCandidate(root) {
363
- 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);
364
425
  }
365
426
  function rememberProjects(roots) {
366
427
  const incoming = roots.map((r) => resolve3(r)).filter((r) => isProjectCandidate(r) && !isAgentWorktree(r));
367
428
  const all = onePerFolder([...knownProjects(), ...incoming]).sort();
368
- mkdirSync2(skilllibHome(), { recursive: true });
369
- writeFileSync2(projectsFile(), JSON.stringify(all, null, 2) + "\n");
429
+ writeState(projectsFile(), all);
430
+ }
431
+ function readableRoots(roots) {
432
+ const unreadable = [];
433
+ const readable = roots.filter((root) => {
434
+ try {
435
+ readManifest(root);
436
+ return true;
437
+ } catch (err) {
438
+ unreadable.push({ root, reason: err.message });
439
+ return false;
440
+ }
441
+ });
442
+ return { readable, unreadable };
443
+ }
444
+ function pinsIn(root) {
445
+ try {
446
+ return readManifest(root).skills;
447
+ } catch {
448
+ return {};
449
+ }
370
450
  }
371
451
 
372
452
  // src/config.ts
@@ -374,24 +454,19 @@ function configFile() {
374
454
  return join6(skilllibHome(), "config.json");
375
455
  }
376
456
  function readConfig() {
377
- try {
378
- const data = JSON.parse(readFileSync4(configFile(), "utf-8"));
379
- return { ...data, roots: data.roots ?? [], hidden: data.hidden ?? [] };
380
- } catch {
381
- return { roots: [], hidden: [] };
382
- }
457
+ const data = readState(configFile()) ?? {};
458
+ return { ...data, roots: data.roots ?? [], hidden: data.hidden ?? [] };
383
459
  }
384
460
  function writeConfig(config) {
385
- mkdirSync3(skilllibHome(), { recursive: true });
386
- writeFileSync3(configFile(), JSON.stringify(config, null, 2) + "\n");
461
+ writeState(configFile(), config);
387
462
  }
388
463
  function expandHome(path) {
389
- return resolve4(path.replace(/^~(?=$|\/)/, homedir2()));
464
+ return resolve4(path.replace(/^~(?=$|[\\/])/, userHome()));
390
465
  }
391
466
  function addRoot(path) {
392
467
  const root = expandHome(path);
393
468
  if (!existsSync5(root)) throw new Error(`${tildify(root)} doesn't exist`);
394
- if (!statSync2(root).isDirectory()) throw new Error(`${tildify(root)} isn't a folder`);
469
+ if (!statSync3(root).isDirectory()) throw new Error(`${tildify(root)} isn't a folder`);
395
470
  const config = readConfig();
396
471
  if (!config.roots.includes(root)) writeConfig({ ...config, roots: [...config.roots, root].sort() });
397
472
  return root;
@@ -410,7 +485,7 @@ function setHidden(project, hidden) {
410
485
  writeConfig({ ...config, hidden: hidden ? [...rest, project].sort() : rest });
411
486
  return true;
412
487
  }
413
- 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"]);
414
489
  function findProjects(dir, depth = 4) {
415
490
  if (depth < 0 || !existsSync5(dir)) return [];
416
491
  if (isProjectCandidate(dir)) {
@@ -462,37 +537,31 @@ function setKeepGlobal(names, keep) {
462
537
  writeConfig({ ...readConfig(), keepGlobal: [...kept].sort() });
463
538
  }
464
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
+
465
544
  // src/library.ts
466
545
  import { execFileSync } from "child_process";
467
- import { cpSync as cpSync2, existsSync as existsSync7, lstatSync as lstatSync2, mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync6, realpathSync as realpathSync4, renameSync, rmSync as rmSync2, symlinkSync, unlinkSync, writeFileSync as writeFileSync5 } from "fs";
468
- import { basename as basename3, dirname as dirname2, join as join8, relative as relative2, sep as sep3 } 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";
469
548
 
470
549
  // src/sources.ts
471
- import { existsSync as existsSync6, lstatSync, mkdirSync as mkdirSync4, readdirSync as readdirSync3, readFileSync as readFileSync5, readlinkSync, realpathSync as realpathSync3, writeFileSync as writeFileSync4 } from "fs";
472
- import { basename as basename2, join as join7, resolve as resolve5 } from "path";
473
- function readJson(path) {
474
- try {
475
- return JSON.parse(readFileSync5(path, "utf-8"));
476
- } catch {
477
- return null;
478
- }
479
- }
550
+ import { existsSync as existsSync6, lstatSync, mkdirSync as mkdirSync3, readdirSync as readdirSync3, readlinkSync } from "fs";
551
+ import { basename as basename2, join as join7, resolve as resolve5, sep as sep3 } from "path";
552
+ import { tmpdir } from "os";
480
553
  function skillsShLock() {
481
554
  return readJson(join7(userHome(), ".agents", ".skill-lock.json"))?.skills ?? {};
482
555
  }
483
556
  function projectSkillsLock(root) {
484
557
  return readJson(join7(root, "skills-lock.json"))?.skills ?? {};
485
558
  }
486
- function realpathOrNull(path) {
487
- try {
488
- return realpathSync3(path);
489
- } catch {
490
- return null;
491
- }
559
+ function vendorSkill(path, kind, origin, harnesses) {
560
+ return { name: basename2(path), kind, origin, path, description: readSkillInfo(path).description, movable: false, broken: false, harnesses, links: [] };
492
561
  }
493
562
  function harnessesReading(dir, enabled) {
494
563
  const real = realPath(dir);
495
- return enabled.filter((id) => HARNESSES.find((h) => h.id === id)?.globalDirs().some((d) => realPath(d) === real));
564
+ return enabled.filter((id) => harness(id).globalDirs().some((d) => realPath(d) === real));
496
565
  }
497
566
  var systemDirs = () => [codexSystemDir()];
498
567
  function globalSkillDirs() {
@@ -511,24 +580,14 @@ function globalDirAs(dir) {
511
580
  }
512
581
  function systemSkills(enabled) {
513
582
  return systemDirs().flatMap(
514
- (dir) => skillDirsIn(dir).map((path) => ({
515
- name: basename2(path),
516
- kind: "system",
517
- origin: dir.replace(/\\/g, "/"),
518
- path,
519
- description: readSkillInfo(path).description,
520
- movable: false,
521
- broken: false,
522
- harnesses: harnessesReading(dir, enabled),
523
- links: []
524
- }))
583
+ (dir) => skillDirsIn(dir).map((path) => vendorSkill(path, "system", dir.replace(/\\/g, "/"), harnessesReading(dir, enabled)))
525
584
  ).filter((s) => s.harnesses.length > 0);
526
585
  }
527
586
  function globalFolderSkills(enabled) {
528
587
  const lock = skillsShLock();
529
588
  const agentsDir = join7(userHome(), ".agents", "skills");
530
589
  const entries = globalSkillDirs().flatMap((dir) => {
531
- if (!existsSync6(dir)) return [];
590
+ if (!isDir(dir)) return [];
532
591
  return readdirSync3(dir).filter((name) => !name.startsWith(".") && name !== "synced").flatMap((name) => {
533
592
  const path = join7(dir, name);
534
593
  const isLink2 = lstatSync(path).isSymbolicLink();
@@ -550,13 +609,13 @@ function globalFolderSkills(enabled) {
550
609
  const inAgents = e.dir === agentsDir || target_.includes(".agents/skills");
551
610
  const locked = lock[e.name]?.source;
552
611
  if (existsSync6(join7(e.path, OWN_SKILL_MARKER))) {
553
- skills.push({ name: e.name, kind: "skilllib", origin: "skilllib", path: e.path, description: readSkillInfo(e.path).description, movable: false, broken: false, harnesses: harnessesReading(e.dir, enabled), links: [] });
612
+ skills.push(vendorSkill(e.path, "skilllib", "skilllib", harnessesReading(e.dir, enabled)));
554
613
  continue;
555
614
  }
556
615
  skills.push({
557
616
  name: e.name,
558
617
  kind: locked || e.isLink && inAgents ? "skills.sh" : "global",
559
- 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)`),
560
619
  path: e.path,
561
620
  description: e.broken ? "" : readSkillInfo(e.path).description,
562
621
  movable: true,
@@ -573,36 +632,17 @@ function globalFolderSkills(enabled) {
573
632
  }
574
633
  return skills;
575
634
  }
576
- function tildify2(p) {
577
- return tildify(p).replace(/\\/g, "/");
578
- }
579
635
  function grokBuiltInSkills(enabled) {
580
636
  if (!enabled.includes("grok")) return [];
581
- return skillDirsIn(join7(grokDir(), "bundled", "skills")).map((path) => ({
582
- name: basename2(path),
583
- kind: "built-in",
584
- origin: "Grok",
585
- path,
586
- description: readSkillInfo(path).description,
587
- movable: false,
588
- broken: false,
589
- harnesses: ["grok"],
590
- links: []
591
- }));
637
+ return skillDirsIn(join7(grokDir(), "bundled", "skills")).map((path) => vendorSkill(path, "built-in", "Grok", ["grok"]));
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"]));
592
642
  }
593
643
  function cursorBuiltInSkills(enabled) {
594
644
  if (!enabled.includes("cursor")) return [];
595
- return skillDirsIn(join7(userHome(), ".cursor", "skills-cursor")).map((path) => ({
596
- name: basename2(path),
597
- kind: "built-in",
598
- origin: "Cursor",
599
- path,
600
- description: readSkillInfo(path).description,
601
- movable: false,
602
- broken: false,
603
- harnesses: ["cursor"],
604
- links: []
605
- }));
645
+ return skillDirsIn(join7(userHome(), ".cursor", "skills-cursor")).map((path) => vendorSkill(path, "built-in", "Cursor", ["cursor"]));
606
646
  }
607
647
  function claudeAiSkills(enabled) {
608
648
  const byReal = /* @__PURE__ */ new Map();
@@ -616,23 +656,18 @@ function claudeAiSkills(enabled) {
616
656
  byReal.set(real, entry);
617
657
  }
618
658
  return [...byReal.values()].flatMap(
619
- ({ dir, readers }) => readdirSync3(dir).filter((d) => !d.startsWith(".")).flatMap(
620
- (bucket) => skillDirsIn(join7(dir, bucket)).map((path) => ({
621
- name: basename2(path),
622
- kind: "claude.ai",
623
- origin: "claude.ai account",
624
- path,
625
- description: readSkillInfo(path).description,
626
- movable: false,
627
- broken: false,
628
- harnesses: enabled.filter((id) => readers.includes(id)),
629
- links: []
630
- }))
659
+ ({ dir, readers }) => (isDir(dir) ? readdirSync3(dir) : []).filter((d) => !d.startsWith(".")).flatMap(
660
+ (bucket) => skillDirsIn(join7(dir, bucket)).map((path) => vendorSkill(path, "claude.ai", "claude.ai account", enabled.filter((id) => readers.includes(id))))
631
661
  )
632
662
  );
633
663
  }
664
+ function installedPluginsJson() {
665
+ return readJson(join7(claudeDir(), "plugins", "installed_plugins.json"))?.plugins ?? {};
666
+ }
634
667
  function pluginDirCandidates(id, installPaths) {
635
668
  const [name = "", marketplace = ""] = id.split("@");
669
+ const oneName = (s) => s !== "" && s !== "." && s !== ".." && !/[\\/]/.test(s);
670
+ if (!oneName(name) || !oneName(marketplace)) return installPaths;
636
671
  const plugins = join7(claudeDir(), "plugins");
637
672
  return [
638
673
  ...installPaths,
@@ -653,7 +688,7 @@ function pluginSkills(harnesses, root) {
653
688
  const cursor = harnesses.includes("cursor");
654
689
  if (!claude2 && !cursor) return [];
655
690
  const plugins = join7(claudeDir(), "plugins");
656
- const installed = readJson(join7(plugins, "installed_plugins.json"))?.plugins ?? {};
691
+ const installed = installedPluginsJson();
657
692
  const user = enabledPlugins();
658
693
  const enabled = root ? enabledPlugins(root) : user;
659
694
  const fromMarketplaces = [.../* @__PURE__ */ new Set([...Object.keys(enabled), ...Object.keys(user)])].filter((id) => claude2 && enabled[id] || cursor && user[id]).flatMap((id) => {
@@ -665,7 +700,7 @@ function pluginSkills(harnesses, root) {
665
700
  return loadedBy.length ? skillDirsIn(join7(dir, "skills")).map((path) => ({ path, origin: id, loadedBy })) : [];
666
701
  });
667
702
  const syncedDir = join7(plugins, "synced");
668
- 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(
669
704
  (bucket) => readdirSync3(join7(syncedDir, bucket), { withFileTypes: true }).filter((d) => d.isDirectory()).flatMap((d) => {
670
705
  const root2 = join7(syncedDir, bucket, d.name);
671
706
  const name = readJson(join7(root2, ".claude-plugin", "plugin.json"))?.name ?? d.name;
@@ -673,17 +708,7 @@ function pluginSkills(harnesses, root) {
673
708
  return skillDirsIn(join7(root2, "skills")).map((path) => ({ path, origin: `${name}@synced`, loadedBy: ["claude-code"] }));
674
709
  })
675
710
  ) : [];
676
- return [...fromMarketplaces, ...fromSynced].map(({ path, origin, loadedBy }) => ({
677
- name: basename2(path),
678
- kind: "plugin",
679
- origin,
680
- path,
681
- description: readSkillInfo(path).description,
682
- movable: false,
683
- broken: false,
684
- harnesses: loadedBy,
685
- links: []
686
- }));
711
+ return [...fromMarketplaces, ...fromSynced].map(({ path, origin, loadedBy }) => vendorSkill(path, "plugin", origin, loadedBy));
687
712
  }
688
713
  var byKindAndName = (a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name);
689
714
  function machineSkills(enabled = enabledHarnesses()) {
@@ -692,6 +717,7 @@ function machineSkills(enabled = enabledHarnesses()) {
692
717
  ...claudeAiSkills(enabled),
693
718
  ...pluginSkills(enabled),
694
719
  ...cursorBuiltInSkills(enabled),
720
+ ...codexBuiltInSkills(enabled),
695
721
  ...grokBuiltInSkills(enabled),
696
722
  ...systemSkills(enabled)
697
723
  ].sort(byKindAndName);
@@ -705,15 +731,20 @@ function originsFile() {
705
731
  return join7(skilllibHome(), "origins.json");
706
732
  }
707
733
  function libraryOrigins() {
708
- return readJson(originsFile()) ?? {};
734
+ return readState(originsFile()) ?? {};
709
735
  }
710
736
  function recordOrigin(name, origin) {
711
737
  const origins = libraryOrigins();
712
738
  if (origins[name] === origin) return;
713
- mkdirSync4(skilllibHome(), { recursive: true });
714
- writeFileSync4(originsFile(), JSON.stringify({ ...origins, [name]: origin }, null, 2) + "\n");
739
+ mkdirSync3(skilllibHome(), { recursive: true });
740
+ writeState(originsFile(), { ...origins, [name]: origin });
715
741
  }
716
742
  function originFor(dir) {
743
+ const under = (path, folder) => path.startsWith((realpathOrNull(folder) ?? folder) + sep3);
744
+ const real = realpathOrNull(dir) ?? dir;
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 "";
717
748
  const match = machineSkills().find((s) => resolve5(s.path) === resolve5(dir));
718
749
  if (match) return match.kind === "global" ? "global folder" : `${match.kind}: ${match.origin}`;
719
750
  const project = dir.split(/[\\/]\.(?:claude|agents|grok|cursor|codex)[\\/]skills[\\/]/)[0];
@@ -733,13 +764,6 @@ function librarySkills() {
733
764
  function librarySkillDir(name) {
734
765
  return join8(libraryDir(), name);
735
766
  }
736
- function realpathOrNull2(path) {
737
- try {
738
- return realpathSync4(path);
739
- } catch {
740
- return null;
741
- }
742
- }
743
767
  function isLink(path) {
744
768
  try {
745
769
  return lstatSync2(path).isSymbolicLink();
@@ -747,9 +771,65 @@ function isLink(path) {
747
771
  return false;
748
772
  }
749
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
+ }
750
830
  function linkDir(target, link2) {
751
831
  if (process.platform === "win32") symlinkSync(target, link2, "junction");
752
- else symlinkSync(relative2(dirname2(link2), target), link2);
832
+ else symlinkSync(relative2(dirname3(link2), target), link2);
753
833
  }
754
834
  function entryExists(path) {
755
835
  return isLink(path) || existsSync7(path);
@@ -760,11 +840,160 @@ function visibilityOf(root, name, enabled = enabledHarnesses()) {
760
840
  paths: harness(id).projectDirs.filter((dir) => existsSync7(join8(root, dir, name, "SKILL.md"))).length
761
841
  }));
762
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
+ }
763
969
  function dependencyDirs(root, name, dep) {
764
- const wanted = installDirs(enabledHarnesses());
765
970
  const untracked = ALL_PROJECT_DIRS.find((d) => d !== GROK_SKILLS_DIR && existsSync7(join8(root, d, name, "SKILL.md")) && !isLink(join8(root, d, name)));
766
- const primary = dep ? dep.dir ?? PROJECT_SKILLS_DIR : untracked ?? wanted[0];
767
- return [primary, .../* @__PURE__ */ new Set([...dep?.links ?? [], ...wanted.filter((d) => d !== primary)])];
971
+ const primary = dep ? dep.dir ?? PROJECT_SKILLS_DIR : untracked ?? installDirs(enabledHarnesses())[0];
972
+ const wanted = installDirs(enabledHarnesses(), 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
+ }
768
997
  }
769
998
  function isGitTracked(root, dir) {
770
999
  try {
@@ -780,16 +1009,18 @@ function projectStatus(root) {
780
1009
  const managed = Object.entries(skills).map(([name, dep]) => {
781
1010
  const location = dep.dir ?? PROJECT_SKILLS_DIR;
782
1011
  const path = join8(root, location, name);
783
- const local = treeHash(path);
1012
+ const local = notWritable(root, location) ? null : treeHash(path);
784
1013
  const latest = latestVersion(name);
785
- const edited = local !== dep.hash;
786
- const behind = latest !== null && latest.hash !== dep.hash && latest.version > dep.version;
787
- 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";
788
1019
  return {
789
1020
  name,
790
1021
  managed: true,
791
1022
  state,
792
- version: dep.version || null,
1023
+ version: pinned || null,
793
1024
  latest: latest?.version ?? null,
794
1025
  location,
795
1026
  path,
@@ -800,7 +1031,7 @@ function projectStatus(root) {
800
1031
  const lock = projectSkillsLock(root);
801
1032
  const unmanaged = [];
802
1033
  const byName = /* @__PURE__ */ new Map();
803
- for (const dir of ALL_PROJECT_DIRS) {
1034
+ for (const dir of ALL_PROJECT_DIRS.filter((d) => writableDir(root, d))) {
804
1035
  for (const path of skillDirsIn(join8(root, dir))) {
805
1036
  const name = basename3(path);
806
1037
  if (seen.has(name)) continue;
@@ -809,7 +1040,7 @@ function projectStatus(root) {
809
1040
  }
810
1041
  for (const [name, paths] of byName) {
811
1042
  const real = paths.find((p) => !isLink(p)) ?? paths[0];
812
- const location = relative2(root, join8(real, "..")).split(sep3).join("/");
1043
+ const location = relative2(root, join8(real, "..")).split(sep4).join("/");
813
1044
  const source = lock[name]?.source;
814
1045
  unmanaged.push({
815
1046
  name,
@@ -841,40 +1072,57 @@ var NESTED_READERS = [
841
1072
  // Cursor reads nested .grok/skills too, but skilllib doesn't count it: that folder stays Grok's.
842
1073
  [".grok/skills", ["grok"]]
843
1074
  ];
844
- var NESTED_SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "out", "vendor", "target", "coverage"]);
845
- 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) {
846
1101
  const found = [];
847
1102
  const walk = (dir, left) => {
848
1103
  let subs = [];
849
1104
  try {
850
- 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));
851
1106
  } catch {
852
1107
  return;
853
1108
  }
854
1109
  for (const sub of subs) {
855
1110
  if (existsSync7(join8(sub, ".git"))) continue;
856
- for (const [skillsDir, readers] of NESTED_READERS) {
857
- const location = relative2(root, join8(sub, skillsDir)).split(sep3).join("/");
858
- for (const path of skillDirsIn(join8(sub, skillsDir))) {
859
- if (isLink(path) && (realpathOrNull2(path) ?? "").startsWith((realpathOrNull2(root) ?? root) + sep3)) continue;
860
- const name = basename3(path);
861
- found.push({
862
- name,
863
- managed: false,
864
- state: unmanagedState(name, path, isRepoSkillLocation(skillsDir), void 0),
865
- version: null,
866
- latest: latestVersion(name)?.version ?? null,
867
- location,
868
- path,
869
- visibility: enabled.map((id) => ({ id, paths: readers.includes(id) ? 1 : 0 }))
870
- });
871
- }
872
- }
1111
+ found.push(sub);
873
1112
  if (left > 1) walk(sub, left - 1);
874
1113
  }
875
1114
  };
876
1115
  walk(root, depth);
877
- 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
+ );
878
1126
  }
879
1127
  function sharedByGit(root, location, name) {
880
1128
  const copy = `${location}/${name}`;
@@ -889,13 +1137,24 @@ function sharedByGit(root, location, name) {
889
1137
  }
890
1138
  function linkEverywhere(root, name, location, { allowTracked = false, dryRun = false } = {}) {
891
1139
  const enabled = enabledHarnesses();
892
- const result = { created: [], blocked: [], uncommitted: [] };
1140
+ const result = { created: [], blocked: [], uncommitted: [], inTheWay: [] };
893
1141
  let shared;
1142
+ const stuck = (path) => void (result.inTheWay.includes(path) || result.inTheWay.push(path));
894
1143
  for (const id of enabled) {
895
1144
  if (visibilityOf(root, name, [id])[0].paths > 0) continue;
896
1145
  const dir = harness(id).projectDirs[0];
897
1146
  const link2 = join8(root, dir, name);
898
- 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
+ }
899
1158
  if (isGitTracked(root, dir)) {
900
1159
  if (!(shared ??= sharedByGit(root, location, name))) {
901
1160
  if (!result.uncommitted.includes(dir)) result.uncommitted.push(dir);
@@ -907,22 +1166,18 @@ function linkEverywhere(root, name, location, { allowTracked = false, dryRun = f
907
1166
  }
908
1167
  }
909
1168
  if (!dryRun) {
910
- mkdirSync5(join8(root, dir), { recursive: true });
1169
+ if (broken) unlinkSync(link2);
1170
+ mkdirSync4(join8(root, dir), { recursive: true });
911
1171
  linkDir(join8(root, location, name), link2);
912
1172
  }
913
1173
  result.created.push(dir);
914
1174
  }
915
1175
  return result;
916
1176
  }
917
- function unlinkEverywhere(root, name, removing) {
918
- const gone = removing ? realpathOrNull2(removing) : null;
919
- return ALL_PROJECT_DIRS.filter((dir) => {
1177
+ function unlinkEverywhere(root, name) {
1178
+ return ALL_PROJECT_DIRS.filter((dir) => writableDir(root, dir)).filter((dir) => {
920
1179
  const link2 = join8(root, dir, name);
921
- if (!isLink(link2)) return false;
922
- if (dir === GROK_SKILLS_DIR) {
923
- const target = realpathOrNull2(link2);
924
- if (target !== null && target !== gone) return false;
925
- }
1180
+ if (!isLink(link2) || realpathOrNull(link2) !== null) return false;
926
1181
  unlinkSync(link2);
927
1182
  return true;
928
1183
  });
@@ -930,7 +1185,7 @@ function unlinkEverywhere(root, name, removing) {
930
1185
  function relinkDependency(root, name, { allowTracked = false } = {}) {
931
1186
  const manifest = readManifest(root);
932
1187
  const dep = manifest.skills[name];
933
- if (!dep) return { created: [], blocked: [], uncommitted: [] };
1188
+ if (!dep) return { created: [], blocked: [], uncommitted: [], inTheWay: [] };
934
1189
  const result = linkEverywhere(root, name, dep.dir ?? PROJECT_SKILLS_DIR, { allowTracked });
935
1190
  if (result.created.length) {
936
1191
  manifest.skills[name] = { ...dep, links: [.../* @__PURE__ */ new Set([...dep.links ?? [], ...result.created])] };
@@ -938,53 +1193,80 @@ function relinkDependency(root, name, { allowTracked = false } = {}) {
938
1193
  }
939
1194
  return result;
940
1195
  }
941
- function linkAll(root, { allowTracked = false } = {}) {
1196
+ function linkAll(root, { allowTracked = false, names } = {}) {
942
1197
  const linked = [];
943
1198
  const blocked = /* @__PURE__ */ new Set();
944
1199
  const uncommitted = [];
1200
+ const inTheWay = /* @__PURE__ */ new Set();
945
1201
  for (const skill of projectStatus(root)) {
946
- 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;
947
1203
  const res = skill.managed ? relinkDependency(root, skill.name, { allowTracked }) : linkEverywhere(root, skill.name, skill.location, { allowTracked });
948
1204
  if (res.created.length) linked.push({ name: skill.name, into: res.created });
949
1205
  for (const dir of res.blocked) blocked.add(dir);
950
1206
  if (res.uncommitted.length) uncommitted.push(skill.name);
1207
+ for (const path of res.inTheWay) inTheWay.add(path);
951
1208
  }
952
- return { linked, blocked: [...blocked], uncommitted };
1209
+ return { linked, blocked: [...blocked], uncommitted, inTheWay: [...inTheWay] };
953
1210
  }
954
- function addSkill(root, name, { force = false, version, allowTracked = false } = {}) {
955
- const target = version === void 0 ? latestVersion(name) : getVersion(name, version);
956
- 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) {
957
1212
  const manifest = readManifest(root);
958
1213
  const recorded = manifest.skills[name];
959
- 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` };
960
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` };
961
1219
  const local = isLink(to) && !recorded ? null : treeHash(to);
962
1220
  const knownContent = local !== null && (local === recorded?.hash || versionForHash(name, local) !== null);
963
- 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) {
964
1227
  return {
965
- name,
966
- action: "skipped",
967
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)`
968
1229
  };
969
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;
970
1244
  let backedUp;
971
- if (local !== target.hash || isLink(to)) {
972
- mkdirSync5(join8(root, primary), { recursive: true });
1245
+ const copied = local !== target.hash || isLink(to);
1246
+ if (copied) {
1247
+ mkdirSync4(join8(root, primary), { recursive: true });
973
1248
  if (isLink(to)) unlinkSync(to);
974
- else if (local !== null && !knownContent) backedUp = stash(to, "edit-backup");
1249
+ else if (local !== null && versionForHash(name, local) === null) backedUp = stash(to, "edit-backup");
975
1250
  copySkill(versionDir(name, target.version), to);
976
1251
  }
977
1252
  const links = [];
978
1253
  const blocked = [];
979
1254
  const uncommitted = [];
1255
+ const inTheWay = [];
980
1256
  let shared;
981
- for (const dir of linkDirs) {
1257
+ let relinked = false;
1258
+ for (const dir of wantedLinks) {
982
1259
  const link2 = join8(root, dir, name);
983
- if (isLink(link2) && realpathOrNull2(link2) === realpathOrNull2(to)) {
1260
+ const slot = slotAt(link2, to);
1261
+ if (slot === "ok") {
984
1262
  links.push(dir);
985
1263
  continue;
986
1264
  }
987
- 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
+ }
988
1270
  if (!recorded?.links?.includes(dir) && isGitTracked(root, dir)) {
989
1271
  if (!(shared ??= sharedByGit(root, primary, name))) {
990
1272
  uncommitted.push(dir);
@@ -995,9 +1277,11 @@ function addSkill(root, name, { force = false, version, allowTracked = false } =
995
1277
  continue;
996
1278
  }
997
1279
  }
998
- mkdirSync5(join8(root, dir), { recursive: true });
1280
+ if (isLink(link2)) unlinkSync(link2);
1281
+ mkdirSync4(join8(root, dir), { recursive: true });
999
1282
  linkDir(to, link2);
1000
1283
  links.push(dir);
1284
+ relinked = true;
1001
1285
  }
1002
1286
  manifest.skills[name] = {
1003
1287
  version: target.version,
@@ -1006,13 +1290,21 @@ function addSkill(root, name, { force = false, version, allowTracked = false } =
1006
1290
  ...links.length ? { links } : {}
1007
1291
  };
1008
1292
  writeManifest(root, manifest);
1293
+ const from = recorded ? versionForHash(name, recorded.hash)?.version ?? recorded.version : void 0;
1009
1294
  return {
1010
1295
  name,
1011
- action: !recorded && local === null ? "installed" : backedUp && recorded?.version === target.version ? "reset" : "updated",
1012
- ...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 } : {},
1013
1304
  to: target.version,
1014
1305
  ...blocked.length ? { blocked } : {},
1015
1306
  ...uncommitted.length ? { uncommitted } : {},
1307
+ ...inTheWay.length ? { inTheWay } : {},
1016
1308
  ...backedUp ? { backedUp } : {}
1017
1309
  };
1018
1310
  }
@@ -1020,54 +1312,113 @@ function removeSkill(root, name, { force = false } = {}) {
1020
1312
  const manifest = readManifest(root);
1021
1313
  const recorded = manifest.skills[name];
1022
1314
  if (!recorded) return { name, action: "skipped", reason: "not managed by skilllib in this project" };
1023
- const dir = join8(root, recorded.dir ?? PROJECT_SKILLS_DIR, name);
1024
- const local = treeHash(dir);
1025
- const edited = local !== null && local !== recorded.hash && !versionForHash(name, local);
1026
- if (edited && !force) return { name, action: "skipped", reason: "has local edits (use --force to delete anyway)" };
1027
- for (const linkDir2 of recorded.links ?? []) {
1028
- const link2 = join8(root, linkDir2, name);
1029
- if (isLink(link2)) unlinkSync(link2);
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("; ") };
1030
1339
  }
1031
- const backedUp = edited ? stash(dir, "edit-backup") : void 0;
1032
- if (!backedUp) rmSync2(dir, { recursive: true, force: true });
1340
+ const local = treeHash(dir);
1341
+ const inLibrary = local !== null && versionForHash(name, local) !== null;
1342
+ const edited = local !== null && local !== recorded.hash && !inLibrary;
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();
1351
+ const backedUp = local !== null && !inLibrary ? stash(dir, "edit-backup") : void 0;
1352
+ if (!backedUp) rmSync3(dir, { recursive: true, force: true });
1033
1353
  delete manifest.skills[name];
1034
1354
  writeManifest(root, manifest);
1035
- return { name, action: "removed", ...backedUp ? { backedUp } : {} };
1355
+ const left = [...leftLinks, ...takenLinks];
1356
+ return { name, action: "removed", ...left.length ? { reason: left.join("; ") } : {}, ...backedUp ? { backedUp } : {} };
1036
1357
  }
1037
- function syncProject(root, { force = false } = {}) {
1038
- 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 }));
1039
1360
  }
1040
1361
  function syncSkill(root, name, { force = false } = {}) {
1041
1362
  const dep = readManifest(root).skills[name];
1042
1363
  if (!dep) return [];
1043
- const local = treeHash(join8(root, dep.dir ?? PROJECT_SKILLS_DIR, name));
1044
- const linksOk = (dep.links ?? []).every((d) => existsSync7(join8(root, d, name, "SKILL.md")));
1045
- if (local === dep.hash && linksOk) return [];
1046
- if (local !== null && local !== dep.hash && !force) return [{ name, action: "skipped", reason: "has local edits (use --force to reset them)" }];
1047
- const change = addSkill(root, name, { force: true, version: pinnedVersion(name, dep) });
1048
- if (change.action === "skipped") return [{ ...change, reason: `not in your library (${tildify(libraryDir())}); import it, or set SKILLLIB_HOME to the library that has it` }];
1049
- return [{ ...change, action: local === null ? "installed" : change.action }];
1364
+ const copy = join8(root, dep.dir ?? PROJECT_SKILLS_DIR, name);
1365
+ const local = treeHash(copy);
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 [];
1369
+ if (local === dep.hash) return [relinkPinned(root, name, dep)];
1370
+ const version = pinnedVersion(name, dep);
1371
+ if (typeof version === "string") return [{ name, action: "skipped", reason: version }];
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
+ ];
1388
+ }
1389
+ function relinkPinned(root, name, dep) {
1390
+ const to = join8(root, dep.dir ?? PROJECT_SKILLS_DIR, name);
1391
+ for (const dir of dep.links ?? []) {
1392
+ const link2 = join8(root, dir, name);
1393
+ if (realpathOrNull(link2) === realpathOrNull(to)) continue;
1394
+ if (isLink(link2)) unlinkSync(link2);
1395
+ else if (entryExists(link2)) return { name, action: "skipped", reason: `${dir}/${name} is in the way of its link: move it, then sync` };
1396
+ mkdirSync4(join8(root, dir), { recursive: true });
1397
+ linkDir(to, link2);
1398
+ }
1399
+ return { name, action: "relinked", to: versionForHash(name, dep.hash)?.version ?? dep.version };
1050
1400
  }
1051
1401
  function pinnedVersion(name, dep) {
1052
- return versionForHash(name, dep.hash)?.version ?? (dep.version > 0 && getVersion(name, dep.version) ? dep.version : void 0);
1402
+ if (!latestVersion(name)) return `not in your library (${tildify(libraryDir())}); import it, or set SKILLLIB_HOME to the library that has it`;
1403
+ return versionForHash(name, dep.hash)?.version ?? `your library has no copy of the version skilllib.json pins (pinned from another library?); skilllib update ${name} installs your newest`;
1053
1404
  }
1054
1405
  function discardEdits(root, name) {
1055
1406
  const dep = readManifest(root).skills[name];
1056
1407
  if (!dep) return { name, action: "skipped", reason: "not managed by skilllib in this project" };
1057
- const dir = join8(root, dep.dir ?? PROJECT_SKILLS_DIR, name);
1058
- if (existsSync7(dir) && !isLink(dir)) stash(dir, "edit-backup");
1059
- return addSkill(root, name, { force: true, version: pinnedVersion(name, dep) });
1408
+ const version = pinnedVersion(name, dep);
1409
+ if (typeof version === "string") return { name, action: "skipped", reason: version };
1410
+ return addSkill(root, name, { force: true, version });
1060
1411
  }
1061
- function updateProject(root, names, { force = false } = {}) {
1412
+ function updateProject(root, names, { force = false, allowTracked = false } = {}) {
1062
1413
  const { skills } = readManifest(root);
1063
1414
  return Object.keys(skills).filter((name) => !names || names.includes(name)).flatMap((name) => {
1064
1415
  const latest = latestVersion(name);
1065
- if (!latest) return [{ name, action: "skipped", reason: "no longer in the library" }];
1416
+ if (!latest) return [{ name, action: "skipped", reason: "not in your library" }];
1066
1417
  const dep = skills[name];
1067
1418
  const local = treeHash(join8(root, dep.dir ?? PROJECT_SKILLS_DIR, name));
1068
1419
  if (dep.hash === latest.hash && local === latest.hash) return [];
1069
1420
  if (dep.hash === latest.hash && local !== null && !force) return [];
1070
- const change = addSkill(root, name, { force });
1421
+ const change = addSkill(root, name, { force, allowTracked });
1071
1422
  return change.action === "skipped" || change.from !== change.to || local !== latest.hash ? [change] : [];
1072
1423
  });
1073
1424
  }
@@ -1078,8 +1429,12 @@ function importSkill(dir, { force = false } = {}) {
1078
1429
  const incoming = treeHash(dir);
1079
1430
  if (existing === incoming) return { name, status: "unchanged" };
1080
1431
  if (existing !== null && !force) return { name, status: "exists" };
1081
- mkdirSync5(libraryDir(), { recursive: true });
1432
+ mkdirSync4(libraryDir(), { recursive: true });
1082
1433
  const origin = existing !== null && libraryOrigins()[name] ? null : originFor(dir);
1434
+ if (existing !== null) {
1435
+ forgetLatest(name);
1436
+ latestVersion(name);
1437
+ }
1083
1438
  copySkill(dir, to);
1084
1439
  if (origin) recordOrigin(name, origin);
1085
1440
  forgetLatest(name);
@@ -1090,19 +1445,15 @@ function backupIndexFile(bucket) {
1090
1445
  return join8(skilllibHome(), bucket, "index.json");
1091
1446
  }
1092
1447
  function readBackupIndex(bucket) {
1093
- try {
1094
- return JSON.parse(readFileSync6(backupIndexFile(bucket), "utf-8"));
1095
- } catch {
1096
- return {};
1097
- }
1448
+ return readState(backupIndexFile(bucket)) ?? {};
1098
1449
  }
1099
1450
  function move(from, to) {
1100
1451
  try {
1101
- renameSync(from, to);
1452
+ renameSync2(from, to);
1102
1453
  } catch (err) {
1103
1454
  if (err.code !== "EXDEV") throw err;
1104
1455
  cpSync2(from, to, { recursive: true, verbatimSymlinks: true });
1105
- rmSync2(from, { recursive: true, force: true });
1456
+ rmSync3(from, { recursive: true, force: true });
1106
1457
  }
1107
1458
  }
1108
1459
  function stash(dir, bucket) {
@@ -1111,17 +1462,21 @@ function stash(dir, bucket) {
1111
1462
  while (entryExists(join8(skilllibHome(), bucket, entryAt(at)))) at++;
1112
1463
  const entry = entryAt(at);
1113
1464
  const target = join8(skilllibHome(), bucket, entry);
1114
- mkdirSync5(join8(skilllibHome(), bucket), { recursive: true });
1465
+ const index = readBackupIndex(bucket);
1466
+ mkdirSync4(join8(skilllibHome(), bucket), { recursive: true });
1115
1467
  move(dir, target);
1116
- writeFileSync5(backupIndexFile(bucket), JSON.stringify({ ...readBackupIndex(bucket), [entry]: dir }, null, 2) + "\n");
1468
+ writeState(backupIndexFile(bucket), { ...index, [entry]: dir });
1117
1469
  return target;
1118
1470
  }
1119
1471
  function removeUntracked(root, name, path) {
1120
- unlinkEverywhere(root, name, path);
1121
- 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;
1122
1477
  }
1123
1478
  function projectsUsing(name) {
1124
- return knownProjects().filter((p) => name in readManifest(p).skills);
1479
+ return knownProjects().filter((p) => pinsSkill(p, name));
1125
1480
  }
1126
1481
  function deleteLibrarySkill(name) {
1127
1482
  const using = projectsUsing(name);
@@ -1150,13 +1505,13 @@ function linkGlobal(path, agents) {
1150
1505
  const skipped = [];
1151
1506
  for (const dir of new Set(agents.map((id) => harness(id).globalDirs()[0]))) {
1152
1507
  const link2 = join8(dir, name);
1153
- if (isLink(link2) && realpathOrNull2(link2) === null) unlinkSync(link2);
1508
+ if (isLink(link2) && realpathOrNull(link2) === null) unlinkSync(link2);
1154
1509
  else if (entryExists(link2)) {
1155
- if (realpathOrNull2(link2) !== realpathOrNull2(path)) skipped.push(link2);
1510
+ if (realpathOrNull(link2) !== realpathOrNull(path)) skipped.push(link2);
1156
1511
  continue;
1157
1512
  }
1158
- mkdirSync5(dir, { recursive: true });
1159
- linkDir(path, link2);
1513
+ mkdirSync4(dir, { recursive: true });
1514
+ linkDir(realpathOrNull(path) ?? path, join8(realpathOrNull(dir) ?? dir, name));
1160
1515
  linked.push(link2);
1161
1516
  }
1162
1517
  return { linked, skipped };
@@ -1165,12 +1520,13 @@ function createSkill(name, description) {
1165
1520
  if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) return { ok: false, reason: "use lowercase letters, digits and dashes" };
1166
1521
  const dir = librarySkillDir(name);
1167
1522
  if (existsSync7(dir)) return { ok: false, reason: `${name} already exists` };
1168
- mkdirSync5(dir, { recursive: true });
1169
- writeFileSync5(
1523
+ mkdirSync4(dir, { recursive: true });
1524
+ writeFileSync2(
1170
1525
  join8(dir, "SKILL.md"),
1526
+ // JSON strings are valid YAML: a description with ": " or "#" stays one value.
1171
1527
  `---
1172
1528
  name: ${name}
1173
- description: ${description || "Describe when an agent should use this skill."}
1529
+ description: ${JSON.stringify(description || "Describe when an agent should use this skill.")}
1174
1530
  ---
1175
1531
 
1176
1532
  # ${name}
@@ -1190,26 +1546,28 @@ function listBackups() {
1190
1546
  const match = entry.match(/^(.*)-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-\d{3}Z$/);
1191
1547
  if (!match) return [];
1192
1548
  const name = match[1];
1193
- const from = index[entry] ?? (kind === "trash" ? librarySkillDir(name) : join8(claudeDir(), "skills", name));
1194
- return [{ name, kind, path: join8(dir, entry), movedAt: `${match[2]} ${match[3]}:${match[4]}`, from }];
1549
+ const from = index[entry] ?? (kind === "trash" ? librarySkillDir(name) : kind === "global-backup" ? join8(claudeDir(), "skills", name) : "");
1550
+ return [{ name, kind, path: join8(dir, entry), movedAt: localStamp(Date.parse(`${match[2]}T${match[3]}:${match[4]}:${match[5]}Z`)), from }];
1195
1551
  });
1196
1552
  }).sort((a, b) => b.path.slice(-24).localeCompare(a.path.slice(-24)));
1197
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
+ }
1198
1559
  function backupFrom(backup) {
1199
1560
  if (backup.kind === "plugin") return "Claude Code plugin";
1200
- return backup.from === librarySkillDir(backup.name) ? "library (deleted)" : tildify(dirname2(backup.from));
1561
+ if (!backup.from) return "unknown";
1562
+ return backup.from === librarySkillDir(backup.name) ? "library (deleted)" : tildify(dirname3(backup.from));
1201
1563
  }
1202
1564
  function restoreBackup(backup) {
1203
1565
  const to = backup.from;
1566
+ if (!to) return { ok: false, reason: `skilllib doesn't know where it came from; its files are in ${tildify(backup.path)}` };
1204
1567
  if (backup.kind === "tidy-backup" && isLink(to)) unlinkSync(to);
1205
- if (backup.kind === "edit-backup" && !isLink(to) && versionForHash(backup.name, treeHash(to) ?? "") !== null) rmSync2(to, { recursive: true, force: true });
1206
- let taken = existsSync7(to);
1207
- try {
1208
- taken = taken || lstatSync2(to) !== null;
1209
- } catch {
1210
- }
1211
- if (taken) return { ok: false, reason: `${backup.name} already exists there` };
1212
- mkdirSync5(join8(to, ".."), { recursive: true });
1568
+ if (backup.kind === "edit-backup" && !isLink(to) && versionForHash(backup.name, treeHash(to) ?? "") !== null) rmSync3(to, { recursive: true, force: true });
1569
+ if (entryExists(to)) return { ok: false, reason: `${backup.name} already exists there` };
1570
+ mkdirSync4(join8(to, ".."), { recursive: true });
1213
1571
  move(backup.path, to);
1214
1572
  forgetLatest(backup.name);
1215
1573
  return { ok: true, to };
@@ -1218,56 +1576,51 @@ function restoreBackup(backup) {
1218
1576
  // src/agentSkill.ts
1219
1577
  var AGENT_SKILL_NAME = "skilllib";
1220
1578
  function shippedSkill() {
1221
- return readFileSync7(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");
1222
1580
  }
1223
1581
  function agentSkillDirs(enabled = enabledHarnesses()) {
1224
1582
  const claudeSkills = join9(claudeDir(), "skills");
1225
1583
  const agentsSkills = join9(userHome(), ".agents", "skills");
1226
1584
  const needsClaude = enabled.some((id) => harness(id).globalDirs().includes(claudeSkills));
1227
1585
  const needsAgents = enabled.some((id) => !harness(id).globalDirs().includes(claudeSkills));
1228
- 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));
1229
1588
  }
1230
1589
  function isOurs(dir) {
1231
1590
  return existsSync8(join9(dir, OWN_SKILL_MARKER));
1232
1591
  }
1233
1592
  function isDangling(path) {
1234
- return entryExists2(path) && lstatSync3(path).isSymbolicLink() && !existsSync8(path);
1235
- }
1236
- function entryExists2(path) {
1237
- try {
1238
- lstatSync3(path);
1239
- return true;
1240
- } catch {
1241
- return false;
1242
- }
1593
+ return entryExists(path) && lstatSync3(path).isSymbolicLink() && !existsSync8(path);
1243
1594
  }
1244
1595
  function agentSkillState(enabled = enabledHarnesses()) {
1245
1596
  const [primary, ...links] = agentSkillDirs(enabled);
1246
1597
  if (!primary || !isOurs(primary) || links.some((l) => !existsSync8(join9(l, "SKILL.md")))) return "missing";
1247
- return readFileSync7(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";
1248
1599
  }
1249
1600
  function installAgentSkill(enabled = enabledHarnesses()) {
1250
1601
  const [primary, ...links] = agentSkillDirs(enabled);
1251
1602
  if (!primary) return { ok: false, reason: "no agents chosen yet (skilllib harnesses <id>...)" };
1252
- 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));
1253
1604
  if (foreign) return { ok: false, reason: `${tildify(foreign)} already exists and isn't skilllib's` };
1254
1605
  for (const d of [primary, ...links]) if (isDangling(d)) unlinkSync2(d);
1255
- mkdirSync6(primary, { recursive: true });
1256
- writeFileSync6(join9(primary, "SKILL.md"), shippedSkill());
1257
- writeFileSync6(join9(primary, OWN_SKILL_MARKER), "Installed by skilllib. `skilllib agent-skill remove` removes it.\n");
1606
+ mkdirSync5(primary, { recursive: true });
1607
+ writeFileSync3(join9(primary, "SKILL.md"), shippedSkill());
1608
+ writeFileSync3(join9(primary, OWN_SKILL_MARKER), "Installed by skilllib. `skilllib agent-skill remove` removes it.\n");
1258
1609
  for (const link2 of links) {
1259
- if (entryExists2(link2)) continue;
1260
- mkdirSync6(join9(link2, ".."), { recursive: true });
1610
+ if (entryExists(link2) && !isLink(link2) && isOurs(link2)) rmSync4(link2, { recursive: true, force: true });
1611
+ if (entryExists(link2)) continue;
1612
+ mkdirSync5(join9(link2, ".."), { recursive: true });
1261
1613
  linkDir(primary, link2);
1262
1614
  }
1263
1615
  return { ok: true, dirs: [primary, ...links] };
1264
1616
  }
1265
1617
  function removeAgentSkill() {
1266
1618
  const all = agentSkillDirs(["claude-code", "codex"]);
1267
- const removed = all.filter((d) => entryExists2(d) && (isOurs(d) || isDangling(d)));
1619
+ const removed = all.filter((d) => entryExists(d) && (isOurs(d) || isDangling(d)));
1268
1620
  for (const d of removed) {
1621
+ if (!entryExists(d)) continue;
1269
1622
  if (lstatSync3(d).isSymbolicLink()) unlinkSync2(d);
1270
- else rmSync3(d, { recursive: true, force: true });
1623
+ else rmSync4(d, { recursive: true, force: true });
1271
1624
  }
1272
1625
  return removed;
1273
1626
  }
@@ -1279,14 +1632,14 @@ function refreshAgentSkill() {
1279
1632
  }
1280
1633
 
1281
1634
  // src/commands.ts
1282
- import { existsSync as existsSync13, readFileSync as readFileSync9 } from "fs";
1635
+ import { existsSync as existsSync14, readFileSync as readFileSync7 } from "fs";
1283
1636
  import { parseArgs as parseFlags } from "util";
1284
- import { basename as basename9, join as join14, resolve as resolve7, sep as sep5 } from "path";
1637
+ import { basename as basename9, join as join15, resolve as resolve8, sep as sep8 } from "path";
1285
1638
 
1286
1639
  // src/git.ts
1287
1640
  import { execFileSync as execFileSync2 } from "child_process";
1288
- import { existsSync as existsSync9 } from "fs";
1289
- 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";
1290
1643
  function git(root, args, input) {
1291
1644
  try {
1292
1645
  return execFileSync2("git", args, { cwd: root, encoding: "utf-8", input, stdio: ["pipe", "pipe", "ignore"], maxBuffer: 16 * 1024 * 1024 });
@@ -1296,11 +1649,32 @@ function git(root, args, input) {
1296
1649
  }
1297
1650
  }
1298
1651
  function gitInfo(root, extraDirs = []) {
1299
- if (!existsSync9(join10(root, ".git"))) return null;
1300
- 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())];
1301
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
+ };
1302
1676
  const tracked = (git(root, ["ls-files", "-z", "--", ...dirs]) ?? "").split("\0").filter(Boolean);
1303
- 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));
1304
1678
  const ignoredCache = /* @__PURE__ */ new Map();
1305
1679
  const isIgnored = (rel) => {
1306
1680
  if (!ignoredCache.has(rel)) {
@@ -1312,7 +1686,7 @@ function gitInfo(root, extraDirs = []) {
1312
1686
  const under = (list2, rel) => list2.some((p) => p === rel || p.startsWith(rel + "/"));
1313
1687
  return {
1314
1688
  of: (relPath) => {
1315
- const rel = relPath.replace(/\/+$/, "");
1689
+ const rel = placeOf(relPath.replace(/\/+$/, ""));
1316
1690
  if (under(tracked, rel)) return under(dirty, rel) ? "changed" : "committed";
1317
1691
  return isIgnored(rel) ? "ignored" : "new";
1318
1692
  }
@@ -1323,24 +1697,17 @@ function relativeTo(root, path) {
1323
1697
  }
1324
1698
 
1325
1699
  // src/here.ts
1326
- import { readdirSync as readdirSync7 } from "fs";
1700
+ import { readdirSync as readdirSync8 } from "fs";
1327
1701
  import { basename as basename7 } from "path";
1328
1702
 
1329
1703
  // src/health.ts
1330
- import { lstatSync as lstatSync4 } from "fs";
1331
- import { basename as basename6, dirname as dirname5 } 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";
1332
1706
 
1333
1707
  // src/plugins.ts
1334
1708
  import { execFileSync as execFileSync3 } from "child_process";
1335
- import { existsSync as existsSync10, mkdirSync as mkdirSync7, readdirSync as readdirSync5, readFileSync as readFileSync8, statSync as statSync3, writeFileSync as writeFileSync7 } from "fs";
1336
- import { basename as basename4, dirname as dirname3, join as join11, resolve as resolve6 } from "path";
1337
- function readJson2(path) {
1338
- try {
1339
- return JSON.parse(readFileSync8(path, "utf-8"));
1340
- } catch {
1341
- return null;
1342
- }
1343
- }
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";
1344
1711
  function nonEmptyDir(path) {
1345
1712
  try {
1346
1713
  return readdirSync5(path).some((f) => !f.startsWith("."));
@@ -1349,7 +1716,7 @@ function nonEmptyDir(path) {
1349
1716
  }
1350
1717
  }
1351
1718
  function extrasOf(root) {
1352
- const manifest = readJson2(join11(root, ".claude-plugin", "plugin.json")) ?? {};
1719
+ const manifest = readJson(join11(root, ".claude-plugin", "plugin.json")) ?? {};
1353
1720
  return [
1354
1721
  ...existsSync10(join11(root, ".mcp.json")) || manifest.mcpServers ? ["MCP servers"] : [],
1355
1722
  ...existsSync10(join11(root, "hooks", "hooks.json")) || manifest.hooks ? ["hooks"] : [],
@@ -1358,21 +1725,22 @@ function extrasOf(root) {
1358
1725
  ];
1359
1726
  }
1360
1727
  function claudePlugins(machine, root) {
1361
- const installed = readJson2(join11(claudeDir(), "plugins", "installed_plugins.json"))?.plugins ?? {};
1728
+ const installed = installedPluginsJson();
1362
1729
  const byOrigin = /* @__PURE__ */ new Map();
1363
1730
  for (const m of machine) if (m.kind === "plugin") byOrigin.set(m.origin, [...byOrigin.get(m.origin) ?? [], m.path]);
1364
1731
  return [...byOrigin].map(([origin, skills]) => {
1365
1732
  const synced = origin.endsWith("@synced");
1366
- const id = origin;
1367
1733
  const entries = installed[origin] ?? [];
1368
1734
  const entry = entries.find((e) => root && e.projectPath === root) ?? entries.find((e) => (e.scope ?? "user") === "user") ?? entries[0];
1369
1735
  return {
1370
- id,
1736
+ id: origin,
1371
1737
  scope: synced ? null : entry?.scope ?? "user",
1372
1738
  ...entry?.projectPath ? { projectPath: entry.projectPath } : {},
1373
1739
  synced,
1740
+ // Turned on in your settings with no install record (as installedPlugins finds it).
1741
+ ...!synced && !entries.length ? { settingsOnly: true } : {},
1374
1742
  skills,
1375
- extras: extrasOf(dirname3(dirname3(skills[0])))
1743
+ extras: extrasOf(dirname4(dirname4(skills[0])))
1376
1744
  };
1377
1745
  });
1378
1746
  }
@@ -1385,10 +1753,10 @@ function newer(a, b) {
1385
1753
  }
1386
1754
  function updateOf(name, marketplace, entry, catalogs) {
1387
1755
  const dir = join11(claudeDir(), "plugins", "marketplaces", marketplace);
1388
- if (!catalogs.has(marketplace)) catalogs.set(marketplace, readJson2(join11(dir, ".claude-plugin", "marketplace.json"))?.plugins ?? []);
1756
+ if (!catalogs.has(marketplace)) catalogs.set(marketplace, readJson(join11(dir, ".claude-plugin", "marketplace.json"))?.plugins ?? []);
1389
1757
  const listed = catalogs.get(marketplace).find((p) => p.name === name);
1390
1758
  if (!listed) return void 0;
1391
- const version = listed.version ?? (typeof listed.source === "string" ? readJson2(join11(dir, listed.source, ".claude-plugin", "plugin.json"))?.version : void 0);
1759
+ const version = listed.version ?? (typeof listed.source === "string" ? readJson(join11(dir, listed.source, ".claude-plugin", "plugin.json"))?.version : void 0);
1392
1760
  if (version && entry.version) return newer(version, entry.version) ? version : void 0;
1393
1761
  const sha = typeof listed.source === "object" ? listed.source?.sha : void 0;
1394
1762
  return sha && entry.gitCommitSha && sha !== entry.gitCommitSha ? "newer" : void 0;
@@ -1398,12 +1766,12 @@ function pluginRoot(id, entry) {
1398
1766
  return candidates.find((c) => existsSync10(join11(c, "skills"))) ?? candidates.find((c) => existsSync10(c)) ?? candidates[0];
1399
1767
  }
1400
1768
  function describe(root) {
1401
- const manifest = readJson2(join11(root, ".claude-plugin", "plugin.json")) ?? {};
1769
+ const manifest = readJson(join11(root, ".claude-plugin", "plugin.json")) ?? {};
1402
1770
  return { description: manifest.description ?? "", version: manifest.version, skills: skillDirsIn(join11(root, "skills")), extras: extrasOf(root) };
1403
1771
  }
1404
1772
  function installedPlugins() {
1405
1773
  const plugins = join11(claudeDir(), "plugins");
1406
- const installed = readJson2(join11(plugins, "installed_plugins.json"))?.plugins ?? {};
1774
+ const installed = installedPluginsJson();
1407
1775
  const catalogs = /* @__PURE__ */ new Map();
1408
1776
  const fromMarketplaces = Object.entries(installed).flatMap(
1409
1777
  ([id, entries]) => entries.map((entry) => {
@@ -1435,18 +1803,18 @@ function installedPlugins() {
1435
1803
  const syncedDir = join11(plugins, "synced");
1436
1804
  const dirs = (p) => existsSync10(p) ? readdirSync5(p, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".")).map((d) => join11(p, d.name)) : [];
1437
1805
  const fromSynced = dirs(syncedDir).flatMap(dirs).map((root) => {
1438
- const id = `${readJson2(join11(root, ".claude-plugin", "plugin.json"))?.name ?? basename4(root)}@synced`;
1806
+ const id = `${readJson(join11(root, ".claude-plugin", "plugin.json"))?.name ?? basename4(root)}@synced`;
1439
1807
  return { id, scope: null, synced: true, agent: "claude-code", on: user[id] !== false, root, ...describe(root) };
1440
1808
  });
1441
1809
  return [...fromMarketplaces, ...fromSettings, ...fromSynced];
1442
1810
  }
1443
1811
  function cursorPlugins() {
1444
1812
  const cache = join11(userHome(), ".cursor", "plugins", "cache");
1445
- const dirs = (p) => existsSync10(p) ? readdirSync5(p).filter((d) => !d.startsWith(".") && statSync3(join11(p, d)).isDirectory()) : [];
1813
+ const dirs = (p) => existsSync10(p) ? readdirSync5(p).filter((d) => !d.startsWith(".") && statSync4(join11(p, d)).isDirectory()) : [];
1446
1814
  return dirs(cache).flatMap(
1447
1815
  (marketplace) => dirs(join11(cache, marketplace)).flatMap((plugin) => {
1448
1816
  const versions = dirs(join11(cache, marketplace, plugin)).map((v) => join11(cache, marketplace, plugin, v));
1449
- const root = versions.sort((a, b) => statSync3(b).mtimeMs - statSync3(a).mtimeMs)[0];
1817
+ const root = versions.sort((a, b) => statSync4(b).mtimeMs - statSync4(a).mtimeMs)[0];
1450
1818
  if (!root) return [];
1451
1819
  const about = describe(root);
1452
1820
  return [{ id: `${plugin}@${marketplace}`, scope: null, synced: false, agent: "cursor", on: null, root, ...about, version: about.version ?? basename4(root) }];
@@ -1485,11 +1853,10 @@ function claude(args, cwd, bin = claudeBinary()) {
1485
1853
  }
1486
1854
  }
1487
1855
  function setPluginIn(file, id, on) {
1488
- const settings = existsSync10(file) ? readJson2(file) : {};
1856
+ const settings = existsSync10(file) ? readJson(file) : {};
1489
1857
  if (!settings) return { ok: false, reason: `can't read ${file}` };
1490
1858
  const enabled = settings.enabledPlugins ?? {};
1491
- mkdirSync7(dirname3(file), { recursive: true });
1492
- writeFileSync7(file, JSON.stringify({ ...settings, enabledPlugins: { ...enabled, [id]: on } }, null, 2) + "\n");
1859
+ writeState(file, { ...settings, enabledPlugins: { ...enabled, [id]: on } });
1493
1860
  return { ok: true };
1494
1861
  }
1495
1862
  function ignoreLocalSettings(root) {
@@ -1511,10 +1878,10 @@ function ignoreLocalSettings(root) {
1511
1878
  } catch {
1512
1879
  }
1513
1880
  try {
1514
- const exclude = resolve6(root, git2("rev-parse", "--git-path", "info/exclude"));
1515
- const before = existsSync10(exclude) ? readFileSync8(exclude, "utf-8") : "";
1516
- mkdirSync7(dirname3(exclude), { recursive: true });
1517
- writeFileSync7(exclude, `${before}${before && !before.endsWith("\n") ? "\n" : ""}/${git2("rev-parse", "--show-prefix")}${file}
1881
+ const exclude = resolve7(root, git2("rev-parse", "--git-path", "info/exclude"));
1882
+ const before = existsSync10(exclude) ? readFileSync6(exclude, "utf-8") : "";
1883
+ mkdirSync6(dirname4(exclude), { recursive: true });
1884
+ writeFileSync4(exclude, `${before}${before && !before.endsWith("\n") ? "\n" : ""}/${git2("rev-parse", "--show-prefix")}${file}
1518
1885
  `);
1519
1886
  return ", which git now ignores via .git/info/exclude";
1520
1887
  } catch {
@@ -1523,7 +1890,7 @@ function ignoreLocalSettings(root) {
1523
1890
  }
1524
1891
  function turnOffIn(plugin, root) {
1525
1892
  const done = `${plugin.id} turned off in ${basename4(root)}; other repos keep it`;
1526
- const bin = plugin.synced ? null : claudeBinary();
1893
+ const bin = plugin.synced || plugin.settingsOnly ? null : claudeBinary();
1527
1894
  if (!bin) {
1528
1895
  const r2 = setPluginIn(join11(root, ".claude", "settings.local.json"), plugin.id, false);
1529
1896
  return r2.ok ? { ok: true, message: `${done} (in .claude/settings.local.json${ignoreLocalSettings(root)})` } : { ok: false, message: `${plugin.id}: ${r2.reason}` };
@@ -1540,7 +1907,8 @@ function whereOf(p) {
1540
1907
  return p.projectPath ? ` in ${basename4(p.projectPath)} (${p.scope})` : "";
1541
1908
  }
1542
1909
  function setPluginOn(p, on) {
1543
- 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}` };
1544
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` };
1545
1913
  const bin = p.synced || p.settingsOnly ? null : claudeBinary();
1546
1914
  if (bin) {
@@ -1549,7 +1917,8 @@ function setPluginOn(p, on) {
1549
1917
  }
1550
1918
  const r = setPluginIn(settingsFileOf(p), p.id, on);
1551
1919
  if (!r.ok) return { ok: false, message: `${p.id}: ${r.reason}` };
1552
- 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;
1553
1922
  }
1554
1923
  function uninstallPlugin(p) {
1555
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` };
@@ -1579,35 +1948,44 @@ function removedFile() {
1579
1948
  return join11(skilllibHome(), "plugin-backup.json");
1580
1949
  }
1581
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` };
1582
1952
  if (plugin.scope && plugin.scope !== "user" && !plugin.projectPath) {
1583
1953
  return { ok: false, message: `${plugin.id}: installed for one project; run \`claude plugin ${how === "delete" ? "uninstall" : "disable"} ${plugin.id} --scope ${plugin.scope}\` there` };
1584
1954
  }
1585
- if (!plugin.synced && !claudeBinary()) {
1955
+ const viaSettings = plugin.synced || plugin.settingsOnly || !claudeBinary() && how === "off" && !plugin.projectPath;
1956
+ if (!viaSettings && !claudeBinary()) {
1586
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)` };
1587
1958
  }
1588
- const imported = plugin.skills.filter((dir) => latestVersion(basename4(dir)) === null).map((dir) => importSkill(dir).name);
1959
+ const wasOn = enabledPlugins(plugin.projectPath)[plugin.id] === true;
1960
+ const yours = new Set(machineSkills().filter((m) => m.movable).map((m) => m.name));
1961
+ const imported = plugin.skills.filter((dir) => !yours.has(basename4(dir)) && latestVersion(basename4(dir)) === null).map((dir) => importSkill(dir).name);
1589
1962
  const scope = plugin.scope ? ["--scope", plugin.scope] : [];
1590
- const r = plugin.synced ? (
1591
- // Synced plugins aren't installed, so `claude plugin` can't touch them: their switch is enabledPlugins.
1963
+ const r = viaSettings ? (
1964
+ // Synced and settings-only plugins aren't installed, so `claude plugin` can't touch them: their switch is enabledPlugins.
1592
1965
  setPluginIn(join11(claudeDir(), "settings.json"), plugin.id, false)
1593
- ) : claude(["plugin", how === "delete" ? "uninstall" : "disable", plugin.id, ...scope], plugin.projectPath);
1966
+ ) : (
1967
+ // --keep-data, as uninstallPlugin: its saved data stays, so a restore brings it back as it was.
1968
+ claude(["plugin", how === "delete" ? "uninstall" : "disable", plugin.id, ...how === "delete" ? ["--keep-data"] : [], ...scope], plugin.projectPath)
1969
+ );
1594
1970
  const saved = imported.length ? `; ${imported.join(", ")} copied into Your skills` : "";
1595
1971
  if (!r.ok) return { ok: false, message: `${plugin.id}: ${r.reason}${saved}` };
1596
- if (how === "delete" && !plugin.synced) recordRemovedPlugin(plugin.id, plugin.scope ?? "user", plugin.projectPath, true);
1597
- return { ok: true, message: `${plugin.id} ${how === "delete" && !plugin.synced ? "removed" : "turned off"}${saved}` };
1972
+ if (how === "delete" && !plugin.synced) recordRemovedPlugin(plugin.id, plugin.scope ?? "user", plugin.projectPath, wasOn);
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}` };
1598
1976
  }
1599
1977
  function recordRemovedPlugin(id, scope, projectPath, on = true) {
1600
- const list2 = readJson2(removedFile()) ?? [];
1601
- mkdirSync7(skilllibHome(), { recursive: true });
1602
- writeFileSync7(removedFile(), JSON.stringify([...list2, { id, scope, ...projectPath ? { projectPath } : {}, on, at: (/* @__PURE__ */ new Date()).toISOString() }], null, 2) + "\n");
1978
+ const list2 = readState(removedFile()) ?? [];
1979
+ mkdirSync6(skilllibHome(), { recursive: true });
1980
+ writeState(removedFile(), [...list2, { id, scope, ...projectPath ? { projectPath } : {}, on, at: (/* @__PURE__ */ new Date()).toISOString() }]);
1603
1981
  }
1604
1982
  function pluginBackups() {
1605
- return (readJson2(removedFile()) ?? []).map((r) => ({
1983
+ return (readState(removedFile()) ?? []).map((r) => ({
1606
1984
  name: r.id,
1607
1985
  kind: "plugin",
1608
1986
  // Project and local installs go back into their project.
1609
1987
  path: r.projectPath ? `${r.scope}:${r.projectPath}` : r.scope,
1610
- movedAt: r.at.slice(0, 16).replace("T", " "),
1988
+ movedAt: localStamp(Date.parse(r.at)),
1611
1989
  from: r.id
1612
1990
  }));
1613
1991
  }
@@ -1616,11 +1994,11 @@ function restorePlugin(backup) {
1616
1994
  const projectPath = rest.join(":") || void 0;
1617
1995
  const r = claude(["plugin", "install", backup.from, "--scope", scope], projectPath);
1618
1996
  if (!r.ok) return r;
1619
- const records = readJson2(removedFile()) ?? [];
1997
+ const records = readState(removedFile()) ?? [];
1620
1998
  const same = (x) => x.id === backup.from && (x.projectPath ?? void 0) === projectPath && x.scope === scope;
1621
1999
  const wasOff = records.filter(same).pop()?.on === false;
1622
2000
  const off = wasOff ? claude(["plugin", "disable", backup.from, "--scope", scope], projectPath) : null;
1623
- writeFileSync7(removedFile(), JSON.stringify(records.filter((x) => !same(x)), null, 2) + "\n");
2001
+ writeState(removedFile(), records.filter((x) => !same(x)));
1624
2002
  const where = `Claude Code plugins (${scope}${projectPath ? ` in ${basename4(projectPath)}` : ""}${wasOff ? off?.ok ? ", off as it was" : ", but it's on: turn it off from Plugins" : ""})`;
1625
2003
  return { ok: true, to: where };
1626
2004
  }
@@ -1629,14 +2007,14 @@ function cursorPluginSkills(plugins = cursorPlugins()) {
1629
2007
  }
1630
2008
 
1631
2009
  // src/tidy.ts
1632
- import { existsSync as existsSync11, mkdirSync as mkdirSync8, readdirSync as readdirSync6, unlinkSync as unlinkSync3 } from "fs";
1633
- import { basename as basename5, dirname as dirname4, join as join12, sep as sep4 } from "path";
2010
+ import { existsSync as existsSync11, mkdirSync as mkdirSync7, readdirSync as readdirSync6, unlinkSync as unlinkSync3 } from "fs";
2011
+ import { basename as basename5, dirname as dirname5, join as join12, sep as sep6 } from "path";
1634
2012
  function entriesOf(dirs, name) {
1635
2013
  return dirs.flatMap((dir) => {
1636
2014
  const path = join12(dir, name);
1637
2015
  if (!entryExists(path)) return [];
1638
2016
  const link2 = isLink(path);
1639
- const real = realpathOrNull2(path);
2017
+ const real = realpathOrNull(path);
1640
2018
  const skill = existsSync11(join12(path, "SKILL.md"));
1641
2019
  if (!link2 && !skill) return [];
1642
2020
  return [{ dir, path, link: link2, real, hash: null }];
@@ -1648,12 +2026,13 @@ function hashReals(reals) {
1648
2026
  function namesIn(dirs) {
1649
2027
  const names = /* @__PURE__ */ new Set();
1650
2028
  for (const dir of dirs) {
1651
- if (!existsSync11(dir)) continue;
2029
+ if (!isDir(dir)) continue;
1652
2030
  for (const name of readdirSync6(dir)) if (!name.startsWith(".") && name !== "synced") names.add(name);
1653
2031
  }
1654
2032
  return [...names].sort();
1655
2033
  }
1656
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);
1657
2036
  function globalTidyDirs() {
1658
2037
  const own = realPath(join12(grokDir(), "skills"));
1659
2038
  return globalSkillDirs().filter((d) => realPath(d) !== own);
@@ -1661,7 +2040,7 @@ function globalTidyDirs() {
1661
2040
  function runsFrom(id, dirsWithCopy, readsOf) {
1662
2041
  const reads = readsOf(id).filter((d) => dirsWithCopy.includes(d));
1663
2042
  if (id === "codex") return reads;
1664
- const pick = (d) => CURSOR_PICKS.indexOf(basename5(dirname4(d)));
2043
+ const pick = (d) => CURSOR_PICKS.indexOf(basename5(dirname5(d)));
1665
2044
  return id === "cursor" ? [...reads].sort((a, b) => pick(a) - pick(b)).slice(0, 1) : reads.slice(0, 1);
1666
2045
  }
1667
2046
  function conflictOf(name, root, reals, enabled, readsOf) {
@@ -1677,17 +2056,19 @@ function planProjectTidy(root, { keep = {}, enabled = enabledHarnesses() } = {})
1677
2056
  const { skills } = readManifest(root);
1678
2057
  const abs = (dir) => join12(root, dir);
1679
2058
  const rel = (dir) => relativeTo(root, dir);
1680
- const readsOf = (id) => HARNESSES.find((h) => h.id === id).projectDirs.map(abs);
2059
+ const readsOf = (id) => harness(id).projectDirs.map(abs);
1681
2060
  let git2;
1682
2061
  const skillsLock = projectSkillsLock(root);
1683
- for (const name of namesIn(PROJECT_TIDY_DIRS.map(abs))) {
1684
- const entries = entriesOf(PROJECT_TIDY_DIRS.map(abs), name);
1685
- const external = entries.find((e) => e.link && e.real && !(e.real + sep4).startsWith(realpathOrNull2(root) + sep4));
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));
1686
2066
  if (external) {
1687
- 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 } : {} });
1688
2069
  continue;
1689
2070
  }
1690
- const reals = entries.filter((e) => !e.link);
2071
+ const reals = entries.filter((e, i, all) => !e.link && all.findIndex((o) => !o.link && realpathOrNull(o.path) === realpathOrNull(e.path)) === i);
1691
2072
  if (reals.length === 0) continue;
1692
2073
  hashReals(reals);
1693
2074
  const dep = skills[name];
@@ -1710,17 +2091,24 @@ function planProjectTidy(root, { keep = {}, enabled = enabledHarnesses() } = {})
1710
2091
  report.conflicts.push({ ...conflictOf(name, root, reals, enabled, readsOf), ...dep ? { managed: primary.dir } : {} });
1711
2092
  continue;
1712
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
+ }
1713
2099
  const wanted = new Set([...installDirs(enabled, rel(primary.dir)), ...dep?.links ?? []].map(abs));
1714
2100
  const steps = [];
1715
2101
  for (const e of entries) {
1716
- if (e === primary) continue;
2102
+ if (e === primary || !e.link && e.real === primary.real) continue;
1717
2103
  const needed = wanted.has(e.dir);
1718
2104
  if (e.link) {
1719
2105
  if (e.real === primary.real) {
2106
+ if (!needed && !dep && ["committed", "changed"].includes((git2 ??= gitInfo(root))?.of(rel(e.path)) ?? "")) continue;
1720
2107
  if (!needed) steps.push({ kind: "unlink", path: e.path });
1721
2108
  } else steps.push(needed ? { kind: "link", path: e.path, target: primary.path } : { kind: "unlink", path: e.path });
1722
2109
  } else {
1723
2110
  const differs = e.hash !== primary.hash ? { differs: true } : {};
2111
+ if (!needed && ["committed", "changed"].includes((git2 ??= gitInfo(root))?.of(rel(e.path)) ?? "")) continue;
1724
2112
  steps.push(needed ? { kind: "replace", path: e.path, target: primary.path, ...differs } : { kind: "remove", path: e.path, ...differs });
1725
2113
  }
1726
2114
  }
@@ -1729,7 +2117,7 @@ function planProjectTidy(root, { keep = {}, enabled = enabledHarnesses() } = {})
1729
2117
  return report;
1730
2118
  }
1731
2119
  function globalReadsOf(id) {
1732
- return HARNESSES.find((h) => h.id === id).globalDirs().map(globalDirAs);
2120
+ return harness(id).globalDirs().map(globalDirAs);
1733
2121
  }
1734
2122
  function isSkillsDirPlugin(path) {
1735
2123
  return existsSync11(join12(path, ".claude-plugin", "plugin.json"));
@@ -1744,7 +2132,7 @@ function planGlobalTidy({ keep = {}, enabled = enabledHarnesses() } = {}) {
1744
2132
  report.skipped.push({ name, reason: "a Claude Code plugin kept in a skills folder" });
1745
2133
  continue;
1746
2134
  }
1747
- const reals = entries.filter((e, i, all) => !e.link && all.findIndex((o) => !o.link && realpathOrNull2(o.path) === realpathOrNull2(e.path)) === i);
2135
+ const reals = entries.filter((e, i, all) => !e.link && all.findIndex((o) => !o.link && realpathOrNull(o.path) === realpathOrNull(e.path)) === i);
1748
2136
  if (reals.length < 2) continue;
1749
2137
  hashReals(reals);
1750
2138
  const kept = reals.find((r) => r.dir === keep[name]);
@@ -1760,21 +2148,22 @@ function planGlobalTidy({ keep = {}, enabled = enabledHarnesses() } = {}) {
1760
2148
  }
1761
2149
  function gitVisibleSteps(plan, git2 = plan.root ? gitInfo(plan.root) : null) {
1762
2150
  if (!plan.root || !git2) return [];
1763
- return plan.steps.filter((s) => {
1764
- const state = git2.of(relativeTo(plan.root, s.path));
1765
- return state === "committed" || state === "changed";
1766
- });
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));
1767
2155
  }
1768
2156
  function applyTidy(plan, { git: git2 = "keep", info: info2 } = {}) {
1769
2157
  const held = git2 === "keep" ? gitVisibleSteps(plan, info2) : [];
1770
2158
  const applied = [];
1771
2159
  for (const step of plan.steps) {
1772
2160
  if (held.includes(step) || !entryExists(step.path) || "target" in step && !entryExists(step.target)) continue;
2161
+ if ("target" in step && realpathOrNull(step.path) === realpathOrNull(step.target)) continue;
1773
2162
  if (step.kind === "link" || step.kind === "unlink") {
1774
2163
  if (isLink(step.path)) unlinkSync3(step.path);
1775
2164
  } else stash(step.path, "tidy-backup");
1776
2165
  if (step.kind === "link" || step.kind === "replace") {
1777
- mkdirSync8(dirname4(step.path), { recursive: true });
2166
+ mkdirSync7(dirname5(step.path), { recursive: true });
1778
2167
  linkDir(step.target, step.path);
1779
2168
  }
1780
2169
  applied.push(step);
@@ -1782,7 +2171,7 @@ function applyTidy(plan, { git: git2 = "keep", info: info2 } = {}) {
1782
2171
  if (plan.root) {
1783
2172
  const manifest = readManifest(plan.root);
1784
2173
  const dep = manifest.skills[plan.name];
1785
- const added = applied.filter((s) => s.kind === "link" || s.kind === "replace").map((s) => relativeTo(plan.root, dirname4(s.path)));
2174
+ const added = applied.filter((s) => s.kind === "link" || s.kind === "replace").map((s) => relativeTo(plan.root, dirname5(s.path)));
1786
2175
  if (dep && added.length) {
1787
2176
  manifest.skills[plan.name] = { ...dep, links: [.../* @__PURE__ */ new Set([...dep.links ?? [], ...added])] };
1788
2177
  writeManifest(plan.root, manifest);
@@ -1803,18 +2192,19 @@ function describeStep(step, root) {
1803
2192
  return `remove ${step.differs ? "differing" : "duplicate"} copy ${shown}`;
1804
2193
  }
1805
2194
  }
1806
- function copyLabel(conflict, dir) {
1807
- return folderLabel(conflict.root, dir);
1808
- }
1809
2195
  function folderLabel(root, dir) {
1810
2196
  return root ? relativeTo(root, dir) : tildify(dir);
1811
2197
  }
1812
2198
  function foldersOf(root, name) {
1813
- 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);
1814
2200
  return { all: entries.map((e) => e.dir), real: entries.filter((e) => !e.link).map((e) => e.dir) };
1815
2201
  }
1816
2202
 
1817
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
+ }
1818
2208
  function runFix(choice) {
1819
2209
  try {
1820
2210
  return { ok: true, message: choice.run() };
@@ -1826,50 +2216,193 @@ function orThrow(r) {
1826
2216
  if (!r.ok) throw new Error(r.message);
1827
2217
  return r.message;
1828
2218
  }
2219
+ function intoLibrary(path, force = false) {
2220
+ const r = importSkill(path, { force });
2221
+ if (r.status === "exists") throw new Error(`${r.name}: your library has a different ${r.name}; update or delete it first`);
2222
+ return r.name;
2223
+ }
2224
+ function track(root, name) {
2225
+ const c = addSkill(root, name);
2226
+ if (c.action === "skipped") throw new Error(`${name}: ${c.reason}`);
2227
+ return c;
2228
+ }
2229
+ var installedIn = (p) => p.projectPath ? ` in ${basename6(p.projectPath)}` : "";
2230
+ var installId = (p) => `${p.projectPath ? `${p.projectPath}:` : ""}${p.id}`;
2231
+ function summarize(where, verb, changes) {
2232
+ const done = changes.filter((c) => c.action !== "skipped");
2233
+ const skipped = changes.filter((c) => c.action === "skipped").map((c) => `${c.name}: ${c.reason}`);
2234
+ if (!done.length) throw new Error(`${where}: nothing ${verb}${skipped.length ? `; ${skipped.join("; ")}` : ""}`);
2235
+ return `${where}: ${verb} ${plural(done.length, "skill")}${skipped.length ? `; skipped ${skipped.join("; ")}` : ""}`;
2236
+ }
1829
2237
  var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
1830
- var agentNames = (ids) => ids.map((id) => HARNESSES.find((h) => h.id === id).name).join(", ");
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
+ };
2249
+ var agentNames = (ids) => ids.map((id) => harness(id).name).join(", ");
1831
2250
  function conflictIssue(c) {
1832
2251
  const where = c.root ? basename6(c.root) : "your global folders";
1833
2252
  const winners = c.managed ? c.copies.filter((x) => x.dir === c.managed) : c.copies;
2253
+ const planFor = (dir) => (c.root ? planProjectTidy(c.root, { keep: { [c.name]: dir } }) : planGlobalTidy({ keep: { [c.name]: dir } })).plans.find((p) => p.name === c.name);
2254
+ const keep = (dir, git2) => () => {
2255
+ const plan = planFor(dir);
2256
+ const r = plan ? applyTidy(plan, { git: git2 }) : null;
2257
+ if (!r?.applied.length) throw new Error(`${c.name}: nothing changed${r?.held.length ? " (git would see every change)" : ""}`);
2258
+ return `${c.name}: kept the ${folderLabel(c.root, dir)} copy${r.held.length ? `; held back ${plural(r.held.length, "change")} git would see` : ""}`;
2259
+ };
1834
2260
  return {
1835
2261
  id: `conflict:${c.root ?? "global"}:${c.name}`,
1836
2262
  severity: "problem",
1837
2263
  title: `${c.name} in ${where}: the copies differ`,
1838
- detail: `Your agents run different versions: ${c.copies.map((x) => `${copyLabel(c, x.dir)}${x.runs.length ? ` (${agentNames(x.runs)})` : ""}`).join(" vs ")}. Keep one; the others become links to it and are restorable from Health.`,
1839
- choices: winners.map((x) => ({
1840
- label: `Keep the ${copyLabel(c, x.dir)} copy`,
1841
- hint: x.dir === c.managed ? "skilllib's version" : "the others become links to it",
1842
- run: () => {
1843
- const keep = { [c.name]: x.dir };
1844
- const plan = (c.root ? planProjectTidy(c.root, { keep }) : planGlobalTidy({ keep })).plans.find((p) => p.name === c.name);
1845
- if (plan) applyTidy(plan, { git: "go" });
1846
- return `${c.name}: kept the ${copyLabel(c, x.dir)} copy`;
1847
- }
1848
- }))
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).`,
2265
+ at: rows(c.root, [c.name]),
2266
+ choices: winners.flatMap((x) => {
2267
+ const plan = planFor(x.dir);
2268
+ const visible2 = plan ? gitVisibleSteps(plan).map((s) => describeStep(s, c.root)) : [];
2269
+ const label = `Keep the ${folderLabel(c.root, x.dir)} copy`;
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") }];
2271
+ return [
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") }] : [],
2274
+ { label: `${label}, and change what git tracks`, hint: `git will see: ${visible2.join(", ")}`, run: keep(x.dir, "go") }
2275
+ ];
2276
+ })
1849
2277
  };
1850
2278
  }
1851
2279
  function applyAll(plans, git2) {
1852
2280
  const results = plans.map((p) => applyTidy(p, { git: git2 }));
1853
2281
  return { skills: results.filter((r) => r.applied.length).length, held: results.reduce((n, r) => n + r.held.length, 0) };
1854
2282
  }
1855
- function findIssues(projects2, machine, libraryNames, statusOf = projectStatus, loadedIn = (root) => skillsLoadedIn(root, machine), nestedOf = nestedSkills) {
1856
- const issues = [];
2283
+ function brokenLinksIn(root, opened) {
2284
+ const where = basename6(root);
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);
2292
+ let git2;
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 }) => {
2314
+ const folder = join13(root, dir);
2315
+ if (!isDir(folder) || notWritable(root, dir)) return [];
2316
+ return readdirSync7(folder).flatMap((name) => {
2317
+ const path = join13(folder, name);
2318
+ if (!isLink(path) || isDir(path) || top && (dir !== GROK_SKILLS_DIR && hasCopy(name) || syncMends(dir, name))) return [];
2319
+ const remove2 = {
2320
+ label: "Remove the link",
2321
+ hint: "restorable with skilllib restore",
2322
+ run: () => {
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)`;
2326
+ }
2327
+ };
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` : ""}.` : "";
2336
+ return [
2337
+ {
2338
+ id: `broken:${root}:${dir}/${name}`,
2339
+ severity: "problem",
2340
+ title: `${name}: broken link in ${where}/${dir}`,
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}`,
2342
+ at: rows(root, [name]),
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] }
2345
+ }
2346
+ ];
2347
+ });
2348
+ });
2349
+ }
2350
+ function grokDiffers(projects2, machine, statusOf) {
2351
+ if (!enabledHarnesses().includes("grok")) return [];
2352
+ const issue = (root, name, grokCopy, other) => ({
2353
+ id: `grok-differs:${root ?? "global"}:${name}`,
2354
+ severity: "suggestion",
2355
+ title: `${name}${root ? ` in ${basename6(root)}` : ""}: Grok runs a different copy`,
2356
+ detail: `Grok reads ${grokCopy} first; your other agents run ${other}, and the two differ. skilllib doesn't change Grok's folder: update or remove that copy there to make them match.`,
2357
+ at: rows(root, [name])
2358
+ });
2359
+ const grokGlobal = join13(grokDir(), "skills");
2360
+ const global = skillDirsIn(grokGlobal).filter((path) => !isLink(path)).flatMap((path) => {
2361
+ const name = basename6(path);
2362
+ const other = machine.find((m) => m.name === name && m.movable && !m.broken && m.harnesses.some((id) => id !== "grok") && realPath(dirname6(m.path)) !== realPath(grokGlobal));
2363
+ return other && treeHash(other.path) !== treeHash(path) ? [issue(null, name, tildify(path), tildify(other.path))] : [];
2364
+ });
2365
+ const repos = projects2.flatMap(
2366
+ (root) => skillDirsIn(join13(root, GROK_SKILLS_DIR)).filter((path) => !isLink(path)).flatMap((path) => {
2367
+ const name = basename6(path);
2368
+ const other = statusOf(root).find((s) => s.name === name && !s.path.startsWith(join13(root, GROK_SKILLS_DIR)));
2369
+ return other && treeHash(other.path) !== treeHash(path) ? [issue(root, name, `${GROK_SKILLS_DIR}/${name}`, relativeTo(root, other.path))] : [];
2370
+ })
2371
+ );
2372
+ return [...global, ...repos];
2373
+ }
2374
+ function findIssues(roots, machine, libraryNames, statusOf = projectStatus, loadedIn = (root) => skillsLoadedIn(root, machine), nestedOf = nestedSkills) {
2375
+ gitMemo = /* @__PURE__ */ new Map();
2376
+ const { readable: projects2, unreadable } = readableRoots(roots);
2377
+ const issues = unreadable.map(({ root, reason }) => ({
2378
+ id: `manifest:${root}`,
2379
+ severity: "problem",
2380
+ title: `${basename6(root)}: skilllib.json can't be read`,
2381
+ detail: `${reason}. Until it's fixed, skilllib leaves ${basename6(root)} out.`,
2382
+ at: [{ root, skill: "" }]
2383
+ }));
1857
2384
  const statuses = new Map(projects2.map((root) => [root, statusOf(root)]));
2385
+ const openedProjects = new Set(projects2.map((p) => realNative(p) ?? p));
2386
+ issues.push(...grokDiffers(projects2, machine, (root) => statuses.get(root)));
1858
2387
  const nested = new Map(projects2.map((root) => [root, nestedOf(root)]));
1859
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
+ };
1860
2399
  issues.push({
1861
- id: `broken:${skill.name}`,
2400
+ id: `broken:${skill.path}`,
1862
2401
  severity: "problem",
1863
- title: `${skill.name}: broken link in ${tildify(dirname5(skill.path))}`,
1864
- detail: "It points at a folder that no longer exists, so it loads nothing.",
1865
- fix: {
1866
- label: "Remove the link",
1867
- run: () => {
1868
- const r = unloadGlobal(skill.path);
1869
- if (!r.ok) throw new Error(`${skill.name}: ${r.reason}`);
1870
- return `Removed broken link ${skill.name}`;
1871
- }
1872
- }
2402
+ title: `${skill.name}: broken link in ${tildify(dirname6(skill.path))}`,
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).",
2404
+ at: rows(null, [skill.name]),
2405
+ ...reaches === "folder" ? { choices: [remove2] } : { fix: remove2 }
1873
2406
  });
1874
2407
  }
1875
2408
  const loaded = machine.filter((m) => !m.broken);
@@ -1884,7 +2417,8 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
1884
2417
  id: `dup:${name}`,
1885
2418
  severity: "problem",
1886
2419
  title: `${name}: loaded twice in ${harnesses.join(" and ")}`,
1887
- detail: `${copies.map((c) => `${c.kind} (${c.origin})`).join(" and ")}. Your skill wins: turn the ${vendor.map((c) => c.kind).join(" and ")} copy off at its source${vendor.some((c) => c.kind === "claude.ai") ? " (claude.ai \u2192 Settings \u2192 Skills)" : ""}${vendor.some((c) => c.kind === "system") ? ` (whoever manages ${vendor.find((c) => c.kind === "system").origin})` : ""}.`
2420
+ detail: `${copies.map((c) => `${c.kind} (${c.origin})`).join(" and ")}. Your skill wins: turn the ${vendor.map((c) => c.kind).join(" and ")} copy off at its source${vendor.some((c) => c.kind === "claude.ai") ? " (claude.ai \u2192 Settings \u2192 Skills)" : ""}${vendor.some((c) => c.kind === "system") ? ` (whoever manages ${vendor.find((c) => c.kind === "system").origin})` : ""}.`,
2421
+ at: rows(null, [name])
1888
2422
  });
1889
2423
  }
1890
2424
  const loadedHere = new Map(projects2.map((root) => [root, loadedIn(root)]));
@@ -1893,18 +2427,21 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
1893
2427
  ];
1894
2428
  const pluginAgentsIn = (plugin, root) => new Set(loadedHere.get(root).filter((m) => plugin.skills.includes(m.path)).flatMap((m) => m.harnesses));
1895
2429
  const globalYours = new Set(loaded.filter((m) => m.movable).map((m) => m.name));
1896
- 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)))]);
1897
2431
  const repoChoices = (plugin, root, names) => {
1898
2432
  const where = basename6(root);
1899
2433
  const copies = statuses.get(root).filter((s) => names.includes(s.name));
1900
- 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));
1901
2438
  return [
1902
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)) },
1903
2440
  // Only copies skilllib installed: Your skills keeps them. A repo's own skill is the team's, never removed from here.
1904
- ...names.length && allManaged ? [
2441
+ ...names.length && allManaged && !committed && !leftOut.length ? [
1905
2442
  {
1906
2443
  label: `Remove your ${copies.map((s) => s.name).join(", ")} from ${where}`,
1907
- 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)])}`,
1908
2445
  run: () => {
1909
2446
  for (const s of copies) {
1910
2447
  const r = removeSkill(root, s.name);
@@ -1929,7 +2466,7 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
1929
2466
  const pluginName = plugin.id.split("@")[0];
1930
2467
  const remove2 = {
1931
2468
  label: `Remove the plugin ${plugin.id}`,
1932
- hint: plugin.extras.length ? `also removes its ${plugin.extras.join(", ")}` : "restorable from Health",
2469
+ hint: plugin.extras.length ? `also removes its ${plugin.extras.join(", ")}` : "restorable with skilllib restore",
1933
2470
  run: () => orThrow(removePlugin(plugin, "delete"))
1934
2471
  };
1935
2472
  const off = { label: `Turn the plugin ${plugin.id} off`, hint: "stays installed", run: () => orThrow(removePlugin(plugin, "off")) };
@@ -1945,19 +2482,36 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
1945
2482
  claudeBoth.length ? `Claude Code loads both (the plugin's as /${pluginName}:${claudeBoth[0]}). Your skill wins.` : "",
1946
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.` : "",
1947
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"}.` : "",
1948
- others ? `The plugin's other ${others === 1 ? "skill is" : `${others} skills are`} copied into Your skills first, so nothing is lost.` : "",
1949
- 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"}.` : "",
1950
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." : "",
1951
2489
  inRepos.length ? `Or keep it and turn it off only in ${inRepos.map(([root]) => basename6(root)).join(", ")}.` : ""
1952
2490
  ].filter(Boolean).join(" "),
1953
- 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))],
2492
+ // Global's rows (yours and the plugin's), and the repos where both load.
2493
+ at: [...rows(null, dupes), ...[...inRepos, ...cursorRepos].flatMap(([root, d]) => rows(root, d))],
2494
+ plugin: plugin.id
1954
2495
  });
1955
2496
  }
2497
+ if (enabledHarnesses().includes("claude-code"))
2498
+ for (const p of installedPlugins().filter((x) => x.update && !x.synced && !x.settingsOnly)) {
2499
+ const update2 = { label: p.update === "newer" ? "Update it" : `Update to ${p.update}`, hint: `runs claude plugin update ${p.id}`, run: () => orThrow(updatePlugin(p)) };
2500
+ issues.push({
2501
+ id: `plugin-update:${installId(p)}`,
2502
+ severity: "suggestion",
2503
+ title: `${p.id}${installedIn(p)}: ${p.update === "newer" ? "its marketplace has a newer commit" : `update available (${p.version ?? "?"} \u2192 ${p.update})`}`,
2504
+ detail: "Updates can bring new commands, hooks or MCP servers. If the marketplace asks to run a command, skilllib stops and you update it yourself. Claude Code uses the new version after a restart.",
2505
+ choices: [update2],
2506
+ plugin: p.id,
2507
+ pluginAt: p.projectPath ?? null
2508
+ });
2509
+ }
1956
2510
  const machinePluginSkills = new Set(machinePlugins.flatMap((p) => p.skills));
1957
2511
  for (const root of projects2) {
1958
2512
  const where = basename6(root);
1959
2513
  const repoOnly = loadedHere.get(root).filter((m) => m.kind === "plugin" && !machinePluginSkills.has(m.path));
1960
- for (const plugin of claudePlugins(repoOnly, root)) {
2514
+ for (const plugin of enabledHarnesses().includes("claude-code") ? claudePlugins(repoOnly, root) : []) {
1961
2515
  const names = plugin.skills.map((p) => basename6(p));
1962
2516
  const inRepo = namesIn2(root).filter((n) => names.includes(n));
1963
2517
  const dupes = names.filter((n) => inRepo.includes(n) || globalYours.has(n));
@@ -1967,7 +2521,9 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
1967
2521
  severity: "problem",
1968
2522
  title: `${dupes.join(", ")}: also in the Claude Code plugin ${plugin.id}, on in ${where}`,
1969
2523
  detail: `${where}'s Claude Code settings turn the plugin on, so Claude Code loads both there (the plugin's as /${plugin.id.split("@")[0]}:${dupes[0]}). Your skill wins.`,
1970
- choices: repoChoices(plugin, root, inRepo)
2524
+ choices: repoChoices(plugin, root, inRepo),
2525
+ at: rows(root, dupes),
2526
+ plugin: plugin.id
1971
2527
  });
1972
2528
  }
1973
2529
  }
@@ -1979,7 +2535,9 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
1979
2535
  id: `cursor-plugin:${plugin}`,
1980
2536
  severity: "suggestion",
1981
2537
  title: `${names.join(", ")}: also in the Cursor plugin ${plugin}`,
1982
- detail: `Cursor lists both copies when that plugin is on. Your skill wins: turn the plugin off in Cursor (Settings \u2192 Plugins).`
2538
+ detail: `Cursor lists both copies when that plugin is on. Your skill wins: turn the plugin off in Cursor (Settings \u2192 Plugins).`,
2539
+ at: [...rows(null, names.filter((n) => globalYours.has(n))), ...projects2.flatMap((root) => rows(root, names.filter((n) => statuses.get(root).some((s) => s.name === n))))],
2540
+ plugin
1983
2541
  });
1984
2542
  }
1985
2543
  }
@@ -1989,7 +2547,8 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
1989
2547
  id: "tidy:global",
1990
2548
  severity: "suggestion",
1991
2549
  title: `${plural(global.plans.length, "global skill")} with duplicate copies`,
1992
- detail: `${global.plans.map((p) => p.name).join(", ")}: each keeps one copy (in ~/.agents/skills when there's one) and the others become links. Restorable from Health.`,
2550
+ detail: `${global.plans.map((p) => p.name).join(", ")}: each keeps one copy (in ~/.agents/skills when there's one) and the others become links. Restorable with skilllib restore.`,
2551
+ at: rows(null, global.plans.map((p) => p.name)),
1993
2552
  fix: {
1994
2553
  label: "Keep one copy of each",
1995
2554
  run: () => {
@@ -2002,13 +2561,14 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
2002
2561
  }
2003
2562
  issues.push(...global.conflicts.map(conflictIssue));
2004
2563
  const kept = keptGlobal();
2005
- const unreviewed = [...new Set(loaded.filter((m) => m.movable && !kept.has(m.name)).map((m) => m.name))];
2564
+ const unreviewed = [...new Set(loaded.filter((m) => m.movable && m.harnesses.length && !kept.has(m.name)).map((m) => m.name))];
2006
2565
  if (unreviewed.length) {
2007
2566
  issues.push({
2008
2567
  id: "review:global",
2009
2568
  severity: "suggestion",
2010
2569
  title: `${plural(unreviewed.length, "global skill")} of yours ${unreviewed.length === 1 ? "loads" : "load"} in every repo`,
2011
- detail: `${unreviewed.join(", ")}. Move each to the repos that need it (skilllib global move <name> --to <repo>...), or mark the ones you want everywhere as global on purpose (skilllib global keep <name>).`
2570
+ detail: `${unreviewed.join(", ")}. Move each to the repos that need it (skilllib global move <name> --to <repo>...), or mark the ones you want everywhere as global on purpose (skilllib global keep <name>).`,
2571
+ at: rows(null, unreviewed)
2012
2572
  });
2013
2573
  }
2014
2574
  const enabled = enabledHarnesses();
@@ -2035,6 +2595,7 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
2035
2595
  severity: "suggestion",
2036
2596
  title: `${plural(skills.length, "global skill")}${onPurpose ? " you keep global" : ""} not usable by ${agentNames(missing)}`,
2037
2597
  detail: `${skills.map((m) => `${m.name} (only ${agentNames(m.harnesses)})`).join(", ")}. ${onPurpose ? "A link in each agent's global folder makes the one real copy reach every agent you use." : `Link ${skills.length === 1 ? "it" : "them"} for every agent, or move ${skills.length === 1 ? "it" : "them"} to the repos that need ${skills.length === 1 ? "it" : "them"} (skilllib global move), which works for every agent too.`}`,
2598
+ at: rows(null, [...new Set(skills.map((m) => m.name))]),
2038
2599
  ...onPurpose ? { fix: link2 } : { choices: [link2] }
2039
2600
  });
2040
2601
  }
@@ -2044,31 +2605,77 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
2044
2605
  for (const root of projects2) {
2045
2606
  const where = basename6(root);
2046
2607
  const status2 = statuses.get(root);
2047
- const restorable = status2.filter((s) => s.state === "folder missing" && s.latest !== null);
2608
+ issues.push(...brokenLinksIn(root, openedProjects));
2609
+ const pins = readManifest(root).skills;
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)))
2631
+ issues.push({
2632
+ id: `pin:${root}:${s.name}`,
2633
+ severity: "problem",
2634
+ title: `${s.name}: the version ${where}'s skilllib.json pins isn't in your library`,
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.`}`,
2636
+ at: rows(root, [s.name]),
2637
+ // It changes what the shared skilllib.json pins: a choice, never run by `doctor --fix`.
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}` }] }
2639
+ });
2048
2640
  if (restorable.length > 0) {
2049
2641
  issues.push({
2050
2642
  id: `sync:${root}`,
2051
2643
  severity: "problem",
2052
2644
  title: `${where}: ${plural(restorable.length, "skill")} in skilllib.json not installed`,
2053
2645
  detail: `${restorable.map((s) => s.name).join(", ")}: the folder is missing`,
2646
+ at: rows(root, restorable.map((s) => s.name)),
2054
2647
  fix: {
2055
2648
  label: "Sync the project",
2056
- run: () => {
2057
- const changes = syncProject(root);
2058
- const done = changes.filter((c) => c.action !== "skipped");
2059
- const skipped = changes.filter((c) => c.action === "skipped").map((c) => `${c.name}: ${c.reason}`);
2060
- if (!done.length) throw new Error(`${where}: nothing restored${skipped.length ? `; ${skipped.join("; ")}` : ""}`);
2061
- return `${where}: restored ${plural(done.length, "skill")}${skipped.length ? `; skipped ${skipped.join("; ")}` : ""}`;
2062
- }
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) }))
2063
2651
  }
2064
2652
  });
2065
2653
  }
2066
- 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))
2067
2673
  issues.push({
2068
2674
  id: `unavailable:${root}:${s.name}`,
2069
2675
  severity: "problem",
2070
2676
  title: `${s.name}: pinned in ${where}'s skilllib.json, but not in your library`,
2071
- 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.`,
2678
+ at: rows(root, [s.name]),
2072
2679
  // Editing skilllib.json is the team's call: a choice, never run by `doctor --fix`.
2073
2680
  choices: [
2074
2681
  {
@@ -2082,27 +2689,26 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
2082
2689
  }
2083
2690
  ]
2084
2691
  });
2085
- const behind = status2.filter((s) => s.state === "update available");
2692
+ const behind = unstuck.filter((s) => s.state === "update available" && !untrackable(s));
2086
2693
  if (behind.length > 0)
2087
2694
  issues.push({
2088
2695
  id: `update:${root}`,
2089
2696
  severity: "suggestion",
2090
2697
  title: `${where}: ${plural(behind.length, "skill")} out of date`,
2091
2698
  detail: behind.map((s) => `${s.name} (v${s.version} \u2192 v${s.latest})`).join(", "),
2092
- fix: {
2093
- label: "Update to the newest versions",
2094
- run: () => {
2095
- const changes = updateProject(root, behind.map((s) => s.name));
2096
- const done = changes.filter((c) => c.action !== "skipped");
2097
- const skipped = changes.filter((c) => c.action === "skipped").map((c) => `${c.name}: ${c.reason}`);
2098
- if (!done.length) throw new Error(`${where}: nothing updated${skipped.length ? `; ${skipped.join("; ")}` : ""}`);
2099
- return `${where}: updated ${plural(done.length, "skill")}${skipped.length ? `; skipped ${skipped.join("; ")}` : ""}`;
2699
+ at: rows(root, behind.map((s) => s.name)),
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)))
2100
2706
  }
2101
- }
2707
+ ]
2102
2708
  });
2103
2709
  const tidy2 = planProjectTidy(root);
2104
2710
  if (tidy2.plans.length) {
2105
- const git3 = gitInfo(root);
2711
+ const git3 = gitOf(root);
2106
2712
  const visible2 = tidy2.plans.flatMap((p) => gitVisibleSteps(p, git3).map((s) => describeStep(s, root)));
2107
2713
  const run = (mode) => () => {
2108
2714
  const r = applyAll(tidy2.plans, mode);
@@ -2113,41 +2719,90 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
2113
2719
  id: `tidy:${root}`,
2114
2720
  severity: "suggestion",
2115
2721
  title: `${where}: ${plural(tidy2.plans.length, "skill")} with extra copies or links`,
2116
- detail: tidy2.plans.map((p) => `${p.name}: ${p.steps.map((s) => describeStep(s, root)).join(", ")}`).join(" \xB7 ") + (visible2.length ? `. Git would see ${plural(visible2.length, "of these change")}: ${visible2.join(", ")}.` : ". Replaced copies are restorable from Health."),
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." : "."),
2723
+ at: rows(root, tidy2.plans.map((p) => p.name)),
2117
2724
  ...visible2.length ? {
2118
2725
  choices: [
2119
- { label: "Tidy, but keep git as it is", hint: `skips the ${plural(visible2.length, "change")} git would see`, run: run("keep") },
2726
+ // "Keep git as it is" only when it leaves something to do.
2727
+ ...visible2.length < tidy2.plans.reduce((n, p) => n + p.steps.length, 0) ? [{ label: "Tidy, but keep git as it is", hint: `skips the ${plural(visible2.length, "change")} git would see`, run: run("keep") }] : [],
2120
2728
  { label: "Tidy everything", hint: `${plural(visible2.length, "change")} will show in git status`, run: run("go") }
2121
2729
  ]
2122
2730
  } : { fix: { label: "Tidy", run: run("go") } }
2123
2731
  });
2124
2732
  }
2125
2733
  issues.push(...tidy2.conflicts.map(conflictIssue));
2126
- const blindHere = status2.filter((s) => s.state !== "folder missing" && s.visibility.some((v) => v.paths === 0)).map((s) => ({ s, would: linkEverywhere(root, s.name, s.location, { dryRun: true }) }));
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
+ };
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
+ }
2127
2775
  for (const { s, would } of blindHere.filter(({ would: w }) => !w.created.length && !w.blocked.length && w.uncommitted.length))
2128
2776
  issues.push({
2129
2777
  id: `uncommitted:${root}:${s.name}`,
2130
2778
  severity: "suggestion",
2131
2779
  title: `${s.name} in ${where}: ${agentNames(s.visibility.filter((v) => v.paths === 0).map((v) => v.id))} can't use it until it's committed`,
2132
- detail: `Git tracks ${would.uncommitted.join(", ")}, but not ${s.location}/${s.name}: a link there would be broken for teammates. Commit it, then link it (\`skilllib link --allow-tracked\`, or from Health).`
2780
+ detail: `Git tracks ${would.uncommitted.join(", ")}, but not ${s.location}/${s.name}: a link there would be broken for teammates. Commit it, then link it (\`skilllib link --allow-tracked\`).`,
2781
+ at: rows(root, [s.name])
2133
2782
  });
2134
2783
  const partial = blindHere.filter(({ would }) => would.created.length || would.blocked.length).map(({ s }) => s);
2135
2784
  if (partial.length) {
2136
- const missing = [...new Set(partial.flatMap((s) => s.visibility.filter((v) => v.paths === 0).map((v) => v.id)))];
2785
+ const missing2 = [...new Set(partial.flatMap((s) => s.visibility.filter((v) => v.paths === 0).map((v) => v.id)))];
2137
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) ?? "");
2138
2788
  const run = (allowTracked) => () => {
2139
2789
  if (allowTracked) allowTrackedLinks(root);
2140
- const r = linkAll(root, { allowTracked });
2790
+ const r = linkAll(root, { allowTracked, names: partial.map((s) => s.name) });
2141
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)` : ""}`;
2142
- if (!r.linked.length) throw new Error(`${where}: nothing linked${notes}`);
2143
- return `${where}: linked ${plural(r.linked.length, "skill")} for ${agentNames(missing)}${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
+ }
2797
+ return `${where}: linked ${plural(r.linked.length, "skill")} for ${agentNames(missing2)}${notes}`;
2144
2798
  };
2145
2799
  issues.push({
2146
2800
  id: `usable:${root}`,
2147
2801
  severity: "suggestion",
2148
- title: `${where}: ${plural(partial.length, "skill")} not usable by ${agentNames(missing)}`,
2149
- 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.` : ""}`,
2150
- ...tracked.length ? {
2802
+ title: `${where}: ${plural(partial.length, "skill")} not usable by ${agentNames(missing2)}`,
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." : ""}`,
2804
+ at: rows(root, partial.map((s) => s.name)),
2805
+ ...manifestShared && !tracked.length ? { choices: [{ label: "Add the links", hint: "git will see skilllib.json change", run: run(false) }] } : tracked.length ? {
2151
2806
  choices: [
2152
2807
  // Only when some link goes where git doesn't track: otherwise this one would link nothing.
2153
2808
  ...blindHere.some(({ would }) => would.created.length) ? [{ label: "Add links, but not where git tracks the folder", hint: `skips ${tracked.join(", ")}`, run: run(false) }] : [],
@@ -2157,22 +2812,24 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
2157
2812
  });
2158
2813
  }
2159
2814
  const differing = new Set(tidy2.conflicts.map((c) => c.name));
2160
- const git2 = gitInfo(root);
2815
+ const git2 = gitOf(root);
2161
2816
  const committed = (s) => ["committed", "changed"].includes(git2?.of(relativeTo(root, s.path)) ?? "");
2162
- for (const s of status2) {
2817
+ for (const s of unstuck) {
2163
2818
  if (isLink(s.path) || differing.has(s.name)) continue;
2164
- 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`;
2165
2821
  issues.push({
2166
2822
  id: `local:${root}:${s.name}`,
2167
2823
  severity: "suggestion",
2168
2824
  title: `${s.name} in ${where} isn't in your library`,
2169
- 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}.`,
2826
+ at: rows(root, [s.name]),
2170
2827
  choices: [
2171
2828
  {
2172
2829
  label: "Copy into the library",
2173
2830
  hint: `${where}'s copy stays as it is`,
2174
2831
  run: () => {
2175
- importSkill(s.path);
2832
+ intoLibrary(s.path);
2176
2833
  return `${s.name} copied into your library; ${where}'s copy stays as it is`;
2177
2834
  }
2178
2835
  }
@@ -2184,27 +2841,102 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
2184
2841
  severity: "suggestion",
2185
2842
  title: `${s.name} in ${where} isn't in your library`,
2186
2843
  detail: "Import it into the library to reuse it in other projects and keep it backed up. The repo then tracks it in skilllib.json.",
2844
+ at: rows(root, [s.name]),
2187
2845
  choices: [
2188
2846
  {
2189
2847
  label: "Import into the library",
2190
- hint: `${where} then tracks it`,
2848
+ // Tracking it writes skilllib.json.
2849
+ hint: `copies it into Your skills${gitNote(root, [join13(root, MANIFEST_FILE)])}`,
2191
2850
  run: () => {
2192
- importSkill(s.path);
2193
- addSkill(root, s.name);
2851
+ intoLibrary(s.path);
2852
+ track(root, s.name);
2194
2853
  return `${s.name} imported and tracked in ${where}`;
2195
2854
  }
2196
2855
  }
2197
2856
  ]
2198
2857
  });
2199
- } else if (s.state === "untracked copy of library skill") {
2858
+ } else if (s.state === "untracked copy of library skill" && !committed(s) && !untrackable(s)) {
2200
2859
  issues.push({
2201
2860
  id: `adopt:${root}:${s.name}`,
2202
2861
  severity: "suggestion",
2203
2862
  title: `${s.name} in ${where} isn't tracked`,
2204
2863
  detail: "It's identical to the library copy; track it (in skilllib.json) so future library changes reach it.",
2205
- choices: [{ label: "Track it", hint: `adds it to ${where}'s skilllib.json`, run: () => `${addSkill(root, s.name).name} tracked in ${where}` }]
2864
+ at: rows(root, [s.name]),
2865
+ choices: [{ label: "Track it", hint: `adds it to ${where}'s skilllib.json`, run: () => `${track(root, s.name).name} tracked in ${where}` }]
2866
+ });
2867
+ } else if (s.state === "untracked, differs from library" || s.state === "repo skill, differs from library") {
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`;
2870
+ issues.push({
2871
+ id: `differs:${root}:${s.name}`,
2872
+ severity: "suggestion",
2873
+ title: `${s.name} in ${where} differs from your library`,
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.`,
2875
+ at: rows(root, [s.name]),
2876
+ choices: [
2877
+ {
2878
+ label: "Update your library from it",
2879
+ hint: teams ? `a new version in Your skills; ${where}'s copy stays as it is` : `a new version in Your skills; ${where} then tracks it`,
2880
+ run: () => {
2881
+ intoLibrary(s.path, true);
2882
+ if (teams) return `${s.name}: your library has ${where}'s copy; ${where}'s copy stays as it is`;
2883
+ const c = track(root, s.name);
2884
+ return `${s.name}: your library has ${where}'s copy as v${c.to}; ${where} tracks it`;
2885
+ }
2886
+ },
2887
+ ...teams ? [] : [
2888
+ {
2889
+ label: "Replace it with your library's",
2890
+ hint: "this copy is restorable with skilllib restore",
2891
+ run: () => {
2892
+ const c = addSkill(root, s.name, { force: true });
2893
+ if (c.action === "skipped") throw new Error(`${s.name}: ${c.reason}`);
2894
+ return `${s.name} in ${where} is your library's v${c.to} now; the old copy is restorable with skilllib restore`;
2895
+ }
2896
+ }
2897
+ ]
2898
+ ]
2899
+ });
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);
2902
+ issues.push({
2903
+ id: `npx:${root}:${s.name}`,
2904
+ severity: "suggestion",
2905
+ title: `${s.name} in ${where} came from npx skills, not your library`,
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`}.`,
2907
+ at: rows(root, [s.name]),
2908
+ choices: [
2909
+ {
2910
+ label: "Import it into your library",
2911
+ hint: teams ? `${where}'s copy stays as it is` : `${where} then tracks it`,
2912
+ run: () => {
2913
+ intoLibrary(s.path);
2914
+ if (teams) return `${s.name} copied into your library; ${where}'s copy stays as it is`;
2915
+ track(root, s.name);
2916
+ return `${s.name} imported and tracked in ${where}`;
2917
+ }
2918
+ }
2919
+ ]
2206
2920
  });
2207
2921
  }
2922
+ if (s.state === "not in library")
2923
+ issues.push({
2924
+ id: `not-in-lib:${root}:${s.name}`,
2925
+ severity: "suggestion",
2926
+ title: `${s.name} in ${where} is tracked, but no longer in your library`,
2927
+ detail: `${where}'s skilllib.json pins it, but your library has no copy: if the folder goes, sync can't restore it, and your other repos can't add it. Copy ${where}'s copy back into the library.`,
2928
+ at: rows(root, [s.name]),
2929
+ choices: [
2930
+ {
2931
+ label: "Copy it back into your library",
2932
+ hint: `${where}'s copy stays as it is`,
2933
+ run: () => {
2934
+ intoLibrary(s.path);
2935
+ return `${s.name} copied back into your library`;
2936
+ }
2937
+ }
2938
+ ]
2939
+ });
2208
2940
  if (s.state === "edited locally" || s.state === "edited locally, update available") {
2209
2941
  const newer2 = s.state !== "edited locally";
2210
2942
  const pinned = s.version ? `v${s.version}` : "the library version";
@@ -2212,11 +2944,21 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
2212
2944
  id: `edited:${root}:${s.name}`,
2213
2945
  severity: "suggestion",
2214
2946
  title: `${s.name} in ${where} has local edits`,
2215
- 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.`,
2216
- choices: [
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."}`,
2948
+ at: rows(root, [s.name]),
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
+ ] : [
2217
2959
  {
2218
2960
  label: "Save as a new version in Your skills",
2219
- 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)])}`,
2220
2962
  run: () => {
2221
2963
  importSkill(s.path, { force: true });
2222
2964
  const c = addSkill(root, s.name);
@@ -2226,7 +2968,7 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
2226
2968
  },
2227
2969
  {
2228
2970
  label: "Discard the edits",
2229
- hint: `back to ${pinned}; the edited copy is restorable from Health`,
2971
+ hint: `back to ${pinned}; the edited copy is restorable with skilllib restore${gitNote(root, [s.path, join13(root, MANIFEST_FILE)])}`,
2230
2972
  run: () => {
2231
2973
  const c = discardEdits(root, s.name);
2232
2974
  if (c.action === "skipped") throw new Error(`${s.name}: ${c.reason}`);
@@ -2242,48 +2984,77 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
2242
2984
  for (const root of projects2) {
2243
2985
  for (const n of nested.get(root)) {
2244
2986
  const copies = alsoGlobal.get(n.name) ?? [];
2245
- if (alsoLoadedGlobally(n) && !copies.some((c) => c.root === root)) alsoGlobal.set(n.name, [...copies, { root, managed: false }]);
2987
+ if (alsoLoadedGlobally(n) && !copies.some((c) => c.root === root)) alsoGlobal.set(n.name, [...copies, { root, managed: false, nested: true }]);
2246
2988
  }
2247
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));
2248
2995
  for (const [name, copies] of alsoGlobal) {
2249
2996
  const wheres = copies.map((c) => basename6(c.root)).join(", ");
2250
- 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);
2251
2998
  const repoOwn = copies.filter((c) => !c.managed).map((c) => basename6(c.root));
2999
+ const at = [...copies.flatMap((c) => rows(c.root, [name])), ...rows(null, [name])];
2252
3000
  if (kept.has(name)) {
2253
- 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(", ");
2254
3006
  issues.push({
2255
3007
  id: `twice:${name}`,
2256
3008
  severity: "suggestion",
2257
3009
  title: `${name}: in ${wheres}, and you keep it global`,
2258
- 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.` : ""}`,
2259
- choices: removable.length ? [
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(" "),
3015
+ at,
3016
+ choices: extra.length ? [
2260
3017
  {
2261
3018
  label: `Remove the copies in ${removeWheres}`,
2262
- 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" : ""}`,
2263
3021
  run: () => {
2264
- const removed = removable.filter((root) => removeSkill(root, name).action === "removed").map((r) => basename6(r));
2265
- return `${name}: removed from ${removed.join(", ") || "no project"}; the global copy stays`;
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(", ")}`);
3026
+ return `${name}: removed from ${removed.join(", ")}; the global copy stays`;
2266
3027
  }
2267
3028
  }
2268
3029
  ] : void 0
2269
3030
  });
2270
3031
  continue;
2271
3032
  }
2272
- 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) : [];
2273
3038
  issues.push({
3039
+ at,
2274
3040
  id: `twice:${name}`,
2275
3041
  severity: "suggestion",
2276
3042
  title: `${name}: in ${wheres} and also loaded globally`,
2277
- 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 (Global \u2192 Enter)." : `The global copy comes from ${machine.find((m) => m.name === name && !m.movable)?.origin ?? "a vendor"}: turn it off there.`}`,
2278
- 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 ? [
2279
3045
  {
2280
3046
  label: "Stop loading it globally",
2281
- hint: "restorable from Health",
3047
+ hint: "restorable with skilllib restore",
2282
3048
  run: () => {
3049
+ const now = blindWithoutGlobal(name, doomed, everyRepo);
3050
+ if (now.length) throw new Error(`${name}: ${blindSaid(name, now)}`);
2283
3051
  if (!libraryNames.has(name)) importSkill(movable.path);
2284
- const r = unloadGlobal(movable.path, movable.links);
2285
- if (!r.ok) throw new Error(`${name}: ${r.reason}`);
2286
- return `${name} no longer loads globally`;
3052
+ const left = strandedLinks(name, doomed, everyRepo);
3053
+ for (const m of machine.filter((x) => x.name === name && x.movable)) {
3054
+ const r = unloadGlobal(m.path, m.links);
3055
+ if (!r.ok) throw new Error(`${name}: ${r.reason}`);
3056
+ }
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"}` : ""}`;
2287
3058
  }
2288
3059
  }
2289
3060
  ] : void 0
@@ -2305,6 +3076,7 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
2305
3076
  severity: "suggestion",
2306
3077
  title: agentSkill2 === "missing" ? "Your agents don't know about skilllib" : "The skilllib skill for your agents is out of date",
2307
3078
  detail: "The skilllib skill lets you ask your agents which skills they can use here, where each comes from, and which of your skills a repo should add.",
3079
+ at: rows(null, ["skilllib"]),
2308
3080
  ...agentSkill2 === "missing" ? { choices: [install] } : { fix: install }
2309
3081
  });
2310
3082
  }
@@ -2319,6 +3091,7 @@ function addedAt(path) {
2319
3091
  }
2320
3092
  }
2321
3093
  function usageIssues(projects2, machine, libraryNames, uses, statusOf = projectStatus) {
3094
+ gitMemo = /* @__PURE__ */ new Map();
2322
3095
  if (!enabledHarnesses().includes("claude-code")) return [];
2323
3096
  const issues = [];
2324
3097
  const since = Date.now() - uses.days * 24 * 60 * 60 * 1e3;
@@ -2326,26 +3099,45 @@ function usageIssues(projects2, machine, libraryNames, uses, statusOf = projectS
2326
3099
  const where = basename6(root);
2327
3100
  const unused = statusOf(root).filter((s) => s.state !== "folder missing" && uses.inProject(root, s.name) === 0 && addedAt(s.path) <= since);
2328
3101
  if (!unused.length) continue;
2329
- const removable = unused.filter((s) => s.managed);
3102
+ const removable = unused.filter((s) => removesClean(root, s));
2330
3103
  issues.push({
2331
3104
  id: `unused:${root}`,
2332
3105
  severity: "suggestion",
2333
- title: `${where}: ${plural(unused.length, "skill")} unused in ${uses.days} days`,
2334
- 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.` : ""}`,
3108
+ at: rows(root, unused.map((s) => s.name)),
2335
3109
  choices: removable.length ? [
2336
3110
  {
2337
3111
  label: `Remove ${removable.length === unused.length ? "them" : `the ${plural(removable.length, "skill")} skilllib installed`} from ${where}`,
2338
- 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)])}`,
2339
3113
  run: () => {
2340
3114
  const changes = removable.map((s) => removeSkill(root, s.name));
2341
3115
  const removed = changes.filter((c) => c.action === "removed").map((c) => c.name);
2342
3116
  const kept = changes.filter((c) => c.action !== "removed").map((c) => c.name);
2343
- return `${where}: removed ${removed.join(", ") || "nothing"}${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(", ")}`;
2344
3119
  }
2345
3120
  }
2346
3121
  ] : void 0
2347
3122
  });
2348
3123
  }
3124
+ if (uses.asUsed)
3125
+ for (const p of installedPlugins().filter((x) => x.agent === "claude-code" && x.on && x.skills.length)) {
3126
+ const name = p.id.split("@")[0];
3127
+ if (p.skills.some((path) => uses.asUsed(`${name}:${basename6(path)}`) > 0)) continue;
3128
+ issues.push({
3129
+ id: `plugin-unused:${installId(p)}`,
3130
+ severity: "suggestion",
3131
+ title: `${p.id}${installedIn(p)}: its skills weren't used in ${plural(uses.days, "day")}`,
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.` : ""}`,
3133
+ choices: [
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)) }]
3136
+ ],
3137
+ plugin: p.id,
3138
+ pluginAt: p.projectPath ?? null
3139
+ });
3140
+ }
2349
3141
  const installed = /* @__PURE__ */ new Set([...projects2.flatMap((root) => statusOf(root).map((s) => s.name)), ...machine.filter((m) => !m.broken).map((m) => m.name)]);
2350
3142
  const idle = [...libraryNames].filter((name) => {
2351
3143
  const first = versionHistory(name)[0];
@@ -2355,15 +3147,18 @@ function usageIssues(projects2, machine, libraryNames, uses, statusOf = projectS
2355
3147
  issues.push({
2356
3148
  id: "unused:library",
2357
3149
  severity: "suggestion",
2358
- 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")}`,
2359
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.`,
3152
+ // Your skills' rows (none loads globally, so none is in Global).
3153
+ at: rows(null, idle),
2360
3154
  choices: [
2361
3155
  {
2362
3156
  label: `Delete ${idle.length === 1 ? "it" : `all ${idle.length}`} from Your skills`,
2363
- hint: "restorable from Health",
3157
+ hint: "restorable with skilllib restore",
2364
3158
  run: () => {
2365
3159
  const done = idle.map((name) => [name, deleteLibrarySkill(name)]);
2366
3160
  const failed = done.filter(([, r]) => !r.ok).map(([name]) => name);
3161
+ if (failed.length === done.length) throw new Error(`Deleted nothing; kept ${failed.join(", ")}`);
2367
3162
  return `Deleted ${plural(done.length - failed.length, "skill")} from Your skills${failed.length ? `; kept ${failed.join(", ")}` : ""}`;
2368
3163
  }
2369
3164
  }
@@ -2385,13 +3180,12 @@ function groupBy(items) {
2385
3180
  }
2386
3181
  var sameSet = (a, b) => a.length === b.length && a.every((x) => b.includes(x));
2387
3182
  function globalFrom(kind, origin, path) {
2388
- if (kind === "global" || kind === "skilllib") return tildify(path.replace(/[\\/][^\\/]+$/, "")).replace(/\\/g, "/");
3183
+ if (kind === "global" || kind === "skilllib") return tildify(path.replace(/[\\/][^\\/]+$/, ""));
2389
3184
  return origin;
2390
3185
  }
2391
- var SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "target", "vendor", "coverage"]);
2392
3186
  function topLevel(root) {
2393
3187
  try {
2394
- return readdirSync7(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);
2395
3189
  } catch {
2396
3190
  return [];
2397
3191
  }
@@ -2407,13 +3201,13 @@ function usableHere(root, uses = /* @__PURE__ */ new Map()) {
2407
3201
  const group = (list2) => groupBy(
2408
3202
  list2.map((s) => {
2409
3203
  const loadedBy = s.visibility.filter((v) => v.paths > 0).map((v) => v.id);
2410
- const behind = s.managed && s.latest !== null && s.version !== null && s.latest > s.version;
3204
+ const behind = s.state.endsWith("update available");
2411
3205
  const gitState = git2?.of(relativeTo(root, s.path));
2412
3206
  const repo = !s.managed && (s.state.startsWith("repo skill") || gitState === "committed" || gitState === "changed");
2413
3207
  const usual = s.state === "ok" || s.state === "local only" || repo && s.state !== "from npx skills";
2414
3208
  const attrs = {
2415
3209
  source: s.managed ? "library" : repo ? "repo" : "local",
2416
- dir: s.location !== ".claude/skills" ? s.location : void 0,
3210
+ dir: s.location !== PROJECT_SKILLS_DIR ? s.location : void 0,
2417
3211
  version: s.version ?? void 0,
2418
3212
  state: usual ? void 0 : s.state,
2419
3213
  latest: behind ? s.latest : void 0,
@@ -2423,7 +3217,14 @@ function usableHere(root, uses = /* @__PURE__ */ new Map()) {
2423
3217
  return [JSON.stringify(attrs), s.name, () => ({ ...attrs, skills: [] })];
2424
3218
  })
2425
3219
  );
2426
- 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);
2427
3228
  const nested = group(nestedHere);
2428
3229
  const machine = machineSkills(agents);
2429
3230
  const loaded = inRepo ? skillsLoadedIn(root, machine, agents) : machine;
@@ -2437,7 +3238,7 @@ function usableHere(root, uses = /* @__PURE__ */ new Map()) {
2437
3238
  for (const i of statusIssues(root, machine)) {
2438
3239
  const choices = i.choices?.map((c) => c.label);
2439
3240
  const key = `${i.detail}\0${i.fix?.label ?? ""}\0${choices?.join() ?? ""}`;
2440
- 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 };
2441
3242
  issue.problems.push(i.title);
2442
3243
  issue.ids.push(i.id);
2443
3244
  issues.set(key, issue);
@@ -2445,7 +3246,8 @@ function usableHere(root, uses = /* @__PURE__ */ new Map()) {
2445
3246
  const usesHere = Object.fromEntries([...uses].filter(([, n]) => n > 0));
2446
3247
  return {
2447
3248
  project: tildify(root),
2448
- files: topLevel(root),
3249
+ // Outside a repo there are no project files to read (and the folder may be your home).
3250
+ files: inRepo ? topLevel(root) : [],
2449
3251
  agents,
2450
3252
  skills,
2451
3253
  nested: nested.length > 0 ? nested : void 0,
@@ -2460,7 +3262,7 @@ function firstSentence(description) {
2460
3262
  return sentence.length > 200 ? sentence.slice(0, 199).trimEnd() + "\u2026" : sentence;
2461
3263
  }
2462
3264
  function libraryFor(currentProject) {
2463
- const manifests = visibleProjects().map((p) => ({ name: basename7(p), skills: readManifest(p).skills }));
3265
+ const manifests = visibleProjects().map((p) => ({ name: basename7(p), skills: pinsIn(p) }));
2464
3266
  const here = currentProject ? new Set(projectStatus(currentProject).map((s) => s.name)) : null;
2465
3267
  const all = librarySkills();
2466
3268
  const describe2 = (s) => {
@@ -2472,8 +3274,8 @@ function libraryFor(currentProject) {
2472
3274
  }
2473
3275
 
2474
3276
  // src/usage.ts
2475
- import { createReadStream, existsSync as existsSync12, readdirSync as readdirSync8, statSync as statSync4 } from "fs";
2476
- import { join as join13 } from "path";
3277
+ import { createReadStream, existsSync as existsSync13, readdirSync as readdirSync9, statSync as statSync5 } from "fs";
3278
+ import { join as join14 } from "path";
2477
3279
  import { createInterface } from "readline";
2478
3280
  var BUILTIN_COMMANDS = /* @__PURE__ */ new Set([
2479
3281
  "add-dir",
@@ -2553,31 +3355,43 @@ function usesFromTranscriptLine(line) {
2553
3355
  return [];
2554
3356
  }
2555
3357
  function transcriptFiles(sinceMs) {
2556
- const base = join13(claudeDir(), "projects");
2557
- if (!existsSync12(base)) return [];
2558
- return readdirSync8(base).flatMap((project) => {
2559
- const dir = join13(base, project);
2560
- if (!statSync4(dir).isDirectory()) return [];
2561
- return readdirSync8(dir).filter((f) => f.endsWith(".jsonl")).map((f) => join13(dir, f)).filter((f) => statSync4(f).mtimeMs >= sinceMs);
3358
+ const base = join14(claudeDir(), "projects");
3359
+ if (!existsSync13(base)) return [];
3360
+ const list2 = (dir) => {
3361
+ try {
3362
+ return readdirSync9(dir);
3363
+ } catch {
3364
+ return [];
3365
+ }
3366
+ };
3367
+ return list2(base).flatMap((project) => {
3368
+ const dir = join14(base, project);
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);
2562
3371
  });
2563
3372
  }
2564
3373
  async function scanUsage(days) {
2565
3374
  const sinceMs = Date.now() - days * 24 * 60 * 60 * 1e3;
2566
3375
  const seen = /* @__PURE__ */ new Map();
2567
3376
  for (const file of transcriptFiles(sinceMs)) {
2568
- const lines = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
2569
- for await (const line of lines) {
2570
- for (const use of usesFromTranscriptLine(line)) {
2571
- 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
+ }
2572
3383
  }
3384
+ } catch {
3385
+ continue;
2573
3386
  }
2574
3387
  }
2575
3388
  return [...seen.values()];
2576
3389
  }
2577
- function summarize(uses, projectOf = () => null) {
3390
+ var skillOf = (key) => key.split(":").pop();
3391
+ function summarize2(uses, projectOf = () => null) {
2578
3392
  const bySkill = /* @__PURE__ */ new Map();
2579
3393
  for (const use of uses) {
2580
- const skill = use.skill.split(":").pop();
3394
+ const skill = skillOf(use.skill);
2581
3395
  const summary = bySkill.get(skill) ?? { skill, uses: 0, lastUsed: "", projects: /* @__PURE__ */ new Set() };
2582
3396
  summary.uses += 1;
2583
3397
  if (use.at > summary.lastUsed) summary.lastUsed = use.at;
@@ -2593,7 +3407,8 @@ function usesByProject(uses, projectOf) {
2593
3407
  const root = projectOf(use.cwd);
2594
3408
  if (!root) continue;
2595
3409
  const counts = byProject.get(root) ?? /* @__PURE__ */ new Map();
2596
- 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);
2597
3412
  byProject.set(root, counts);
2598
3413
  }
2599
3414
  return byProject;
@@ -2601,20 +3416,43 @@ function usesByProject(uses, projectOf) {
2601
3416
 
2602
3417
  // src/move.ts
2603
3418
  import { basename as basename8 } from "path";
2604
- function moveGlobalSkills(skills, roots, { group, allowTracked = false, label = basename8 } = {}) {
3419
+ function moveGlobalSkills(skills, roots, {
3420
+ group,
3421
+ allowTracked = false,
3422
+ label = basename8,
3423
+ allowTrackedHint = "re-run with --allow-tracked"
3424
+ } = {}) {
2605
3425
  const byName = /* @__PURE__ */ new Map();
2606
3426
  for (const m of skills) byName.set(m.name, [...byName.get(m.name) ?? [], m]);
2607
3427
  return [...byName].map(([name, copies]) => {
2608
- if (new Set(copies.map((m) => treeHash(m.path))).size > 1)
3428
+ if (copiesDiffer(copies.map((m) => m.path)))
2609
3429
  return { name, moved: false, reason: `your global copies of ${name} differ; nothing moved (keep one first: skilllib tidy --global)`, changes: [] };
2610
- 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")
2611
3437
  return { name, moved: false, reason: `your library has a different ${name}; nothing moved (update your library from this copy first)`, changes: [] };
2612
3438
  if (group) recordOrigin(name, `group: ${group}`);
2613
3439
  const changes = roots.map((root) => [root, addSkill(root, name, { allowTracked })]);
2614
3440
  const skipped = changes.filter(([, c]) => c.action === "skipped");
2615
- if (skipped.length) return { name, moved: false, reason: `not added to ${skipped.map(([r, c]) => `${label(r)} (${c.reason})`).join(", ")}, so it still loads globally`, changes };
3441
+ const why = (reason = "") => reason.replace(/ \(use --force to replace it\)$/, ": move or remove it there first");
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 };
3452
+ }
3453
+ const left = strandedLinks(name, copies, [...roots, ...knownProjects()], label);
2616
3454
  const failed = copies.map((m) => unloadGlobal(m.path, m.links)).find((r) => !r.ok);
2617
- 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 } : {} };
2618
3456
  });
2619
3457
  }
2620
3458
 
@@ -2636,6 +3474,7 @@ var FLAGS = {
2636
3474
  function parseArgs(argv) {
2637
3475
  const { values: v, positionals } = parseFlags({ args: argv, options: FLAGS, allowPositionals: true, strict: true });
2638
3476
  const days = Number(v.days ?? USAGE_DAYS);
3477
+ if (!Number.isInteger(days) || days <= 0) throw new Error(`--days takes a whole number of days, like --days 30 (got ${v.days})`);
2639
3478
  return {
2640
3479
  positional: positionals,
2641
3480
  force: !!v.force,
@@ -2644,7 +3483,7 @@ function parseArgs(argv) {
2644
3483
  fix: !!v.fix,
2645
3484
  allowTracked: !!v["allow-tracked"],
2646
3485
  json: !!v.json,
2647
- days: Number.isFinite(days) && days > 0 ? days : USAGE_DAYS,
3486
+ days,
2648
3487
  dryRun: !!v["dry-run"],
2649
3488
  allowGit: !!v["allow-git"],
2650
3489
  ...v.keep ? { keep: v.keep } : {},
@@ -2660,27 +3499,28 @@ function colorState(state) {
2660
3499
  function withDirs(root, changes) {
2661
3500
  const { skills } = readManifest(root);
2662
3501
  return changes.map((c) => {
2663
- const dep = c.action === "installed" || c.action === "updated" || c.action === "reset" ? skills[c.name] : void 0;
2664
- 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;
2665
3504
  });
2666
3505
  }
3506
+ var incomplete = (c) => c.action === "skipped" || !!c.blocked?.length || !!c.uncommitted?.length || !!c.inTheWay?.length;
3507
+ function warnHeldLinks(root, c, where = "here") {
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.`);
3509
+ if (c.uncommitted?.length)
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.`);
3512
+ }
2667
3513
  function printChanges(root, changes, args) {
2668
- if (changes.some((c) => c.action === "skipped")) process.exitCode = 1;
3514
+ if (changes.some(incomplete)) process.exitCode = 1;
2669
3515
  if (args.json) return json("changes", withDirs(root, changes));
2670
3516
  for (const c of changes) {
2671
- if (c.blocked?.length) {
2672
- warn(`${c.name}: git tracks ${c.blocked.join(", ")} here, so no link was added there. Re-run with --allow-tracked to add it.`);
2673
- }
2674
- if (c.uncommitted?.length) {
2675
- warn(
2676
- `${c.name}: git tracks ${c.uncommitted.join(", ")} here 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.`
2677
- );
2678
- }
3517
+ warnHeldLinks(root, c);
2679
3518
  if (c.action === "skipped") error(`${c.name}: ${c.reason}`);
2680
3519
  else {
2681
- const icon = c.action === "removed" ? red("-") : c.action === "reset" ? yellow("\u21BA") : green(c.action === "installed" ? "+" : "\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");
2682
3521
  const versions = c.action === "removed" ? "" : c.from && c.from !== c.to ? ` v${c.from} \u2192 v${c.to}` : c.to ? ` v${c.to}` : "";
2683
3522
  info(` ${icon} ${c.name}${versions} ${dim(c.action)}`);
3523
+ if (c.reason) info(dim(` ${c.reason}`));
2684
3524
  }
2685
3525
  if (c.backedUp) info(dim(` local edits saved to ${tildify(c.backedUp)}; \`skilllib restore ${c.name}\` brings them back`));
2686
3526
  }
@@ -2693,9 +3533,9 @@ function projectOfFactory(projects2) {
2693
3533
  const sorted = [...projects2].sort((a, b) => b.length - a.length);
2694
3534
  return (cwd) => {
2695
3535
  if (!cwd) return null;
2696
- const known = sorted.find((p) => cwd === p || cwd.startsWith(p + sep5));
3536
+ const known = sorted.find((p) => cwd === p || cwd.startsWith(p + sep8));
2697
3537
  if (known) return known;
2698
- const repo = cwd.split(`${sep5}.claude${sep5}worktrees${sep5}`)[0];
3538
+ const repo = cwd.split(`${sep8}.claude${sep8}worktrees${sep8}`)[0];
2699
3539
  return isProjectCandidate(repo) ? repo : null;
2700
3540
  };
2701
3541
  }
@@ -2703,13 +3543,25 @@ async function usageBySkill(days, onlyProject) {
2703
3543
  const projectOf = projectOfFactory(onlyProject ? [onlyProject, ...knownProjects()] : knownProjects());
2704
3544
  const uses = await scanUsage(days);
2705
3545
  const scoped = onlyProject ? uses.filter((u) => projectOf(u.cwd) === onlyProject) : uses;
2706
- return new Map(summarize(scoped, projectOf).map((s) => [s.skill, s]));
3546
+ return new Map(summarize2(scoped, projectOf).map((s) => [s.skill, s]));
2707
3547
  }
3548
+ var projectsFor = (args) => {
3549
+ if (!args.all) return [requireProject()];
3550
+ const { readable, unreadable } = readableRoots(visibleProjects());
3551
+ for (const u of unreadable) {
3552
+ error(`${basename9(u.root)}: ${u.reason}`);
3553
+ process.exitCode = 1;
3554
+ }
3555
+ return readable;
3556
+ };
3557
+ var yourGlobals = () => machineSkills().filter((m) => m.movable && !m.broken);
2708
3558
  function requireProject() {
2709
3559
  const root = projectHere();
2710
3560
  if (root) return root;
2711
3561
  if (!isProjectCandidate(findProjectRoot())) {
2712
- const stray = existsSync13(join14(userHome(), MANIFEST_FILE)) ? `, though ${tildify(join14(userHome(), MANIFEST_FILE))} makes it look like one` : "";
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.`);
3564
+ const stray = existsSync14(join15(userHome(), MANIFEST_FILE)) ? `, though ${tildify(join15(userHome(), MANIFEST_FILE))} makes it look like one` : "";
2713
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.`);
2714
3566
  }
2715
3567
  throw new Error("Not in a git repo. Run this inside a project (or `git init` first).");
@@ -2722,7 +3574,10 @@ async function status(args) {
2722
3574
  const root2 = findProjectRoot();
2723
3575
  const usage3 = await usageBySkill(args.days, root2);
2724
3576
  json("status", usableHere(root2, new Map([...usage3].map(([name, u]) => [name, u.uses]))));
2725
- if (projectHere()) rememberProjects([root2]);
3577
+ try {
3578
+ if (projectHere()) rememberProjects([root2]);
3579
+ } catch {
3580
+ }
2726
3581
  return;
2727
3582
  }
2728
3583
  const root = requireProject();
@@ -2732,14 +3587,8 @@ async function status(args) {
2732
3587
  `);
2733
3588
  if (skills.length === 0 && nested.length === 0) info("No skills in this project yet. Add one from your library with: skilllib add <name>");
2734
3589
  const usage2 = await usageBySkill(args.days, root);
2735
- const git2 = gitInfo(root, [...new Set(nested.map((s) => s.location))]);
2736
- const committed = (path) => ["committed", "changed"].includes(git2?.of(relativeTo(root, path)) ?? "");
2737
- const asRepo = {
2738
- "local only": "repo skill",
2739
- "untracked copy of library skill": "repo skill, in library",
2740
- "untracked, differs from library": "repo skill, differs from library"
2741
- };
2742
- const stateOf = (s) => colorState(committed(s.path) && asRepo[s.state] || s.state);
3590
+ const teamState = teamStateIn(root, nested.map((s) => s.location));
3591
+ const stateOf = (s) => colorState(teamState(s));
2743
3592
  const agentsOf = (s) => {
2744
3593
  const ids = s.visibility.filter((v) => v.paths > 0).map((v) => v.id);
2745
3594
  return ids.length === s.visibility.length ? dim("all") : ids.length ? ids.map((id) => harness(id).name).join(", ") : red("none");
@@ -2767,17 +3616,19 @@ async function status(args) {
2767
3616
  }))
2768
3617
  );
2769
3618
  }
2770
- const states = new Set(skills.map((s) => s.state));
3619
+ const states = new Set(skills.map((s) => teamState(s)));
2771
3620
  const hints = [];
2772
3621
  if (states.has("update available")) hints.push("`skilllib update` to install the newest versions");
2773
3622
  const missing = skills.filter((s) => s.state === "folder missing");
2774
- 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");
2775
3625
  const notInLibrary = missing.filter((s) => s.latest === null).map((s) => s.name);
2776
3626
  if (notInLibrary.length)
2777
- 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"}`);
2778
- if (states.has("edited locally") || states.has("edited locally, update available"))
2779
- hints.push("`skilllib import .claude/skills/<name> --force` to save local edits to the library");
2780
- 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`);
2781
3632
  if (states.has("untracked copy of library skill") || states.has("untracked, differs from library"))
2782
3633
  hints.push("`skilllib add <name>` to manage an untracked skill (--force if it differs)");
2783
3634
  if (hints.length > 0) info("\n" + hints.map((h) => dim(`\u2192 ${h}`)).join("\n"));
@@ -2804,7 +3655,7 @@ async function list(args) {
2804
3655
  } else {
2805
3656
  const installs = /* @__PURE__ */ new Map();
2806
3657
  for (const project of visibleProjects()) {
2807
- for (const name of Object.keys(readManifest(project).skills)) {
3658
+ for (const name of Object.keys(pinsIn(project))) {
2808
3659
  installs.set(name, [...installs.get(name) ?? [], basename9(project)]);
2809
3660
  }
2810
3661
  }
@@ -2816,14 +3667,14 @@ async function list(args) {
2816
3667
  skills.map((s) => ({
2817
3668
  Skill: s.name,
2818
3669
  Description: truncate(s.description, 50),
2819
- From: dim(truncate(origins[s.name] ?? "", 30)),
3670
+ From: dim(truncate(tildify(origins[s.name] ?? ""), 30)),
2820
3671
  Projects: (installs.get(s.name) ?? []).join(", ") || dim("none"),
2821
3672
  [`Uses (${args.days}d)`]: String(usage2.get(s.name)?.uses ?? 0)
2822
3673
  }))
2823
3674
  );
2824
3675
  }
2825
3676
  const libraryNames = new Set(skills.map((s) => s.name));
2826
- const globals = [...new Set(machineSkills().filter((m) => m.movable && !m.broken).map((m) => m.name))];
3677
+ const globals = [...new Set(yourGlobals().filter((m) => m.harnesses.length).map((m) => m.name))];
2827
3678
  if (globals.length > 0) {
2828
3679
  info("");
2829
3680
  warn(`${globals.length} of your global ${globals.length === 1 ? "skill still loads" : "skills still load"} in every project`);
@@ -2835,20 +3686,33 @@ async function list(args) {
2835
3686
  );
2836
3687
  }
2837
3688
  }
3689
+ function teamStateIn(root, extraDirs = []) {
3690
+ const git2 = gitInfo(root, [...new Set(extraDirs)]);
3691
+ const committed = (path) => ["committed", "changed"].includes(git2?.of(relativeTo(root, path)) ?? "");
3692
+ const asRepo = {
3693
+ "local only": "repo skill",
3694
+ "untracked copy of library skill": "repo skill, in library",
3695
+ "untracked, differs from library": "repo skill, differs from library"
3696
+ };
3697
+ return (s) => committed(s.path) && asRepo[s.state] || s.state;
3698
+ }
2838
3699
  function projects(args) {
2839
- const roots = visibleProjects();
3700
+ const { readable: roots, unreadable } = readableRoots(visibleProjects());
3701
+ for (const u of unreadable) {
3702
+ error(`${basename9(u.root)}: ${u.reason}`);
3703
+ process.exitCode = 1;
3704
+ }
2840
3705
  if (args.json) {
2841
3706
  return json(
2842
3707
  "projects",
2843
- roots.map(
2844
- (root) => ({
3708
+ roots.map((root) => {
3709
+ const teamState = teamStateIn(root);
3710
+ return {
2845
3711
  name: basename9(root),
2846
3712
  path: root,
2847
- skills: projectStatus(root).map(
2848
- (s) => ({ name: s.name, managed: s.managed, state: s.state, version: s.version, latest: s.latest })
2849
- )
2850
- })
2851
- )
3713
+ skills: projectStatus(root).map((s) => ({ name: s.name, managed: s.managed, state: teamState(s), version: s.version, latest: s.latest }))
3714
+ };
3715
+ })
2852
3716
  );
2853
3717
  }
2854
3718
  if (roots.length === 0) {
@@ -2858,13 +3722,14 @@ function projects(args) {
2858
3722
  table(
2859
3723
  roots.map((root) => {
2860
3724
  const skills = projectStatus(root);
2861
- const managed = skills.filter((s) => s.managed);
2862
- 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;
2863
3728
  return {
2864
3729
  Project: basename9(root),
2865
3730
  Path: dim(tildify(root)),
2866
- Skills: managed.map((s) => s.name).join(", ") || dim("none"),
2867
- "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),
2868
3733
  Status: attention === 0 ? green("ok") : yellow(`${attention} need attention`)
2869
3734
  };
2870
3735
  })
@@ -2875,8 +3740,7 @@ function add(args) {
2875
3740
  throw new Error("Usage: skilllib add <skill-name>... [--force]");
2876
3741
  }
2877
3742
  const root = requireProject();
2878
- if (args.allowTracked) allowTrackedLinks(root);
2879
- 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);
2880
3744
  rememberProjects([root]);
2881
3745
  }
2882
3746
  function remove(args) {
@@ -2886,59 +3750,59 @@ function remove(args) {
2886
3750
  const root = requireProject();
2887
3751
  printChanges(root, args.positional.map((name) => removeSkill(root, name, { force: args.force })), args);
2888
3752
  }
2889
- function sync(args) {
2890
- if (args.positional.length) throw new Error(`sync takes no skill names: it installs exactly what skilllib.json pins. To add a skill: skilllib add ${args.positional.join(" ")}`);
2891
- const roots = args.all ? visibleProjects() : [requireProject()];
3753
+ function changeEach(args, change, nothing) {
3754
+ const roots = projectsFor(args);
2892
3755
  const results = roots.map((root) => {
2893
- if (args.allowTracked) allowTrackedLinks(root);
2894
- const changes = syncProject(root, { force: args.force });
2895
- if (changes.some((c) => c.action === "skipped")) process.exitCode = 1;
3756
+ const changes = change(root);
3757
+ if (changes.some(incomplete)) process.exitCode = 1;
2896
3758
  if (!args.json) {
2897
3759
  if (args.all) info(`${basename9(root)} ${dim(tildify(root))}`);
2898
- if (changes.length === 0) {
2899
- if (existsSync13(join14(root, MANIFEST_FILE))) success("Installed versions match skilllib.json");
2900
- else info(dim("No skilllib.json: nothing to sync"));
2901
- } else printChanges(root, changes, args);
3760
+ if (changes.length === 0) nothing(root);
3761
+ else printChanges(root, changes, args);
2902
3762
  }
2903
3763
  return { project: basename9(root), path: root, changes: withDirs(root, changes) };
2904
3764
  });
2905
3765
  if (args.json) args.all ? json("changes --all", results) : json("changes", results[0].changes);
3766
+ return roots;
3767
+ }
3768
+ function sync(args) {
3769
+ if (args.positional.length) throw new Error(`sync takes no skill names: it installs exactly what skilllib.json pins. To add a skill: skilllib add ${args.positional.join(" ")}`);
3770
+ const roots = changeEach(
3771
+ args,
3772
+ (root) => syncProject(root, { force: args.force }),
3773
+ (root) => existsSync14(join15(root, MANIFEST_FILE)) ? success("Installed versions match skilllib.json") : info(dim("No skilllib.json: nothing to sync"))
3774
+ );
2906
3775
  if (!args.all) rememberProjects(roots);
2907
3776
  }
2908
3777
  function update(args) {
2909
- const roots = args.all ? visibleProjects() : [requireProject()];
2910
- const results = roots.map((root) => {
2911
- if (args.allowTracked) allowTrackedLinks(root);
2912
- const names = args.positional.length ? args.positional : void 0;
2913
- const changes = updateProject(root, names, { force: args.force });
2914
- if (!args.all) {
2915
- const { skills } = readManifest(root);
2916
- for (const name of names ?? []) if (!(name in skills)) changes.push({ name, action: "skipped", reason: "not managed by skilllib in this project" });
2917
- }
2918
- if (changes.some((c) => c.action === "skipped")) process.exitCode = 1;
2919
- if (!args.json) {
2920
- if (args.all) info(`${basename9(root)} ${dim(tildify(root))}`);
2921
- if (changes.length === 0) success("Everything is on the latest version");
2922
- else printChanges(root, changes, args);
2923
- }
2924
- return { project: basename9(root), path: root, changes: withDirs(root, changes) };
2925
- });
2926
- if (args.json) args.all ? json("changes --all", results) : json("changes", results[0].changes);
3778
+ const names = args.positional.length ? args.positional : void 0;
3779
+ changeEach(
3780
+ args,
3781
+ (root) => {
3782
+ const changes = updateProject(root, names, { force: args.force, allowTracked: args.allowTracked });
3783
+ if (!args.all) {
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" });
3785
+ }
3786
+ return changes;
3787
+ },
3788
+ () => success("Everything is on the latest version")
3789
+ );
2927
3790
  }
2928
3791
  function outdated(args) {
2929
- const roots = args.all ? visibleProjects() : [requireProject()];
3792
+ const roots = projectsFor(args);
2930
3793
  const behind = roots.flatMap(
2931
- (root) => projectStatus(root).filter((s) => s.managed && s.latest !== null && s.version !== null && s.latest > s.version).map((s) => ({ project: basename9(root), path: root, skill: s.name, installed: s.version, latest: s.latest, state: s.state }))
3794
+ (root) => projectStatus(root).filter((s) => s.state.endsWith("update available") && s.latest !== null && s.version !== null).map((s) => ({ project: basename9(root), path: root, skill: s.name, installed: s.version, latest: s.latest, state: s.state }))
2932
3795
  );
2933
3796
  if (args.json) return json("outdated", behind);
2934
- const rows = behind.map((b) => ({ Project: b.project, Skill: b.skill, Installed: `v${b.installed}`, Latest: `v${b.latest}`, Status: colorState(b.state) }));
2935
- if (rows.length === 0) return success("Everything is on the latest version");
2936
- table(rows);
3797
+ const rows2 = behind.map((b) => ({ Project: b.project, Skill: b.skill, Installed: `v${b.installed}`, Latest: `v${b.latest}`, Status: colorState(b.state) }));
3798
+ if (rows2.length === 0) return success("Everything is on the latest version");
3799
+ table(rows2);
2937
3800
  info(dim(`
2938
3801
  \u2192 skilllib update${args.all ? " --all" : ""}`));
2939
3802
  }
2940
3803
  function importSkills(args) {
2941
- const dirs = args.global ? machineSkills().filter((m) => m.movable && !m.broken).map((m) => m.path) : args.positional.map((p) => resolve7(p));
3804
+ if (args.global && args.positional.length) throw new Error("skilllib import --global takes no folders: it imports all your global skills");
3805
+ const dirs = args.global ? yourGlobals().map((m) => m.path) : args.positional.map((p) => resolve8(p));
2942
3806
  if (dirs.length === 0) {
2943
3807
  throw new Error("Usage: skilllib import <skill-dir>... [--force] | skilllib import --global");
2944
3808
  }
@@ -2958,14 +3822,11 @@ function importSkills(args) {
2958
3822
  continue;
2959
3823
  }
2960
3824
  success(`${name} ${dim(result === "unchanged" ? "already in library" : result === "added" ? "added to library" : "library updated")}`);
2961
- const skillsDir = resolve7(dir, "..");
2962
- const root = resolve7(skillsDir, "..", "..");
2963
- if (skillsDir === join14(root, ".claude", "skills") && isProjectCandidate(root)) {
2964
- if (readManifest(root).skills[name]?.hash !== treeHash(dir)) {
2965
- const change = addSkill(root, name);
2966
- if (change.action !== "skipped") info(dim(` ${basename9(root)} now depends on ${name} v${change.to}`));
2967
- }
2968
- rememberProjects([root]);
3825
+ const root = resolve8(dir, "..", "..", "..");
3826
+ const dep = projectHere(root) === root ? readManifest(root).skills[name] : void 0;
3827
+ if (dep && realpathOrNull(join15(root, dep.dir ?? PROJECT_SKILLS_DIR, name)) === realpathOrNull(dir) && dep.hash !== treeHash(dir)) {
3828
+ const change = addSkill(root, name);
3829
+ if (change.action !== "skipped") info(dim(` ${basename9(root)} now depends on ${name} v${change.to}`));
2969
3830
  }
2970
3831
  }
2971
3832
  if (args.json) json("import", results);
@@ -2980,7 +3841,8 @@ function scan(args) {
2980
3841
  const found = discoverProjects();
2981
3842
  const library = new Set(librarySkills().map((s) => s.name));
2982
3843
  const skillNames = (root) => [
2983
- .../* @__PURE__ */ new Set([...ALL_PROJECT_DIRS.flatMap((d) => skillDirsIn(join14(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)])
2984
3846
  ];
2985
3847
  const withSkills = found.filter((root) => skillNames(root).length > 0);
2986
3848
  info(`Scanned ${roots.map((r) => tildify(r)).join(", ")}: ${found.length} project${found.length === 1 ? "" : "s"}, ${withSkills.length} with skills
@@ -2997,44 +3859,43 @@ ${yellow("Yellow")} skills aren't in your library yet (\`skilllib import <skill-
2997
3859
  }
2998
3860
  function folders(args) {
2999
3861
  const [action, ...paths] = args.positional;
3000
- let failed = false;
3862
+ if (action && !["add", "remove", "rm"].includes(action)) throw new Error("Usage: skilllib folders [add|remove <folder>...]");
3863
+ const failures = [];
3001
3864
  if (action === "add") {
3002
3865
  for (const p of paths) {
3003
3866
  try {
3004
3867
  success(`Added ${tildify(addRoot(p))}`);
3005
3868
  } catch (e) {
3006
- error(e.message);
3007
- failed = true;
3869
+ failures.push(e.message);
3008
3870
  }
3009
3871
  }
3010
3872
  discoverProjects();
3011
3873
  } else if (action === "remove" || action === "rm") {
3012
3874
  for (const p of paths) {
3013
3875
  if (removeRoot(p)) success(`Removed ${tildify(expandHome(p))}`);
3014
- else warn(`${tildify(expandHome(p))} wasn't a project folder`);
3876
+ else failures.push(`${tildify(expandHome(p))} wasn't a project folder`);
3015
3877
  }
3016
3878
  }
3017
3879
  const { roots, hidden } = readConfig();
3018
3880
  info(roots.length ? `Project folders:
3019
3881
  ${roots.map((r) => ` ${tildify(r)}`).join("\n")}` : "No project folders. Add one: skilllib folders add ~/Projects");
3020
3882
  if (hidden.length) info(dim(`Hidden projects: ${hidden.map((h) => tildify(h)).join(", ")} (skilllib unhide <path>)`));
3021
- if (failed) process.exitCode = 1;
3883
+ if (failures.length) throw new Error(failures.join("; "));
3022
3884
  }
3023
3885
  function unhide(args) {
3024
3886
  if (args.positional.length === 0) {
3025
3887
  throw new Error("Usage: skilllib unhide <path>...");
3026
3888
  }
3027
- let failed = false;
3889
+ const failures = [];
3028
3890
  for (const p of args.positional) {
3029
3891
  const path = expandHome(p);
3030
3892
  if (setHidden(path, false)) success(`${tildify(path)} is visible again`);
3031
3893
  else {
3032
3894
  const { hidden } = readConfig();
3033
- error(`${tildify(path)} wasn't hidden. ${hidden.length ? `Hidden projects: ${hidden.map((h) => tildify(h)).join(", ")}` : "No projects are hidden."}`);
3034
- failed = true;
3895
+ failures.push(`${tildify(path)} wasn't hidden. ${hidden.length ? `Hidden projects: ${hidden.map((h) => tildify(h)).join(", ")}` : "No projects are hidden."}`);
3035
3896
  }
3036
3897
  }
3037
- if (failed) process.exitCode = 1;
3898
+ if (failures.length) throw new Error(failures.join("; "));
3038
3899
  }
3039
3900
  function newSkill(args) {
3040
3901
  const [name, ...rest] = args.positional;
@@ -3045,7 +3906,7 @@ function newSkill(args) {
3045
3906
  if (!r.ok) {
3046
3907
  throw new Error(`Can't create ${name}: ${r.reason}`);
3047
3908
  }
3048
- success(`Created ${tildify(join14(r.dir, "SKILL.md"))}`);
3909
+ success(`Created ${tildify(join15(r.dir, "SKILL.md"))}`);
3049
3910
  }
3050
3911
  function restore(args) {
3051
3912
  const backups = [...listBackups(), ...pluginBackups()];
@@ -3056,16 +3917,15 @@ function restore(args) {
3056
3917
  info(dim("\n\u2192 skilllib restore <name>"));
3057
3918
  return;
3058
3919
  }
3059
- const backup = backups.find((b) => b.name === name);
3060
- if (!backup) {
3061
- throw new Error(`No backup named ${name}`);
3062
- }
3063
- const r = backup.kind === "plugin" ? restorePlugin(backup) : restoreBackup(backup);
3064
- if (r.ok) success(`Restored ${name} to ${tildify(r.to)}`);
3065
- else {
3066
- error(`Can't restore ${name}: ${r.reason}`);
3067
- process.exitCode = 1;
3920
+ const newest = backups.find((b) => b.name === name);
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})`);
3922
+ const failures = [];
3923
+ for (const backup of movedTogether(backups, newest)) {
3924
+ const r = backup.kind === "plugin" ? restorePlugin(backup) : restoreBackup(backup);
3925
+ if (r.ok) success(`Restored ${name} to ${tildify(r.to)}`);
3926
+ else failures.push(r.reason);
3068
3927
  }
3928
+ if (failures.length) throw new Error(`Can't restore ${name}: ${failures.join("; ")}`);
3069
3929
  }
3070
3930
  async function allIssues(args, withUsage = true) {
3071
3931
  const projects2 = visibleProjects();
@@ -3076,11 +3936,14 @@ async function allIssues(args, withUsage = true) {
3076
3936
  const projectOf = projectOfFactory(projects2);
3077
3937
  const uses = await scanUsage(args.days);
3078
3938
  const inProject = usesByProject(uses, projectOf);
3079
- const total = new Map(summarize(uses, projectOf).map((s) => [s.skill, s.uses]));
3939
+ const total = new Map(summarize2(uses, projectOf).map((s) => [s.skill, s.uses]));
3940
+ const asUsed = /* @__PURE__ */ new Map();
3941
+ for (const u of uses) asUsed.set(u.skill, (asUsed.get(u.skill) ?? 0) + 1);
3080
3942
  issues.push(
3081
- ...usageIssues(projects2, machine, libraryNames, {
3943
+ ...usageIssues(readableRoots(projects2).readable, machine, libraryNames, {
3082
3944
  inProject: (root, skill) => inProject.get(root)?.get(skill) ?? 0,
3083
3945
  total: (skill) => total.get(skill) ?? 0,
3946
+ asUsed: (key) => asUsed.get(key) ?? 0,
3084
3947
  days: args.days
3085
3948
  })
3086
3949
  );
@@ -3102,7 +3965,7 @@ async function doctor(args) {
3102
3965
  id,
3103
3966
  severity,
3104
3967
  title,
3105
- detail,
3968
+ detail: detailWithGit({ detail, fix, choices }),
3106
3969
  fix: fix?.label,
3107
3970
  choices: choices?.map((c) => c.label),
3108
3971
  fixed: args.fix && fix ? fixed(fix) : void 0
@@ -3114,7 +3977,8 @@ async function doctor(args) {
3114
3977
  for (const issue of issues) {
3115
3978
  info(`${issue.severity === "problem" ? red("\u25CF") : yellow("\u25C6")} ${issue.title}`);
3116
3979
  info(dim(` ${issue.detail}${issue.fix ? ` \u2192 ${issue.fix.label}` : ""}`));
3117
- 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>"}`));
3118
3982
  if (args.fix && issue.fix) {
3119
3983
  const r = fixed(issue.fix);
3120
3984
  info(` ${r.ok ? green("\u2713") : red("\u2717")} ${r.message}`);
@@ -3125,14 +3989,14 @@ async function doctor(args) {
3125
3989
  async function apply(args) {
3126
3990
  const [id, pick] = args.positional;
3127
3991
  if (!id) throw new Error("Usage: skilllib apply <issue-id> [choice] (ids and choices: skilllib status --json or doctor --json)");
3128
- const found = [...statusIssues(findProjectRoot()), ...await allIssues(args, id.startsWith("unused:"))].filter((i) => i.id === id);
3992
+ const found = [...statusIssues(findProjectRoot()), ...await allIssues(args, /^(plugin-)?unused:/.test(id))].filter((i) => i.id === id);
3129
3993
  if (!found.length) throw new Error(`No issue ${id} right now (it may be fixed already; for an unused:\u2026 issue, pass doctor's --days). See skilllib status --json or doctor --json`);
3130
3994
  const optionsOf = (i) => i.choices ?? (i.fix ? [i.fix] : []);
3131
3995
  const labels = (i) => optionsOf(i).map((c) => c.label).join("\0");
3132
3996
  const byLabel = pick === void 0 ? void 0 : found.find((i) => optionsOf(i).some((c) => c.label.toLowerCase() === pick.toLowerCase()));
3133
3997
  if (!byLabel && found.some((i) => labels(i) !== labels(found[0]))) {
3134
3998
  const all = [...new Set(found.flatMap((i) => optionsOf(i).map((c) => c.label)))];
3135
- 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(" ")}`);
3136
4000
  }
3137
4001
  const issue = byLabel ?? found[0];
3138
4002
  const options = optionsOf(issue);
@@ -3148,22 +4012,22 @@ async function apply(args) {
3148
4012
  else error(r.message);
3149
4013
  }
3150
4014
  async function usage(args) {
3151
- const summaries = summarize(await scanUsage(args.days), projectOfFactory(knownProjects()));
4015
+ const summaries = summarize2(await scanUsage(args.days), projectOfFactory(knownProjects()));
4016
+ const library = new Set(librarySkills().map((s) => s.name));
4017
+ const unused = [...library].filter((name) => !summaries.some((u) => u.skill === name));
3152
4018
  if (args.json) {
3153
- const library2 = new Set(librarySkills().map((s) => s.name));
3154
4019
  return json("usage", {
3155
4020
  usageDays: args.days,
3156
4021
  source: "Claude Code transcripts",
3157
- skills: summaries.map((s) => ({ skill: s.skill, uses: s.uses, lastUsed: s.lastUsed, projects: [...s.projects].map((p) => basename9(p)), inLibrary: library2.has(s.skill) })),
3158
- unusedLibrarySkills: librarySkills().filter((l) => !summaries.some((u) => u.skill === l.name)).map((l) => l.name)
4022
+ skills: summaries.map((s) => ({ skill: s.skill, uses: s.uses, lastUsed: s.lastUsed, projects: [...s.projects].map((p) => basename9(p)), inLibrary: library.has(s.skill) })),
4023
+ unusedLibrarySkills: unused
3159
4024
  });
3160
4025
  }
3161
4026
  if (summaries.length === 0) {
3162
4027
  info(`No skill usage in Claude Code transcripts from the last ${args.days} days.`);
3163
4028
  return;
3164
4029
  }
3165
- const library = new Set(librarySkills().map((s) => s.name));
3166
- info(`Skill usage, last ${args.days} days ${dim(`(from ${tildify(join14(claudeDir(), "projects"))})`)}
4030
+ info(`Skill usage, last ${args.days} days ${dim(`(from ${tildify(join15(claudeDir(), "projects"))})`)}
3167
4031
  `);
3168
4032
  table(
3169
4033
  summaries.map((s) => ({
@@ -3174,15 +4038,14 @@ async function usage(args) {
3174
4038
  Library: library.has(s.skill) ? green("yes") : dim("no")
3175
4039
  }))
3176
4040
  );
3177
- const unused = librarySkills().filter((s) => !summaries.some((u) => u.skill === s.name));
3178
4041
  if (unused.length > 0) {
3179
4042
  info("");
3180
- warn(`Library skills unused in the last ${args.days} days: ${unused.map((s) => s.name).join(", ")}`);
4043
+ warn(`Library skills unused in the last ${args.days} days: ${unused.join(", ")}`);
3181
4044
  }
3182
4045
  }
3183
4046
  function show(args) {
3184
4047
  const name = args.positional[0];
3185
- const dir = name ? join14(libraryDir(), name) : "";
4048
+ const dir = name ? join15(libraryDir(), name) : "";
3186
4049
  if (!name || !isSkillDir(dir)) {
3187
4050
  throw new Error(name ? `${name} is not in the library` : "Usage: skilllib show <skill-name>");
3188
4051
  }
@@ -3194,14 +4057,14 @@ function show(args) {
3194
4057
  path: dir,
3195
4058
  latest: latestVersion(name)?.version ?? null,
3196
4059
  origin: libraryOrigins()[name] ?? "",
3197
- projects: knownProjects().filter((p) => name in readManifest(p).skills).map((p) => basename9(p)),
3198
- skillMd: readFileSync9(join14(dir, "SKILL.md"), "utf-8")
4060
+ projects: projectsUsing(name).map((p) => basename9(p)),
4061
+ skillMd: readFileSync7(join15(dir, "SKILL.md"), "utf-8")
3199
4062
  });
3200
4063
  }
3201
4064
  info(`${name} ${dim(tildify(dir))}`);
3202
4065
  if (description) info(`
3203
4066
  ${description}`);
3204
- const using = knownProjects().filter((p) => name in readManifest(p).skills);
4067
+ const using = projectsUsing(name);
3205
4068
  info(`
3206
4069
  Installed in: ${using.map((p) => tildify(p)).join(", ") || "no projects"}`);
3207
4070
  }
@@ -3223,26 +4086,35 @@ function harnessesCommand(args) {
3223
4086
  New installs go to ${installDirs(enabled).join(" (real copy) + ")}${installDirs(enabled).length > 1 ? " (link)" : ""}.`));
3224
4087
  }
3225
4088
  function link(args) {
3226
- const roots = args.all ? visibleProjects() : [requireProject()];
4089
+ const roots = projectsFor(args);
3227
4090
  for (const root of roots) {
3228
- if (args.allowTracked) allowTrackedLinks(root);
3229
- const res = linkAll(root);
4091
+ const res = linkAll(root, { allowTracked: args.allowTracked });
3230
4092
  const label = args.all ? `${basename9(root)}: ` : "";
3231
4093
  if (res.linked.length) success(`${label}linked ${res.linked.length} skill(s) for your other agents`);
3232
- 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
+ }
3233
4103
  if (res.blocked.length) {
3234
4104
  process.exitCode = 1;
3235
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`);
3236
4106
  }
3237
- if (res.uncommitted.length)
4107
+ if (res.uncommitted.length) {
4108
+ process.exitCode = 1;
3238
4109
  warn(
3239
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`
3240
4111
  );
4112
+ }
3241
4113
  }
3242
4114
  }
3243
4115
  function globalCommand(args) {
3244
4116
  const [action, ...names] = args.positional;
3245
- const yours = machineSkills().filter((m) => m.movable && !m.broken);
4117
+ const yours = yourGlobals().filter((m) => m.harnesses.length);
3246
4118
  if (action === "move") return moveGlobal(args, names, yours);
3247
4119
  if (action === "keep" || action === "unkeep") {
3248
4120
  const unknown = names.filter((n) => !yours.some((m) => m.name === n));
@@ -3267,7 +4139,7 @@ function repoRoot(repo) {
3267
4139
  if (named.length === 1) return named[0];
3268
4140
  if (named.length > 1) throw new Error(`Several projects are named ${repo}: ${named.map(tildify).join(", ")}. Pass its path.`);
3269
4141
  const path = expandHome(repo);
3270
- if (!existsSync13(path)) throw new Error(`No project ${repo} (pass its path, or see skilllib projects)`);
4142
+ if (!existsSync14(path)) throw new Error(`No project ${repo} (pass its path, or see skilllib projects)`);
3271
4143
  const root = projectHere(path);
3272
4144
  if (root !== path) throw new Error(root ? `${tildify(path)} is inside ${tildify(root)}: pass the repo's top folder` : `${tildify(path)} isn't in a git repo`);
3273
4145
  return root;
@@ -3279,14 +4151,16 @@ function moveGlobal(args, names, yours) {
3279
4151
  const roots = [...new Set(args.to.map(repoRoot))];
3280
4152
  const skills = yours.filter((m) => names.includes(m.name));
3281
4153
  const moves = moveGlobalSkills(skills, roots, { allowTracked: args.allowTracked });
4154
+ rememberProjects(roots);
3282
4155
  if (moves.some((m) => !m.moved)) process.exitCode = 1;
3283
4156
  if (args.json) {
3284
4157
  return json(
3285
4158
  "global move",
3286
- moves.map(({ name, moved, reason, changes }) => ({
4159
+ moves.map(({ name, moved, reason, changes, left }) => ({
3287
4160
  skill: name,
3288
4161
  moved,
3289
4162
  ...reason ? { reason } : {},
4163
+ ...left ? { left } : {},
3290
4164
  projects: changes.map(([root, c]) => ({ project: basename9(root), path: root, change: withDirs(root, [c])[0] }))
3291
4165
  }))
3292
4166
  );
@@ -3294,20 +4168,18 @@ function moveGlobal(args, names, yours) {
3294
4168
  for (const m of moves) {
3295
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)`);
3296
4170
  else error(`${m.name}: ${m.reason}`);
3297
- for (const [root, c] of m.changes) {
3298
- if (c.blocked?.length) warn(` ${basename9(root)}: git tracks ${c.blocked.join(", ")}, so no link there. Re-run with --allow-tracked to add it.`);
3299
- if (c.uncommitted?.length) warn(` ${basename9(root)}: no link in ${c.uncommitted.join(", ")}: git tracks it but not the skill's copy, so the link would break for teammates. Commit the copy, then run skilllib link --allow-tracked.`);
3300
- }
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"}`);
3301
4173
  }
3302
4174
  if (moves.some((m) => m.moved)) info(dim("Commit skilllib.json and the skill folders in each repo."));
3303
4175
  }
3304
4176
  function tidy(args) {
3305
4177
  const names = args.positional;
3306
- 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)])) : {};
3307
4179
  if (args.keep && !names.length) {
3308
4180
  throw new Error("Usage: skilllib tidy <name>... --keep <folder>");
3309
4181
  }
3310
- const targets = args.global ? [null] : args.all ? visibleProjects() : [requireProject()];
4182
+ const targets = args.global ? [null] : projectsFor(args);
3311
4183
  const problems = [];
3312
4184
  for (const name of names) {
3313
4185
  const where = targets.map((root) => ({ root, ...foldersOf(root, name) })).filter((w) => w.all.length);
@@ -3335,30 +4207,35 @@ function tidy(args) {
3335
4207
  for (const { label, report } of reports) {
3336
4208
  const plans = report.plans.filter((p) => !names.length || names.includes(p.name));
3337
4209
  const conflicts = report.conflicts.filter((c) => !names.length || names.includes(c.name));
3338
- if (!plans.length && !conflicts.length && !args.all) success(`${label}: every skill has one copy and the links your agents need`);
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)`);
3339
4213
  for (const plan of plans) {
3340
- const result = args.dryRun ? { applied: [], held: gitVisibleSteps(plan) } : applyTidy(plan, { git: args.allowGit ? "go" : "keep" });
3341
- const show2 = args.dryRun ? plan.steps : result.applied;
3342
- if (show2.length) info(`${args.dryRun ? dim("would") : green("\u2713")} ${label} \xB7 ${plan.name}: ${show2.map((s) => describeStep(s, plan.root)).join(", ")}`);
3343
- if (!args.dryRun) for (const step of result.held) info(`${yellow("!")} ${label} \xB7 ${plan.name}: held back, git would see it: ${describeStep(step, plan.root)}`);
3344
- held += args.dryRun ? 0 : result.held.length;
4214
+ const wouldHold = args.allowGit ? [] : gitVisibleSteps(plan);
4215
+ const result = args.dryRun ? { applied: plan.steps.filter((s) => !wouldHold.includes(s)), held: wouldHold } : applyTidy(plan, { git: args.allowGit ? "go" : "keep" });
4216
+ if (result.applied.length) info(`${args.dryRun ? dim("would") : green("\u2713")} ${label} \xB7 ${plan.name}: ${result.applied.map((s) => describeStep(s, plan.root)).join(", ")}`);
4217
+ for (const step of result.held) info(`${yellow("!")} ${label} \xB7 ${plan.name}: ${args.dryRun ? "would be held" : "held"} back, git would see it: ${describeStep(step, plan.root)}`);
4218
+ held += result.held.length;
3345
4219
  changed += result.applied.length;
3346
4220
  }
3347
- if (args.keep && conflicts.length) process.exitCode = 1;
4221
+ if (conflicts.length && (args.keep || names.length)) process.exitCode = 1;
3348
4222
  for (const c of conflicts) {
3349
4223
  warn(`${label} \xB7 ${c.name}: the copies differ`);
3350
4224
  for (const copy of c.copies) {
3351
- const runs = copy.runs.map((id) => HARNESSES.find((h) => h.id === id).name);
3352
- info(` ${copyLabel(c, copy.dir)}${runs.length ? dim(` run by ${runs.join(", ")}`) : ""}${c.managed === copy.dir ? dim(" (skilllib's copy)") : ""}`);
4225
+ const runs = copy.runs.map((id) => harness(id).name);
4226
+ info(` ${folderLabel(c.root, copy.dir)}${runs.length ? dim(` run by ${runs.join(", ")}`) : ""}${c.managed === copy.dir ? dim(" (skilllib's copy)") : ""}`);
3353
4227
  }
3354
- const pick = copyLabel(c, c.managed ?? c.copies[0].dir);
4228
+ const pick = folderLabel(c.root, c.managed ?? c.copies[0].dir);
3355
4229
  info(dim(` keep one: skilllib tidy ${c.name}${args.global ? " --global" : ""} --keep ${pick}`));
3356
4230
  }
3357
4231
  for (const s of report.skipped.filter((x) => !names.length || names.includes(x.name))) info(dim(`- ${label} \xB7 ${s.name}: skipped, ${s.reason}`));
3358
4232
  }
3359
4233
  if (args.dryRun) info(dim("\nNothing changed (--dry-run)."));
3360
4234
  else if (changed) info(dim("\nReplaced copies are in ~/.skilllib/tidy-backup; `skilllib restore` puts them back."));
3361
- 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
+ }
3362
4239
  }
3363
4240
  function agentSkill(args) {
3364
4241
  const action = args.positional[0];
@@ -3378,6 +4255,7 @@ function agentSkill(args) {
3378
4255
  if (args.json) return json("agent-skill", { state: agentSkillState(), removed });
3379
4256
  return removed.length ? success(`Removed from ${removed.map((d) => tildify(d)).join(" and ")}`) : info("The skilllib skill isn't installed.");
3380
4257
  }
4258
+ if (action) throw new Error("Usage: skilllib agent-skill [install|remove]");
3381
4259
  const state = agentSkillState();
3382
4260
  if (args.json) return json("agent-skill", { state, dirs: agentSkillDirs() });
3383
4261
  if (state === "installed") success(`Your agents can use skilllib ${dim(`(${agentSkillDirs().map((d) => tildify(d)).join(", ")})`)}`);
@@ -3386,21 +4264,29 @@ function agentSkill(args) {
3386
4264
  }
3387
4265
 
3388
4266
  export {
4267
+ realPath,
3389
4268
  skilllibHome,
3390
4269
  PROJECT_SKILLS_DIR,
3391
4270
  HARNESSES,
3392
4271
  harness,
4272
+ installDirs,
3393
4273
  readSkillInfo,
4274
+ treeHash,
4275
+ isDir,
3394
4276
  versionHistory,
3395
4277
  forgetLatest,
3396
4278
  latestVersion,
3397
4279
  projectHere,
3398
4280
  readManifest,
4281
+ knownProjects,
4282
+ rememberProjects,
4283
+ readableRoots,
3399
4284
  setJsonMode,
3400
4285
  isJsonMode,
3401
4286
  json,
3402
4287
  error,
3403
4288
  tildify,
4289
+ localStamp,
3404
4290
  readConfig,
3405
4291
  addRoot,
3406
4292
  removeRoot,
@@ -3419,50 +4305,51 @@ export {
3419
4305
  recordOrigin,
3420
4306
  librarySkills,
3421
4307
  librarySkillDir,
3422
- visibilityOf,
4308
+ notWritable,
4309
+ inTheWaySaid,
4310
+ entryExists,
4311
+ survives,
4312
+ blindWithoutGlobal,
4313
+ strandedLinks,
4314
+ blindSaid,
4315
+ trackBlocker,
3423
4316
  projectStatus,
3424
4317
  linkEverywhere,
3425
4318
  relinkDependency,
4319
+ addRefusal,
3426
4320
  addSkill,
3427
4321
  removeSkill,
3428
- syncSkill,
3429
4322
  updateProject,
3430
4323
  importSkill,
3431
4324
  removeUntracked,
3432
4325
  projectsUsing,
3433
4326
  deleteLibrarySkill,
3434
4327
  deleteGlobal,
3435
- unloadGlobal,
3436
- linkGlobal,
3437
4328
  createSkill,
3438
4329
  listBackups,
4330
+ movedTogether,
3439
4331
  restoreBackup,
3440
4332
  gitInfo,
3441
4333
  relativeTo,
3442
- claudePlugins,
3443
4334
  installedPlugins,
3444
4335
  cursorPlugins,
3445
4336
  forgetClaudeBinary,
3446
4337
  claudeBinary,
3447
- turnOffIn,
3448
4338
  setPluginOn,
3449
4339
  uninstallPlugin,
3450
4340
  updatePlugin,
3451
4341
  pluginBackups,
3452
4342
  restorePlugin,
3453
- cursorPluginSkills,
3454
4343
  agentSkillState,
3455
4344
  installAgentSkill,
3456
4345
  removeAgentSkill,
3457
4346
  refreshAgentSkill,
3458
- planProjectTidy,
3459
- planGlobalTidy,
3460
- gitVisibleSteps,
3461
- applyTidy,
3462
- describeStep,
3463
- copyLabel,
3464
- addedAt,
4347
+ foldersOf,
4348
+ runFix,
4349
+ findIssues,
4350
+ usageIssues,
3465
4351
  scanUsage,
4352
+ skillOf,
3466
4353
  moveGlobalSkills,
3467
4354
  parseArgs,
3468
4355
  projectOfFactory,