skilllib 1.10.0 → 1.11.0

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.
package/README.md CHANGED
@@ -143,7 +143,7 @@ What skilllib flags:
143
143
 
144
144
  ### Adding and removing skills
145
145
 
146
- - **+ Add a skill…** searches your library. Its last rows create a new skill (it opens in `$EDITOR`, and goes into the repo when you close it) or copy a prompt for an agent to write it.
146
+ - **+ Add a skill…** searches your library. Tick several with `space` and add them in one go; a plugin's skills (e.g. ponytail and its `ponytail-*` commands) sit under one row that ticks or adds them all. Its last rows create a new skill (it opens in `$EDITOR`, and goes into the repo when you close it) or copy a prompt for an agent to write it.
147
147
  - **Adding** copies the newest version into the fewest folders your agents read, and records it in `skilllib.json`. Agents pick it up in their next session. The layout matches `npx skills`' symlink option: the real copy lives in `.agents/skills`, which nearly every agent reads, and Claude Code gets a link. Grok reads `.agents/skills` and, by default, `.claude/skills`, so it never needs its own copy.
148
148
  - Claude Code, with or without Cursor or Grok: `.claude/skills/<name>`.
149
149
  - Cursor, Codex, Zed or Grok without Claude Code: `.agents/skills/<name>`.
@@ -175,7 +175,7 @@ Edit a skill once in **Your skills** (Enter → *Edit SKILL.md*). Every repo tha
175
175
 
176
176
  **Global** lists everything that loads in every repo. Each of your global skills you haven't decided about is flagged **Not reviewed**, with three choices:
177
177
 
178
- - **Move it to the repos that need it…**: tick the repos (the ones where Claude Code used it are pre-ticked). It goes into your library and those repos, and stops loading globally. If a repo commits a folder one of your agents needs a link in (often `.claude/skills`), skilllib asks before adding it there.
178
+ - **Move it to the repos that need it…**: tick the repos (the ones where Claude Code used it are pre-ticked). It goes into your library and those repos, and stops loading globally. If a repo commits a folder one of your agents needs a link in (often `.claude/skills`), skilllib asks before adding it there. From the command line: `skilllib global move <name>... --to <repo>...`.
179
179
  - **Keep it global on purpose**: skilllib stops warning about it (also `skilllib global keep <name>`). The mark only records your decision; to undo it, pick **Stop marking it as global on purpose**.
180
180
  - **Delete it**: it goes to Settings › Backups.
181
181
 
@@ -370,10 +370,12 @@ skilllib tidy [--all|--global] one real copy per skill, plus only the links you
370
370
  skilllib list | show <name> your library
371
371
  skilllib import <dir>... add skill folders to your library (--global: all your global skills)
372
372
  skilllib global [keep|unkeep] your global skills; mark the ones you keep global on purpose
373
+ skilllib global move <name> --to <repo> load a global skill only in those repos
373
374
  skilllib new <name> [desc] create a skill
374
375
 
375
376
  skilllib usage [--days N] which skills Claude Code used
376
377
  skilllib doctor [--fix] find (and fix) problems
378
+ skilllib apply <id> [choice] apply one problem's fix, or the choice you pick (ids from status or doctor --json)
377
379
  skilllib restore [name] put back something skilllib moved out
378
380
  skilllib agent-skill [install|remove] teach your agents to use skilllib
379
381
  skilllib --version
@@ -385,13 +387,13 @@ Flags: `--force` (overwrite local edits, backed up to `~/.skilllib/edit-backup`)
385
387
 
386
388
  A command exits 1 when it couldn't do all you asked: a skill skipped (not in the library, or local edits in the way), links git-tracked folders blocked, an unknown `tidy` name or `--keep` folder. What it did do is still done, and listed.
387
389
 
388
- **`--json`** prints one line of JSON on stdout, for scripts and agents. Messages and warnings go to stderr. It works with `status`, `list`, `show`, `projects`, `outdated`, `usage`, `doctor`, `add`, `remove`, `sync`, `update`, `import` and `agent-skill`. The output is shaped to keep agents fast and cheap:
390
+ **`--json`** prints one line of JSON on stdout, for scripts and agents. Messages and warnings go to stderr. It works with `status`, `list`, `show`, `projects`, `outdated`, `usage`, `doctor`, `apply`, `add`, `remove`, `sync`, `update`, `import`, `global move` and `agent-skill`. The output is shaped to keep agents fast and cheap:
389
391
 
390
392
  - **`status --json`** answers everything about the repo in one call:
391
393
  - the repo's skills, with their source, version, state and git state
392
394
  - skills in subfolders (`nested`, e.g. monorepo packages), with their folder
393
395
  - the global skills, grouped by where they come from, with the plugins the repo's `.claude/settings.json` turns on or off
394
- - every problem skilllib found here, with its fix
396
+ - every problem skilllib found here, with its fix or choices and an id for `skilllib apply`
395
397
  - how often Claude Code used each skill here
396
398
  - the repo's top-level files
397
399
 
@@ -456,14 +458,10 @@ npm link # use your checkout as `skilllib`
456
458
 
457
459
  How the TUI is designed, and why: [docs/DESIGN.md](docs/DESIGN.md).
458
460
 
459
- **Releasing:**
460
- 1. Bump `version` in `package.json` and merge to `main`.
461
- 2. Tag and push:
462
- ```bash
463
- git tag v1.10.0 && git push --tags
464
- ```
465
-
466
- CI then tests on Linux, macOS and Windows, publishes to npm through [trusted publishing](https://docs.npmjs.com/trusted-publishers) (no token; provenance is automatic), and creates a GitHub release.
461
+ **Releasing:** automatic, with [release-please](https://github.com/googleapis/release-please). Don't edit `version` by hand.
462
+ 1. Write commits (and PR titles) as [Conventional Commits](https://www.conventionalcommits.org/): `fix:` → patch, `feat:` → minor, `feat!:` or a `BREAKING CHANGE:` footer → major. `docs:`, `ci:`, `chore:`, `test:` and `refactor:` don't release.
463
+ 2. After each merge to `main`, release-please keeps one PR, **chore(main): release X.Y.Z**, up to date with the next version and its `CHANGELOG.md`.
464
+ 3. Merge that PR when you want to release. It tags `vX.Y.Z` and creates the GitHub release; CI then tests, builds and publishes to npm through [trusted publishing](https://docs.npmjs.com/trusted-publishers) (no token; provenance is automatic).
467
465
 
468
466
  The earlier registry-server version (Next.js, Postgres, S3) lives on the `archive/registry-server` branch.
469
467
 
@@ -342,7 +342,10 @@ function knownProjects() {
342
342
  const path = projectsFile();
343
343
  if (!existsSync4(path)) return [];
344
344
  const list2 = JSON.parse(readFileSync3(path, "utf-8"));
345
- return onePerFolder(list2.filter((p) => existsSync4(p) && isProjectCandidate(p)));
345
+ return onePerFolder(list2.filter((p) => existsSync4(p) && isProjectCandidate(p) && !isAgentWorktree(p)));
346
+ }
347
+ function isAgentWorktree(path) {
348
+ return /[\\/]\.claude[\\/]worktrees[\\/]/.test(path);
346
349
  }
347
350
  function onePerFolder(paths) {
348
351
  const byReal = /* @__PURE__ */ new Map();
@@ -357,7 +360,7 @@ function isProjectCandidate(root) {
357
360
  return realPath(root) !== realPath(userHome());
358
361
  }
359
362
  function rememberProjects(roots) {
360
- const incoming = roots.map((r) => resolve3(r)).filter(isProjectCandidate);
363
+ const incoming = roots.map((r) => resolve3(r)).filter((r) => isProjectCandidate(r) && !isAgentWorktree(r));
361
364
  const all = onePerFolder([...knownProjects(), ...incoming]).sort();
362
365
  mkdirSync2(skilllibHome(), { recursive: true });
363
366
  writeFileSync2(projectsFile(), JSON.stringify(all, null, 2) + "\n");
@@ -1262,7 +1265,7 @@ function refreshAgentSkill() {
1262
1265
 
1263
1266
  // src/commands.ts
1264
1267
  import { existsSync as existsSync13, readFileSync as readFileSync9 } from "fs";
1265
- import { basename as basename8, join as join14, resolve as resolve7, sep as sep5 } from "path";
1268
+ import { basename as basename9, join as join14, resolve as resolve7, sep as sep5 } from "path";
1266
1269
 
1267
1270
  // src/git.ts
1268
1271
  import { execFileSync as execFileSync2 } from "child_process";
@@ -1989,7 +1992,7 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
1989
1992
  id: "review:global",
1990
1993
  severity: "suggestion",
1991
1994
  title: `${plural(unreviewed.length, "global skill")} of yours ${unreviewed.length === 1 ? "loads" : "load"} in every repo`,
1992
- detail: `${unreviewed.join(", ")}. Move each to the repos that need it (skilllib \u2192 Global \u2192 Move it to the repos that need it\u2026), or mark the ones you want everywhere as global on purpose (skilllib global keep <name>).`
1995
+ 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>).`
1993
1996
  });
1994
1997
  }
1995
1998
  const enabled = enabledHarnesses();
@@ -2015,7 +2018,7 @@ function findIssues(projects2, machine, libraryNames, statusOf = projectStatus,
2015
2018
  id: `usable:global${onPurpose ? ":kept" : ""}`,
2016
2019
  severity: "suggestion",
2017
2020
  title: `${plural(skills.length, "global skill")}${onPurpose ? " you keep global" : ""} not usable by ${agentNames(missing)}`,
2018
- 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 \u2192 Global), which works for every agent too.`}`,
2021
+ 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.`}`,
2019
2022
  ...onPurpose ? { fix: link2 } : { choices: [link2] }
2020
2023
  });
2021
2024
  }
@@ -2377,6 +2380,9 @@ function topLevel(root) {
2377
2380
  return [];
2378
2381
  }
2379
2382
  }
2383
+ function statusIssues(root, machine = machineSkills(enabledHarnesses())) {
2384
+ return findIssues(projectHere(root) === root ? [root] : [], machine, new Set(librarySkills().map((l) => l.name)));
2385
+ }
2380
2386
  function usableHere(root, uses = /* @__PURE__ */ new Map()) {
2381
2387
  const agents = enabledHarnesses();
2382
2388
  const inRepo = projectHere(root) === root;
@@ -2412,11 +2418,12 @@ function usableHere(root, uses = /* @__PURE__ */ new Map()) {
2412
2418
  })
2413
2419
  );
2414
2420
  const issues = /* @__PURE__ */ new Map();
2415
- for (const i of findIssues(inRepo ? [root] : [], machine, new Set(librarySkills().map((l) => l.name)))) {
2421
+ for (const i of statusIssues(root, machine)) {
2416
2422
  const choices = i.choices?.map((c) => c.label);
2417
2423
  const key = `${i.detail}\0${i.fix?.label ?? ""}\0${choices?.join() ?? ""}`;
2418
- const issue = issues.get(key) ?? { problems: [], detail: i.detail, fix: i.fix?.label, choices };
2424
+ const issue = issues.get(key) ?? { problems: [], ids: [], detail: i.detail, fix: i.fix?.label, choices };
2419
2425
  issue.problems.push(i.title);
2426
+ issue.ids.push(i.id);
2420
2427
  issues.set(key, issue);
2421
2428
  }
2422
2429
  const usesHere = Object.fromEntries([...uses].filter(([, n]) => n > 0));
@@ -2576,6 +2583,25 @@ function usesByProject(uses, projectOf) {
2576
2583
  return byProject;
2577
2584
  }
2578
2585
 
2586
+ // src/move.ts
2587
+ import { basename as basename8 } from "path";
2588
+ function moveGlobalSkills(skills, roots, { group, allowTracked = false, label = basename8 } = {}) {
2589
+ const byName = /* @__PURE__ */ new Map();
2590
+ for (const m of skills) byName.set(m.name, [...byName.get(m.name) ?? [], m]);
2591
+ return [...byName].map(([name, copies]) => {
2592
+ if (new Set(copies.map((m) => treeHash(m.path))).size > 1)
2593
+ return { name, moved: false, reason: `your global copies of ${name} differ; nothing moved (keep one first: skilllib tidy --global)`, changes: [] };
2594
+ if (importSkill(copies[0].path).status === "exists")
2595
+ return { name, moved: false, reason: `your library has a different ${name}; nothing moved (update your library from this copy first)`, changes: [] };
2596
+ if (group) recordOrigin(name, `group: ${group}`);
2597
+ const changes = roots.map((root) => [root, addSkill(root, name, { allowTracked })]);
2598
+ const skipped = changes.filter(([, c]) => c.action === "skipped");
2599
+ 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 };
2600
+ const failed = copies.map((m) => unloadGlobal(m.path, m.links)).find((r) => !r.ok);
2601
+ return failed && !failed.ok ? { name, moved: false, reason: failed.reason, changes } : { name, moved: true, changes };
2602
+ });
2603
+ }
2604
+
2579
2605
  // src/commands.ts
2580
2606
  var USAGE_DAYS = 30;
2581
2607
  function parseArgs(argv) {
@@ -2583,7 +2609,7 @@ function parseArgs(argv) {
2583
2609
  const days = daysIdx >= 0 ? Number(argv[daysIdx + 1]) : USAGE_DAYS;
2584
2610
  const keepIdx = argv.indexOf("--keep");
2585
2611
  return {
2586
- positional: argv.filter((a, i) => !a.startsWith("--") && argv[i - 1] !== "--days" && argv[i - 1] !== "--keep"),
2612
+ positional: argv.filter((a, i) => !a.startsWith("--") && !["--days", "--keep", "--to"].includes(argv[i - 1])),
2587
2613
  force: argv.includes("--force"),
2588
2614
  all: argv.includes("--all"),
2589
2615
  global: argv.includes("--global"),
@@ -2593,7 +2619,8 @@ function parseArgs(argv) {
2593
2619
  days: Number.isFinite(days) && days > 0 ? days : USAGE_DAYS,
2594
2620
  dryRun: argv.includes("--dry-run"),
2595
2621
  allowGit: argv.includes("--allow-git"),
2596
- ...keepIdx >= 0 && argv[keepIdx + 1] ? { keep: argv[keepIdx + 1] } : {}
2622
+ ...keepIdx >= 0 && argv[keepIdx + 1] ? { keep: argv[keepIdx + 1] } : {},
2623
+ to: argv.flatMap((a, i) => argv[i - 1] === "--to" && !a.startsWith("--") ? a.split(",").filter(Boolean) : [])
2597
2624
  };
2598
2625
  }
2599
2626
  function colorState(state) {
@@ -2673,7 +2700,7 @@ async function status(args) {
2673
2700
  const root = requireProject();
2674
2701
  const skills = projectStatus(root);
2675
2702
  const nested = nestedSkills(root);
2676
- info(`${basename8(root)} ${dim(tildify(root))}
2703
+ info(`${basename9(root)} ${dim(tildify(root))}
2677
2704
  `);
2678
2705
  if (skills.length === 0 && nested.length === 0) info("No skills in this project yet. Add one from your library with: skilllib add <name>");
2679
2706
  const usage2 = await usageBySkill(args.days, root);
@@ -2750,7 +2777,7 @@ async function list(args) {
2750
2777
  const installs = /* @__PURE__ */ new Map();
2751
2778
  for (const project of visibleProjects()) {
2752
2779
  for (const name of Object.keys(readManifest(project).skills)) {
2753
- installs.set(name, [...installs.get(name) ?? [], basename8(project)]);
2780
+ installs.set(name, [...installs.get(name) ?? [], basename9(project)]);
2754
2781
  }
2755
2782
  }
2756
2783
  const usage2 = await usageBySkill(args.days);
@@ -2787,7 +2814,7 @@ function projects(args) {
2787
2814
  "projects",
2788
2815
  roots.map(
2789
2816
  (root) => ({
2790
- name: basename8(root),
2817
+ name: basename9(root),
2791
2818
  path: root,
2792
2819
  skills: projectStatus(root).map(
2793
2820
  (s) => ({ name: s.name, managed: s.managed, state: s.state, version: s.version, latest: s.latest })
@@ -2806,7 +2833,7 @@ function projects(args) {
2806
2833
  const managed = skills.filter((s) => s.managed);
2807
2834
  const attention = skills.filter((s) => s.state !== "ok" && s.state !== "local only").length;
2808
2835
  return {
2809
- Project: basename8(root),
2836
+ Project: basename9(root),
2810
2837
  Path: dim(tildify(root)),
2811
2838
  Skills: managed.map((s) => s.name).join(", ") || dim("none"),
2812
2839
  "Local only": String(skills.filter((s) => s.state === "local only").length),
@@ -2840,13 +2867,13 @@ function sync(args) {
2840
2867
  const changes = syncProject(root, { force: args.force });
2841
2868
  if (changes.some((c) => c.action === "skipped")) process.exitCode = 1;
2842
2869
  if (!args.json) {
2843
- if (args.all) info(`${basename8(root)} ${dim(tildify(root))}`);
2870
+ if (args.all) info(`${basename9(root)} ${dim(tildify(root))}`);
2844
2871
  if (changes.length === 0) {
2845
2872
  if (existsSync13(join14(root, MANIFEST_FILE))) success("Installed versions match skilllib.json");
2846
2873
  else info(dim("No skilllib.json: nothing to sync"));
2847
2874
  } else printChanges(root, changes, args);
2848
2875
  }
2849
- return { project: basename8(root), path: root, changes: withDirs(root, changes) };
2876
+ return { project: basename9(root), path: root, changes: withDirs(root, changes) };
2850
2877
  });
2851
2878
  if (args.json) args.all ? json("changes --all", results) : json("changes", results[0].changes);
2852
2879
  if (!args.all) rememberProjects(roots);
@@ -2863,18 +2890,18 @@ function update(args) {
2863
2890
  }
2864
2891
  if (changes.some((c) => c.action === "skipped")) process.exitCode = 1;
2865
2892
  if (!args.json) {
2866
- if (args.all) info(`${basename8(root)} ${dim(tildify(root))}`);
2893
+ if (args.all) info(`${basename9(root)} ${dim(tildify(root))}`);
2867
2894
  if (changes.length === 0) success("Everything is on the latest version");
2868
2895
  else printChanges(root, changes, args);
2869
2896
  }
2870
- return { project: basename8(root), path: root, changes: withDirs(root, changes) };
2897
+ return { project: basename9(root), path: root, changes: withDirs(root, changes) };
2871
2898
  });
2872
2899
  if (args.json) args.all ? json("changes --all", results) : json("changes", results[0].changes);
2873
2900
  }
2874
2901
  function outdated(args) {
2875
2902
  const roots = args.all ? visibleProjects() : [requireProject()];
2876
2903
  const behind = roots.flatMap(
2877
- (root) => projectStatus(root).filter((s) => s.managed && s.latest !== null && s.version !== null && s.latest > s.version).map((s) => ({ project: basename8(root), path: root, skill: s.name, installed: s.version, latest: s.latest, state: s.state }))
2904
+ (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 }))
2878
2905
  );
2879
2906
  if (args.json) return json("outdated", behind);
2880
2907
  const rows = behind.map((b) => ({ Project: b.project, Skill: b.skill, Installed: `v${b.installed}`, Latest: `v${b.latest}`, Status: colorState(b.state) }));
@@ -2908,7 +2935,7 @@ function importSkills(args) {
2908
2935
  if (skillsDir === join14(root, ".claude", "skills") && isProjectCandidate(root)) {
2909
2936
  if (readManifest(root).skills[name]?.hash !== treeHash(dir)) {
2910
2937
  const change = addSkill(root, name);
2911
- if (change.action !== "skipped") info(dim(` ${basename8(root)} now depends on ${name} v${change.to}`));
2938
+ if (change.action !== "skipped") info(dim(` ${basename9(root)} now depends on ${name} v${change.to}`));
2912
2939
  }
2913
2940
  rememberProjects([root]);
2914
2941
  }
@@ -2925,14 +2952,14 @@ function scan(args) {
2925
2952
  const found = discoverProjects();
2926
2953
  const library = new Set(librarySkills().map((s) => s.name));
2927
2954
  const skillNames = (root) => [
2928
- .../* @__PURE__ */ new Set([...ALL_PROJECT_DIRS.flatMap((d) => skillDirsIn(join14(root, d))).map((d) => basename8(d)), ...nestedSkills(root).map((s) => s.name)])
2955
+ .../* @__PURE__ */ new Set([...ALL_PROJECT_DIRS.flatMap((d) => skillDirsIn(join14(root, d))).map((d) => basename9(d)), ...nestedSkills(root).map((s) => s.name)])
2929
2956
  ];
2930
2957
  const withSkills = found.filter((root) => skillNames(root).length > 0);
2931
2958
  info(`Scanned ${roots.map((r) => tildify(r)).join(", ")}: ${found.length} project${found.length === 1 ? "" : "s"}, ${withSkills.length} with skills
2932
2959
  `);
2933
2960
  table(
2934
2961
  withSkills.map((root) => ({
2935
- Project: basename8(root),
2962
+ Project: basename9(root),
2936
2963
  Path: dim(tildify(root)),
2937
2964
  Skills: skillNames(root).map((n) => library.has(n) ? n : yellow(n)).join(", ")
2938
2965
  }))
@@ -3016,12 +3043,12 @@ function restore(args) {
3016
3043
  process.exitCode = 1;
3017
3044
  }
3018
3045
  }
3019
- async function doctor(args) {
3046
+ async function allIssues(args, withUsage = true) {
3020
3047
  const projects2 = visibleProjects();
3021
3048
  const machine = machineSkills();
3022
3049
  const libraryNames = new Set(librarySkills().map((s) => s.name));
3023
3050
  const issues = findIssues(projects2, machine, libraryNames);
3024
- if (enabledHarnesses().includes("claude-code")) {
3051
+ if (withUsage && enabledHarnesses().includes("claude-code")) {
3025
3052
  const projectOf = projectOfFactory(projects2);
3026
3053
  const uses = await scanUsage(args.days);
3027
3054
  const inProject = usesByProject(uses, projectOf);
@@ -3034,6 +3061,10 @@ async function doctor(args) {
3034
3061
  })
3035
3062
  );
3036
3063
  }
3064
+ return issues;
3065
+ }
3066
+ async function doctor(args) {
3067
+ const issues = await allIssues(args);
3037
3068
  const fixed = (fix) => {
3038
3069
  const r = runFix(fix);
3039
3070
  if (!r.ok) process.exitCode = 1;
@@ -3059,13 +3090,38 @@ async function doctor(args) {
3059
3090
  for (const issue of issues) {
3060
3091
  info(`${issue.severity === "problem" ? red("\u25CF") : yellow("\u25C6")} ${issue.title}`);
3061
3092
  info(dim(` ${issue.detail}${issue.fix ? ` \u2192 ${issue.fix.label}` : ""}`));
3062
- if (issue.choices) info(dim(` choose in skilllib \u2192 Health: ${issue.choices.map((c) => c.label).join(" / ")}`));
3093
+ if (issue.choices) info(dim(` your call: ${issue.choices.map((c, i) => `${i + 1}) ${c.label}`).join(" ")} \u2192 skilllib apply ${issue.id} <n>`));
3063
3094
  if (args.fix && issue.fix) {
3064
3095
  const r = fixed(issue.fix);
3065
3096
  info(` ${r.ok ? green("\u2713") : red("\u2717")} ${r.message}`);
3066
3097
  }
3067
3098
  }
3068
- if (!args.fix && issues.some((i) => i.fix)) info(dim("\nRun `skilllib doctor --fix` to apply the fixes, or fix them one by one in `skilllib` \u2192 Health."));
3099
+ if (!args.fix && issues.some((i) => i.fix)) info(dim("\nRun `skilllib doctor --fix` to apply the fixes, or one at a time with `skilllib apply <id>`."));
3100
+ }
3101
+ async function apply(args) {
3102
+ const [id, pick] = args.positional;
3103
+ if (!id) throw new Error("Usage: skilllib apply <issue-id> [choice] (ids and choices: skilllib status --json or doctor --json)");
3104
+ const found = [...statusIssues(findProjectRoot()), ...await allIssues(args, id.startsWith("unused:"))].filter((i) => i.id === id);
3105
+ 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`);
3106
+ const optionsOf = (i) => i.choices ?? (i.fix ? [i.fix] : []);
3107
+ const labels = (i) => optionsOf(i).map((c) => c.label).join("\0");
3108
+ const byLabel = pick === void 0 ? void 0 : found.find((i) => optionsOf(i).some((c) => c.label.toLowerCase() === pick.toLowerCase()));
3109
+ if (!byLabel && found.some((i) => labels(i) !== labels(found[0]))) {
3110
+ const all = [...new Set(found.flatMap((i) => optionsOf(i).map((c) => c.label)))];
3111
+ 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(" ")}`);
3112
+ }
3113
+ const issue = byLabel ?? found[0];
3114
+ const options = optionsOf(issue);
3115
+ if (!options.length) throw new Error(`${issue.title}: nothing to apply. ${issue.detail}`);
3116
+ const listed = options.map((c, i) => `${i + 1}) ${c.label}`).join(" ");
3117
+ if (pick === void 0 && options.length > 1) throw new Error(`${issue.title}: pick one: ${listed}`);
3118
+ const choice = pick === void 0 ? options[0] : options.find((c) => c.label.toLowerCase() === pick.toLowerCase()) ?? options[Number(pick) - 1];
3119
+ if (!choice) throw new Error(`${issue.title}: no choice ${pick}. Pick one: ${listed}`);
3120
+ const r = runFix(choice);
3121
+ if (!r.ok) process.exitCode = 1;
3122
+ if (args.json) return json("apply", { id, applied: choice.label, ...r });
3123
+ if (r.ok) success(r.message);
3124
+ else error(r.message);
3069
3125
  }
3070
3126
  async function usage(args) {
3071
3127
  const summaries = summarize(await scanUsage(args.days), projectOfFactory(knownProjects()));
@@ -3074,7 +3130,7 @@ async function usage(args) {
3074
3130
  return json("usage", {
3075
3131
  usageDays: args.days,
3076
3132
  source: "Claude Code transcripts",
3077
- skills: summaries.map((s) => ({ skill: s.skill, uses: s.uses, lastUsed: s.lastUsed, projects: [...s.projects].map((p) => basename8(p)), inLibrary: library2.has(s.skill) })),
3133
+ 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) })),
3078
3134
  unusedLibrarySkills: librarySkills().filter((l) => !summaries.some((u) => u.skill === l.name)).map((l) => l.name)
3079
3135
  });
3080
3136
  }
@@ -3089,7 +3145,7 @@ async function usage(args) {
3089
3145
  summaries.map((s) => ({
3090
3146
  Skill: s.skill,
3091
3147
  Uses: String(s.uses),
3092
- Projects: [...s.projects].map((p) => basename8(p)).join(", ") || dim("-"),
3148
+ Projects: [...s.projects].map((p) => basename9(p)).join(", ") || dim("-"),
3093
3149
  "Last used": lastUsed(s),
3094
3150
  Library: library.has(s.skill) ? green("yes") : dim("no")
3095
3151
  }))
@@ -3115,7 +3171,7 @@ function show(args) {
3115
3171
  path: dir,
3116
3172
  latest: latestVersion(name)?.version ?? null,
3117
3173
  origin: libraryOrigins()[name] ?? "",
3118
- projects: knownProjects().filter((p) => name in readManifest(p).skills).map((p) => basename8(p)),
3174
+ projects: knownProjects().filter((p) => name in readManifest(p).skills).map((p) => basename9(p)),
3119
3175
  skillMd: readFileSync9(join14(dir, "SKILL.md"), "utf-8")
3120
3176
  });
3121
3177
  }
@@ -3149,7 +3205,7 @@ function link(args) {
3149
3205
  for (const root of roots) {
3150
3206
  if (args.allowTracked) allowTrackedLinks(root);
3151
3207
  const res = linkAll(root);
3152
- const label = args.all ? `${basename8(root)}: ` : "";
3208
+ const label = args.all ? `${basename9(root)}: ` : "";
3153
3209
  if (res.linked.length) success(`${label}linked ${res.linked.length} skill(s) for your other agents`);
3154
3210
  else if (!args.all && !res.blocked.length && !res.uncommitted.length) success("Every skill here is already usable by all your agents");
3155
3211
  if (res.blocked.length) {
@@ -3165,6 +3221,7 @@ function link(args) {
3165
3221
  function globalCommand(args) {
3166
3222
  const [action, ...names] = args.positional;
3167
3223
  const yours = machineSkills().filter((m) => m.movable && !m.broken);
3224
+ if (action === "move") return moveGlobal(args, names, yours);
3168
3225
  if (action === "keep" || action === "unkeep") {
3169
3226
  const unknown = names.filter((n) => !yours.some((m) => m.name === n));
3170
3227
  if (!names.length || unknown.length) {
@@ -3174,7 +3231,7 @@ function globalCommand(args) {
3174
3231
  setKeepGlobal(names, action === "keep");
3175
3232
  success(action === "keep" ? `Keeping ${names.join(", ")} global on purpose` : `${names.join(", ")} will show up in cleanup again`);
3176
3233
  } else if (action) {
3177
- error("Usage: skilllib global [keep|unkeep <name>...]");
3234
+ error("Usage: skilllib global [keep|unkeep <name>...] | skilllib global move <name>... --to <repo>...");
3178
3235
  process.exit(1);
3179
3236
  }
3180
3237
  const kept = keptGlobal();
@@ -3184,6 +3241,46 @@ function globalCommand(args) {
3184
3241
  if (unreviewed) info(dim(`
3185
3242
  ${unreviewed} not reviewed. Keep one global on purpose: skilllib global keep <name>`));
3186
3243
  }
3244
+ function repoRoot(repo) {
3245
+ const looksLikePath = /[\\/]/.test(repo) || repo.startsWith(".") || repo.startsWith("~");
3246
+ const named = looksLikePath ? [] : visibleProjects().filter((p) => basename9(p) === repo);
3247
+ if (named.length === 1) return named[0];
3248
+ if (named.length > 1) throw new Error(`Several projects are named ${repo}: ${named.map(tildify).join(", ")}. Pass its path.`);
3249
+ const path = expandHome(repo);
3250
+ if (!existsSync13(path)) throw new Error(`No project ${repo} (pass its path, or see skilllib projects)`);
3251
+ const root = projectHere(path);
3252
+ 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`);
3253
+ return root;
3254
+ }
3255
+ function moveGlobal(args, names, yours) {
3256
+ if (!names.length || !args.to.length) throw new Error("Usage: skilllib global move <name>... --to <repo>[,<repo>...]");
3257
+ const unknown = names.filter((n) => !yours.some((m) => m.name === n));
3258
+ if (unknown.length) throw new Error(`Not one of your global skills: ${unknown.join(", ")} (see skilllib global)`);
3259
+ const roots = [...new Set(args.to.map(repoRoot))];
3260
+ const skills = yours.filter((m) => names.includes(m.name));
3261
+ const moves = moveGlobalSkills(skills, roots, { allowTracked: args.allowTracked });
3262
+ if (moves.some((m) => !m.moved)) process.exitCode = 1;
3263
+ if (args.json) {
3264
+ return json(
3265
+ "global move",
3266
+ moves.map(({ name, moved, reason, changes }) => ({
3267
+ skill: name,
3268
+ moved,
3269
+ ...reason ? { reason } : {},
3270
+ projects: changes.map(([root, c]) => ({ project: basename9(root), path: root, change: withDirs(root, [c])[0] }))
3271
+ }))
3272
+ );
3273
+ }
3274
+ for (const m of moves) {
3275
+ if (m.moved) success(`${m.name} now loads only in ${m.changes.map(([r]) => basename9(r)).join(", ")} (the global copy is in skilllib restore)`);
3276
+ else error(`${m.name}: ${m.reason}`);
3277
+ for (const [root, c] of m.changes) {
3278
+ if (c.blocked?.length) warn(` ${basename9(root)}: git tracks ${c.blocked.join(", ")}, so no link there. Re-run with --allow-tracked to add it.`);
3279
+ 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.`);
3280
+ }
3281
+ }
3282
+ if (moves.some((m) => m.moved)) info(dim("Commit skilllib.json and the skill folders in each repo."));
3283
+ }
3187
3284
  function tidy(args) {
3188
3285
  const names = args.positional;
3189
3286
  const keepFor = (base) => args.keep ? Object.fromEntries(names.map((n) => [n, base ? resolve7(base, args.keep) : expandHome(args.keep)])) : {};
@@ -3196,13 +3293,13 @@ function tidy(args) {
3196
3293
  for (const name of names) {
3197
3294
  const where = targets.map((root) => ({ root, ...foldersOf(root, name) })).filter((w) => w.all.length);
3198
3295
  if (!where.length) {
3199
- problems.push(`${name}: no skill by that name ${args.global ? "in your global folders" : args.all ? "in your projects" : `in ${basename8(targets[0])}`}`);
3296
+ problems.push(`${name}: no skill by that name ${args.global ? "in your global folders" : args.all ? "in your projects" : `in ${basename9(targets[0])}`}`);
3200
3297
  continue;
3201
3298
  }
3202
3299
  if (!args.keep) continue;
3203
3300
  for (const { root, real } of where) {
3204
3301
  if (real.includes(keepFor(root)[name])) continue;
3205
- const label = root ? `${basename8(root)} \xB7 ` : "";
3302
+ const label = root ? `${basename9(root)} \xB7 ` : "";
3206
3303
  problems.push(
3207
3304
  `${label}${name}: no copy in ${args.keep}; ${real.length ? `pick one of: ${real.map((d) => folderLabel(root, d)).join(", ")}` : "it has no real copy to keep"}`
3208
3305
  );
@@ -3213,7 +3310,7 @@ function tidy(args) {
3213
3310
  process.exit(1);
3214
3311
  }
3215
3312
  const reports = targets.map(
3216
- (root) => root === null ? { label: "global folders", report: planGlobalTidy({ keep: keepFor(null) }) } : { label: basename8(root), report: planProjectTidy(root, { keep: keepFor(root) }) }
3313
+ (root) => root === null ? { label: "global folders", report: planGlobalTidy({ keep: keepFor(null) }) } : { label: basename9(root), report: planProjectTidy(root, { keep: keepFor(root) }) }
3217
3314
  );
3218
3315
  let held = 0;
3219
3316
  let changed = 0;
@@ -3347,6 +3444,7 @@ export {
3347
3444
  copyLabel,
3348
3445
  addedAt,
3349
3446
  scanUsage,
3447
+ moveGlobalSkills,
3350
3448
  parseArgs,
3351
3449
  projectOfFactory,
3352
3450
  status,
@@ -3365,6 +3463,7 @@ export {
3365
3463
  newSkill,
3366
3464
  restore,
3367
3465
  doctor,
3466
+ apply,
3368
3467
  usage,
3369
3468
  show,
3370
3469
  harnessesCommand,
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import {
3
3
  add,
4
4
  agentSkill,
5
+ apply,
5
6
  doctor,
6
7
  error,
7
8
  folders,
@@ -27,7 +28,7 @@ import {
27
28
  unhide,
28
29
  update,
29
30
  usage
30
- } from "./chunk-Z2F5W3DY.js";
31
+ } from "./chunk-LO4FMXDE.js";
31
32
 
32
33
  // src/index.ts
33
34
  import { readFileSync } from "fs";
@@ -43,6 +44,8 @@ Set up:
43
44
  skilllib import <dir>... Add skill folders to the library (--force replaces)
44
45
  skilllib import --global Copy your global skills (every agent's folder) into the library
45
46
  skilllib global [keep|unkeep <name>...] Your global skills; mark ones you keep global on purpose
47
+ skilllib global move <name>... --to <repo>...
48
+ Load global skills only in those repos (by path or project name)
46
49
 
47
50
  In a project (a git repo or a folder with skilllib.json; never your home folder):
48
51
  skilllib status This project's skills, which agents load them, and issues
@@ -65,6 +68,7 @@ Library:
65
68
 
66
69
  Health:
67
70
  skilllib doctor [--fix] Broken links, duplicates, out-of-date projects
71
+ skilllib apply <id> [choice] Apply one issue's fix, or the choice you pick (number or label)
68
72
  skilllib restore [name] Restore something skilllib moved out of the way
69
73
  skilllib unhide <path> Show a hidden project again
70
74
 
@@ -73,7 +77,7 @@ Agents:
73
77
  Teach your agents to use skilllib, so you can ask them
74
78
  "which skills can you use here?" in any repo
75
79
  --json Machine-readable output (status list show projects outdated
76
- usage doctor add remove sync update import agent-skill)
80
+ usage doctor apply add remove sync update import global move agent-skill)
77
81
 
78
82
  Options: --force (overwrite local edits) --allow-tracked (link into git-tracked skill folders) --days N
79
83
  `.trim();
@@ -92,10 +96,10 @@ async function main() {
92
96
  if (!["--version", "-v", "version", "help", "--help", "-h"].includes(command ?? "")) refreshAgentSkill();
93
97
  switch (command) {
94
98
  case void 0:
95
- return process.stdin.isTTY && process.stdout.isTTY ? (await import("./tui-7CMGG6HX.js")).tui() : overview(args);
99
+ return process.stdin.isTTY && process.stdout.isTTY ? (await import("./tui-3LDZ5GOZ.js")).tui() : overview(args);
96
100
  case "ui":
97
101
  if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("`skilllib ui` needs an interactive terminal. Agents and scripts: use `skilllib status --json`.");
98
- return (await import("./tui-7CMGG6HX.js")).tui();
102
+ return (await import("./tui-3LDZ5GOZ.js")).tui();
99
103
  case "status":
100
104
  return status(args);
101
105
  case "add":
@@ -144,6 +148,8 @@ async function main() {
144
148
  return restore(args);
145
149
  case "doctor":
146
150
  return doctor(args);
151
+ case "apply":
152
+ return apply(args);
147
153
  case "usage":
148
154
  return usage(args);
149
155
  case "agent-skill":
package/dist/json.d.cts CHANGED
@@ -70,10 +70,13 @@ type StatusGlobalGroup = {
70
70
  };
71
71
  /**
72
72
  * Problems skilllib found here or in your global skills that share a cause and remedy: a `fix` is a
73
- * safe repair (`skilllib doctor --fix` runs it); `choices` need the user to decide (skilllib → Health).
73
+ * safe repair (`skilllib doctor --fix` runs it); `choices` need the user to decide.
74
+ * `skilllib apply <id> [choice]` applies either, one problem at a time.
74
75
  */
75
76
  type StatusIssue = {
76
77
  problems: string[];
78
+ /** One per problem, in the same order: what `skilllib apply` takes. */
79
+ ids: string[];
77
80
  detail: string;
78
81
  fix?: string;
79
82
  choices?: string[];
@@ -167,7 +170,7 @@ type DoctorJson = {
167
170
  detail: string;
168
171
  /** A safe repair; `doctor --fix` runs it. */
169
172
  fix?: string;
170
- /** Fixes that need a decision (skilllib → Health). */
173
+ /** Fixes that need a decision: `skilllib apply <id> <n>` runs the nth (from 1). */
171
174
  choices?: string[];
172
175
  /** With --fix: what running `fix` did. */
173
176
  fixed?: {
@@ -175,6 +178,25 @@ type DoctorJson = {
175
178
  message: string;
176
179
  };
177
180
  }[];
181
+ /** `skilllib apply <issue-id> [choice] --json` */
182
+ type ApplyJson = {
183
+ id: string;
184
+ applied: string;
185
+ ok: boolean;
186
+ message: string;
187
+ };
188
+ /** `skilllib global move <name>... --to <repo>... --json`: one entry per skill. */
189
+ type GlobalMoveJson = {
190
+ skill: string;
191
+ /** Added to every repo and no longer global. When false, it still loads globally and `reason` says why. */
192
+ moved: boolean;
193
+ reason?: string;
194
+ projects: {
195
+ project: string;
196
+ path: string;
197
+ change: ChangeJson;
198
+ }[];
199
+ }[];
178
200
  /** `skilllib usage [--days N] --json` */
179
201
  type UsageJson = {
180
202
  usageDays: number;
@@ -197,4 +219,4 @@ type AgentSkillJson = {
197
219
  removed?: string[];
198
220
  };
199
221
 
200
- export type { AgentSkillJson, AgentSkillState, AllChangesJson, ChangeJson, ChangesJson, DoctorJson, GitState, HarnessId, ImportJson, ListJson, OutdatedJson, ProjectSource, ProjectsJson, ShowJson, SkillState, SourceKind, StatusGlobalGroup, StatusIssue, StatusJson, StatusSkillGroup, UsageJson };
222
+ export type { AgentSkillJson, AgentSkillState, AllChangesJson, ApplyJson, ChangeJson, ChangesJson, DoctorJson, GitState, GlobalMoveJson, HarnessId, ImportJson, ListJson, OutdatedJson, ProjectSource, ProjectsJson, ShowJson, SkillState, SourceKind, StatusGlobalGroup, StatusIssue, StatusJson, StatusSkillGroup, UsageJson };
package/dist/json.d.ts CHANGED
@@ -70,10 +70,13 @@ type StatusGlobalGroup = {
70
70
  };
71
71
  /**
72
72
  * Problems skilllib found here or in your global skills that share a cause and remedy: a `fix` is a
73
- * safe repair (`skilllib doctor --fix` runs it); `choices` need the user to decide (skilllib → Health).
73
+ * safe repair (`skilllib doctor --fix` runs it); `choices` need the user to decide.
74
+ * `skilllib apply <id> [choice]` applies either, one problem at a time.
74
75
  */
75
76
  type StatusIssue = {
76
77
  problems: string[];
78
+ /** One per problem, in the same order: what `skilllib apply` takes. */
79
+ ids: string[];
77
80
  detail: string;
78
81
  fix?: string;
79
82
  choices?: string[];
@@ -167,7 +170,7 @@ type DoctorJson = {
167
170
  detail: string;
168
171
  /** A safe repair; `doctor --fix` runs it. */
169
172
  fix?: string;
170
- /** Fixes that need a decision (skilllib → Health). */
173
+ /** Fixes that need a decision: `skilllib apply <id> <n>` runs the nth (from 1). */
171
174
  choices?: string[];
172
175
  /** With --fix: what running `fix` did. */
173
176
  fixed?: {
@@ -175,6 +178,25 @@ type DoctorJson = {
175
178
  message: string;
176
179
  };
177
180
  }[];
181
+ /** `skilllib apply <issue-id> [choice] --json` */
182
+ type ApplyJson = {
183
+ id: string;
184
+ applied: string;
185
+ ok: boolean;
186
+ message: string;
187
+ };
188
+ /** `skilllib global move <name>... --to <repo>... --json`: one entry per skill. */
189
+ type GlobalMoveJson = {
190
+ skill: string;
191
+ /** Added to every repo and no longer global. When false, it still loads globally and `reason` says why. */
192
+ moved: boolean;
193
+ reason?: string;
194
+ projects: {
195
+ project: string;
196
+ path: string;
197
+ change: ChangeJson;
198
+ }[];
199
+ }[];
178
200
  /** `skilllib usage [--days N] --json` */
179
201
  type UsageJson = {
180
202
  usageDays: number;
@@ -197,4 +219,4 @@ type AgentSkillJson = {
197
219
  removed?: string[];
198
220
  };
199
221
 
200
- export type { AgentSkillJson, AgentSkillState, AllChangesJson, ChangeJson, ChangesJson, DoctorJson, GitState, HarnessId, ImportJson, ListJson, OutdatedJson, ProjectSource, ProjectsJson, ShowJson, SkillState, SourceKind, StatusGlobalGroup, StatusIssue, StatusJson, StatusSkillGroup, UsageJson };
222
+ export type { AgentSkillJson, AgentSkillState, AllChangesJson, ApplyJson, ChangeJson, ChangesJson, DoctorJson, GitState, GlobalMoveJson, HarnessId, ImportJson, ListJson, OutdatedJson, ProjectSource, ProjectsJson, ShowJson, SkillState, SourceKind, StatusGlobalGroup, StatusIssue, StatusJson, StatusSkillGroup, UsageJson };
@@ -36,6 +36,7 @@ import {
36
36
  linkGlobal,
37
37
  listBackups,
38
38
  machineSkills,
39
+ moveGlobalSkills,
39
40
  planGlobalTidy,
40
41
  planProjectTidy,
41
42
  pluginBackups,
@@ -72,7 +73,7 @@ import {
72
73
  versionHistory,
73
74
  visibilityOf,
74
75
  visibleProjects
75
- } from "./chunk-Z2F5W3DY.js";
76
+ } from "./chunk-LO4FMXDE.js";
76
77
 
77
78
  // src/tui/index.tsx
78
79
  import { render } from "ink";
@@ -873,11 +874,14 @@ function originGroup(origin) {
873
874
  return null;
874
875
  }
875
876
  var word = (name) => name.split(/[-_.]/)[0];
877
+ function libraryOrigin(w, l) {
878
+ return l && (l.origin ?? w.library.find((x) => x.origin?.match(/^plugin: ([^@]+)@/)?.[1] === l.name)?.origin);
879
+ }
876
880
  function groupCandidates(w, u) {
877
881
  const byTime = (family, installed) => installed ? [{ key: `time:${family}:${installed}`, label: `installed together ${installed.slice(0, 10)}` }] : [];
878
882
  const byWord = (family, name) => word(name).length >= 3 ? [{ key: `word:${family}:${word(name)}`, label: `${word(name)}-*` }] : [];
879
883
  if (!("source" in u)) {
880
- const origin = originGroup(u.origin);
884
+ const origin = originGroup(libraryOrigin(w, u));
881
885
  return [...origin ? [origin] : [], ...byWord("library", u.name)];
882
886
  }
883
887
  const m = u.machine;
@@ -892,8 +896,7 @@ function groupCandidates(w, u) {
892
896
  return [...origin ? [origin] : [], ...byTime("global", m.installed), ...byWord("global", m.name)];
893
897
  }
894
898
  if (u.local?.source === "lib") {
895
- const l = w.library.find((x) => x.name === u.name);
896
- const origin = originGroup(l?.origin);
899
+ const origin = originGroup(libraryOrigin(w, w.library.find((x) => x.name === u.name)));
897
900
  return [...origin ? [origin] : [], ...byWord("local", u.name)];
898
901
  }
899
902
  return byWord("local", u.name);
@@ -920,7 +923,33 @@ function addRows(w, target, q) {
920
923
  const taken = new Set(target ? project(w, target).skills.map((s) => s.name) : []);
921
924
  const loaded = new Set(target ? usable(w, target).map((u) => u.name) : []);
922
925
  const hit = (name, description) => !q || matches(name, q) || q.length >= 3 && description.toLowerCase().includes(q);
923
- const library = target ? w.library.filter((l) => !taken.has(l.name) && hit(l.name, w.descriptions[l.name] ?? "")).map((l) => ({ key: `lib:${l.name}`, name: l.name, note: `v${l.latest}`, description: w.descriptions[l.name] ?? "", run: (w2) => w2.ops.add(target, l.name) })) : [];
926
+ const groups = assignGroups(w.library.map((l) => groupCandidates(w, l)));
927
+ const shown = w.library.map((l, i) => ({ l, g: groups[i] })).filter(({ l }) => !taken.has(l.name) && hit(l.name, w.descriptions[l.name] ?? ""));
928
+ const skillRow = (l, inGroup) => ({
929
+ key: `lib:${l.name}`,
930
+ name: l.name,
931
+ note: `v${l.latest}`,
932
+ description: w.descriptions[l.name] ?? "",
933
+ members: [l.name],
934
+ ...inGroup ? { inGroup } : {},
935
+ run: (w2) => w2.ops.add(target, l.name)
936
+ });
937
+ const library = [];
938
+ const done = /* @__PURE__ */ new Set();
939
+ for (const { l, g } of target ? shown : []) {
940
+ if (done.has(l.name)) continue;
941
+ const members = g ? shown.filter((x) => x.g?.key === g.key).map((x) => x.l) : [l];
942
+ members.forEach((m) => done.add(m.name));
943
+ if (members.length < 2) {
944
+ library.push(skillRow(l, false));
945
+ continue;
946
+ }
947
+ const names = members.map((m) => m.name);
948
+ library.push(
949
+ { key: `group:${g.key}`, name: groupLabel(g, names), note: `${names.length} skills`, description: names.join(", "), members: names, run: (w2) => w2.ops.addTo([target], names) },
950
+ ...members.map((m) => skillRow(m, true))
951
+ );
952
+ }
924
953
  const exists = q !== "" && (loaded.has(q) || w.library.some((l) => l.name === q));
925
954
  const where = target ? ` for ${target}` : "";
926
955
  const fresh = exists ? [] : [
@@ -1682,7 +1711,7 @@ function App({ initial, reload, loadUsage: loadUsage2 }) {
1682
1711
  const repo = open;
1683
1712
  const skills = project(world, repo).skills.map((s) => s.name);
1684
1713
  return [
1685
- row("#add", "+ Add a skill\u2026", ["Search your library, or create a new skill."], () => setModal({ kind: "add", cursor: 0, query: "", target: repo })),
1714
+ row("#add", "+ Add a skill\u2026", ["Search your library, or create a new skill."], () => setModal({ kind: "add", cursor: 0, query: "", target: repo, picked: /* @__PURE__ */ new Set() })),
1686
1715
  ...fix,
1687
1716
  row(
1688
1717
  "#repo",
@@ -1705,7 +1734,7 @@ function App({ initial, reload, loadUsage: loadUsage2 }) {
1705
1734
  }
1706
1735
  if (place === "health") return fix;
1707
1736
  if (place === "library")
1708
- return [row("#add", "+ New skill\u2026", ["Create a skill in your library, or have an agent write it. Skills in your repos can be copied in from their own actions."], () => setModal({ kind: "add", cursor: 0, query: "", target: null }))];
1737
+ return [row("#add", "+ New skill\u2026", ["Create a skill in your library, or have an agent write it. Skills in your repos can be copied in from their own actions."], () => setModal({ kind: "add", cursor: 0, query: "", target: null, picked: /* @__PURE__ */ new Set() }))];
1709
1738
  return [];
1710
1739
  }
1711
1740
  function move(delta) {
@@ -1963,6 +1992,18 @@ function App({ initial, reload, loadUsage: loadUsage2 }) {
1963
1992
  if (key.upArrow) return step(-1);
1964
1993
  if (key.downArrow) return step(1);
1965
1994
  if (key.backspace || key.delete) return setModal({ ...m, query: m.query.slice(0, -1), cursor: 0 });
1995
+ const members = rows[cursor2]?.members;
1996
+ if (input === " " && members) {
1997
+ const picked = new Set(m.picked);
1998
+ const all2 = members.every((n) => picked.has(n));
1999
+ for (const n of members) all2 ? picked.delete(n) : picked.add(n);
2000
+ return setModal({ ...m, picked });
2001
+ }
2002
+ if (key.return && m.target && m.picked.size) {
2003
+ const [target, names] = [m.target, [...m.picked]];
2004
+ setModal(null);
2005
+ return apply((w) => w.ops.addTo([target], names));
2006
+ }
1966
2007
  if (key.return && rows[cursor2]?.agent) {
1967
2008
  setModal(null);
1968
2009
  return ui.copy(writeSkillPrompt(world, m.query, m.target), "a prompt for an agent to write the skill");
@@ -2170,10 +2211,15 @@ function App({ initial, reload, loadUsage: loadUsage2 }) {
2170
2211
  );
2171
2212
  } else if (modal?.kind === "add") {
2172
2213
  const rows = addRows(world, modal.target, modal.query);
2214
+ const picked = modal.picked;
2215
+ const tick = (names) => {
2216
+ const n = names.filter((x) => picked.has(x)).length;
2217
+ return { text: n === names.length ? "[x] " : n ? "[-] " : "[ ] ", width: 4, color: n ? color.green : color.faint };
2218
+ };
2173
2219
  body = /* @__PURE__ */ jsx2(
2174
2220
  ListPanel,
2175
2221
  {
2176
- title: modal.target ? `Add a skill to ${modal.target}` : "New skill",
2222
+ title: modal.target ? picked.size ? `Add ${picked.size} skill${picked.size === 1 ? "" : "s"} to ${modal.target}` : `Add a skill to ${modal.target}` : "New skill",
2177
2223
  focused: true,
2178
2224
  width: columns,
2179
2225
  height: bodyH,
@@ -2182,10 +2228,14 @@ function App({ initial, reload, loadUsage: loadUsage2 }) {
2182
2228
  header: /* @__PURE__ */ jsx2(Text2, { color: modal.query ? color.accent : color.muted, children: modal.query ? `\u2315 ${modal.query}\u258F` : modal.target ? "type to search your library, or to name a new skill" : "type the new skill's name" }),
2183
2229
  headerHeight: 1,
2184
2230
  rows: rows.map(
2185
- (r) => r.header ? { key: r.key, header: true, cells: [{ text: r.header }] } : r.create || r.agent ? { key: r.key, cells: [{ text: r.name, grow: true, color: color.accent }] } : {
2231
+ (r) => r.header ? { key: r.key, header: true, cells: [{ text: r.header }] } : r.create || r.agent ? { key: r.key, cells: [{ text: r.name, grow: true, color: color.accent }] } : r.members.length > 1 ? {
2232
+ key: r.key,
2233
+ cells: [tick(r.members), { text: clip(r.name, 30), width: 30, bold: true }, { text: `${r.note}: ${r.description}`, grow: true, color: color.faint }]
2234
+ } : {
2186
2235
  key: r.key,
2187
2236
  cells: [
2188
- { text: clip(r.name, 22), width: 22 },
2237
+ tick(r.members),
2238
+ { text: clip(`${r.inGroup ? " " : ""}${r.name}`, 24), width: 24 },
2189
2239
  { text: r.note, width: 6, color: color.accent },
2190
2240
  { text: r.description, grow: true, color: color.faint }
2191
2241
  ]
@@ -2200,7 +2250,7 @@ function App({ initial, reload, loadUsage: loadUsage2 }) {
2200
2250
  list,
2201
2251
  split ? side : null
2202
2252
  ] });
2203
- const hints = modal?.kind === "confirm" ? "enter apply \xB7 esc cancel" : modal?.kind === "repos" || modal?.kind === "fixall" ? "space tick \xB7 enter apply \xB7 esc cancel" : modal?.kind === "agents" ? "space tick \xB7 enter save" : modal?.kind === "menu" ? "\u2191\u2193 choose \xB7 enter do it \xB7 esc cancel" : modal?.kind === "add" ? "enter add \xB7 esc cancel" : modal ? "any key closes" : focus === "detail" ? "\u2191\u2193 \u2190\u2192 choose \xB7 enter apply \xB7 esc back" : focus === "sidebar" ? "\u2191\u2193 choose \xB7 enter open \xB7 type to filter repos" : place === "projects" && !open ? `type to filter \xB7 enter open \xB7 ${sidebar ? "\u2190 sidebar" : "\u2190\u2192 places"} \xB7 ? keys` : place === "settings" ? `enter toggle \xB7 ${sidebar ? "\u2190 sidebar" : "\u2190\u2192 places"} \xB7 ? keys` : `type to filter \xB7 space \u2605 fix \xB7 enter actions \xB7 tab tabs${sidebar ? " \xB7 esc back" : `${dashboard ? " \xB7 esc all projects" : ""} \xB7 ? keys`}`;
2253
+ const hints = modal?.kind === "confirm" ? "enter apply \xB7 esc cancel" : modal?.kind === "repos" || modal?.kind === "fixall" ? "space tick \xB7 enter apply \xB7 esc cancel" : modal?.kind === "agents" ? "space tick \xB7 enter save" : modal?.kind === "menu" ? "\u2191\u2193 choose \xB7 enter do it \xB7 esc cancel" : modal?.kind === "add" ? modal.target ? "space tick \xB7 enter add \xB7 esc cancel" : "enter add \xB7 esc cancel" : modal ? "any key closes" : focus === "detail" ? "\u2191\u2193 \u2190\u2192 choose \xB7 enter apply \xB7 esc back" : focus === "sidebar" ? "\u2191\u2193 choose \xB7 enter open \xB7 type to filter repos" : place === "projects" && !open ? `type to filter \xB7 enter open \xB7 ${sidebar ? "\u2190 sidebar" : "\u2190\u2192 places"} \xB7 ? keys` : place === "settings" ? `enter toggle \xB7 ${sidebar ? "\u2190 sidebar" : "\u2190\u2192 places"} \xB7 ? keys` : `type to filter \xB7 space \u2605 fix \xB7 enter actions \xB7 tab tabs${sidebar ? " \xB7 esc back" : `${dashboard ? " \xB7 esc all projects" : ""} \xB7 ? keys`}`;
2204
2254
  return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", width: termCols, height: termRows, children: [
2205
2255
  sidebar ? null : topBar,
2206
2256
  /* @__PURE__ */ jsxs2(Box2, { children: [
@@ -2655,29 +2705,19 @@ function realOps(roots, backups) {
2655
2705
  const moved = [];
2656
2706
  const notes = [];
2657
2707
  const held = [];
2658
- for (const m of skills) {
2659
- if (importSkill(m.path).status === "exists") {
2660
- notes.push(`${m.name}: your library has a different ${m.name}; nothing moved (update your library from this copy first)`);
2661
- continue;
2662
- }
2663
- if (group) recordOrigin(m.name, `group: ${group}`);
2664
- const results = repos.map((r2) => [r2, addSkill(rootOf(r2), m.name)]);
2665
- const skipped = results.filter(([, c]) => c.action === "skipped");
2666
- if (skipped.length) {
2667
- notes.push(`${m.name}: not added to ${skipped.map(([r2, c]) => `${r2} (${c.reason})`).join(", ")}, so it still loads globally`);
2668
- continue;
2669
- }
2670
- const r = unloadGlobal(m.path, m.links);
2671
- if (!r.ok) {
2672
- notes.push(`${m.name}: ${r.reason}`);
2708
+ const repoRoots = repos.map(rootOf);
2709
+ for (const mv of moveGlobalSkills(skills, repoRoots, { group, label: (root) => repos[repoRoots.indexOf(root)] })) {
2710
+ if (!mv.moved) {
2711
+ notes.push(`${mv.name}: ${mv.reason}`);
2673
2712
  continue;
2674
2713
  }
2675
- moved.push(m.name);
2676
- for (const [repo, c] of results) {
2677
- const blind = visibilityOf(rootOf(repo), m.name).flatMap((v) => v.paths === 0 ? [v.id] : []);
2714
+ moved.push(mv.name);
2715
+ for (const [i, [root, c]] of mv.changes.entries()) {
2716
+ const repo = repos[i];
2717
+ const blind = visibilityOf(root, mv.name).flatMap((v) => v.paths === 0 ? [v.id] : []);
2678
2718
  const blocked = [...c.blocked ?? [], ...c.uncommitted ?? []];
2679
2719
  if (!blocked.length || !blind.length) continue;
2680
- held.push({ repo, name: m.name, blocked, blind, copy: copyDir(repo, m.name), shared: !c.uncommitted?.length });
2720
+ held.push({ repo, name: mv.name, blocked, blind, copy: copyDir(repo, mv.name), shared: !c.uncommitted?.length });
2681
2721
  }
2682
2722
  }
2683
2723
  const gaps = /* @__PURE__ */ new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skilllib",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Manage your agent skills like dependencies. One library for all your skills; each repo gets only the ones it needs, and every agent can see them. Works with Claude Code, Cursor, Codex, Zed and Grok.",
5
5
  "keywords": [
6
6
  "agent-skills",
@@ -14,25 +14,32 @@ Always pass `--json` (one line of JSON on stdout; messages go to stderr). Never
14
14
 
15
15
  The **Now** section at the end has this repo's `status` and `list` output. Answer from it and don't run them again. If it shows the commands instead of their output, run both in parallel.
16
16
 
17
- `status --json`: `skills` are this repo's own; `nested` (when there are any) are in subfolders such as monorepo packages, and load only when the agent works in their `dir`; `global` groups load in every repo, as this repo loads them (its `.claude/settings.json` can turn a plugin on or off); `issues` are everything skilllib finds wrong (out-of-date or missing skills, broken links, skills loaded twice), with either a `fix` (a safe repair; `skilllib doctor --fix` applies every fix, across all the user's projects) or `choices` (decisions the user makes in `skilllib` → Health). When asked what's out of date or broken, `issues` and each skill's `state` are the answer. Don't audit skill contents unless asked.
17
+ `status --json`: `skills` are this repo's own; `nested` (when there are any) are in subfolders such as monorepo packages, and load only when the agent works in their `dir`; `global` groups load in every repo, as this repo loads them (its `.claude/settings.json` can turn a plugin on or off); `issues` are everything skilllib finds wrong (out-of-date or missing skills, broken links, skills loaded twice), one `ids` entry per `problems` entry, with either a `fix` (a safe repair; `skilllib doctor --fix` applies every fix, across all the user's projects) or `choices` (decisions: propose one, and apply it once the user agrees, see Changing). When asked what's out of date or broken, `issues` and each skill's `state` are the answer. Don't audit skill contents unless asked.
18
18
 
19
19
  - `source`: `library` (added with skilllib) · `repo` (the team's: committed to git, or in `.agents/skills`) · `local` (only on this machine: not committed, not managed by skilllib) · `global` (`~/.claude/skills`, `~/.agents/skills` or `~/.codex/skills`) · `skills.sh` (`npx skills add`; `from` is the source repo) · `plugin` (Claude Code plugin; turn off with `/plugin`) · `claude.ai` (synced from the account) · `built-in` (bundled with an agent) · `skilllib` (this skill; `skilllib agent-skill remove` takes it out).
20
20
  - Missing fields mean the usual: loaded by all of `agents`, state ok, in `.claude/skills`. `usesHere` counts Claude Code uses in this repo.
21
21
 
22
- `list --json`: `inThisProject` (names) and `skills`, the library skills the repo doesn't have, each with a short `description` and the `projects` using it. To recommend, read the repo's README and manifests (see `files` in `status`) in one parallel step, then give a reason for each pick. Don't add a skill that already loads globally (it would load twice): name it, and say `skilllib` → Global → Clean up moves it into just the repos that need it. `show <name> --json` has the full SKILL.md.
22
+ `list --json`: `inThisProject` (names) and `skills`, the library skills the repo doesn't have, each with a short `description` and the `projects` using it. To recommend, read the repo's README and manifests (see `files` in `status`) in one parallel step, then give a reason for each pick. Don't add a skill that already loads globally (it would load twice): offer to move it into just the repos that need it instead (`global move`). `show <name> --json` has the full SKILL.md.
23
23
 
24
- Also: `usage --json` (Claude Code usage only, so "unused" is a hint), `projects --json` (every repo), `outdated --all --json`.
24
+ Also: `usage --json` (Claude Code usage only, so "unused" is a hint; each skill lists the `projects` that used it), `projects --json` (every repo), `outdated --all --json`, `doctor --json` (every issue in every repo and globally, each with an `id`).
25
25
 
26
26
  ## Changing (ask first unless the user asked for this exact change)
27
27
 
28
- `add <name>...` · `remove <name>...` · `update [name...]` · `sync` (pinned versions) · `import <dir>` (into the library) · `doctor --fix`. Pass several names in one call. Each prints `[{name, action, from, to, dirs, reason, blocked, uncommitted, backedUp}]`, which is the whole result: there's no need to check files afterwards.
28
+ `add <name>...` · `remove <name>...` · `update [name...]` · `sync` (pinned versions) · `import <dir>` (into the library) · `doctor --fix`. Pass several names in one call. Their result is `[{name, action, from, to, dirs, reason, blocked, uncommitted, backedUp}]`, which is the whole result: there's no need to check files afterwards.
29
29
 
30
30
  - `skipped` + `reason`: nothing changed for that skill, and the command exits 1; tell the user. `--force` overwrites local edits (`backedUp` says where they went; `skilllib restore <name>` brings them back), but only use it with their OK.
31
31
  - `blocked`: git tracks that folder. Re-run with `--allow-tracked` only with their OK.
32
32
  - `uncommitted`: git tracks that folder but not the skill's real copy, so a link there would break for teammates; `--allow-tracked` doesn't change that. Once the copy is committed, `link --allow-tracked` adds it.
33
33
  - Changes apply from the agent's next session. Remind the user to commit `skilllib.json` and `.claude/skills`.
34
34
 
35
- To move global skills into repos, tell the user to run `skilllib` and open Global → Clean up.
35
+ **Prefer per-repo skills to global ones.** A global skill loads in every repo, for every agent. When `usage --json` shows one used in only a few repos, propose moving it there:
36
+
37
+ - `global move <name>... --to <repo>[,<repo>...]` (a project name from `projects`, or the path of a repo's top folder): imports it into the library, adds it to each repo, and only then stops loading it globally (restorable with `skilllib restore`). Prints `[{skill, moved, reason, projects: [{project, path, change}]}]`; `moved: false` means it still loads globally, and `reason` says why.
38
+ - `global keep <name>...`: the user wants it everywhere; skilllib stops flagging it.
39
+
40
+ **Fixes and decisions.** `apply <id> [choice]` runs one problem's `fix`, or the choice you name: its label (safest), or its number from 1. With several choices it needs one. Prints `{id, applied, ok, message}`. Ids come from `issues` in `status --json` (Now, below) or from `doctor --json` (every repo; pass `apply` the same `--days` you gave `doctor`). A few issues span repos and offer different choices in each; then `apply` takes only the label.
41
+
42
+ **Importing.** `import <dir>...` copies skill folders (each with a `SKILL.md`) into the library; skilllib doesn't download anything. For a skill on GitHub, clone the repo into a temporary folder, `import` the skill's folder, then `add` it here.
36
43
 
37
44
  ## Now
38
45