skilllib 1.13.0 → 1.14.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
@@ -193,10 +193,10 @@ These are the issues `skilllib doctor` reports, with the same fixes and choices.
193
193
 
194
194
  A skill's pin goes in one of two places:
195
195
 
196
- - **`skilllib.json` in the repo (shared).** Commit it with the skill folders, and teammates get the same skills. This is where pins go when the repo already has a `skilllib.json`.
197
- - **Your personal pins (`~/.skilllib/pins/`).** One file per repo, matched by its `origin` remote, so every clone and worktree of the repo shares it. skilllib keeps the copies and links out of git through the repo's `.git/info/exclude` (only your clone; the repo's `.gitignore` doesn't change). Nothing shows up in `git status`. This is where pins go when the repo has no `skilllib.json`.
196
+ - **Your personal pins (`~/.skilllib/pins/`).** The default. One file per repo, matched by its `origin` remote, so every clone and worktree of the repo shares it. skilllib keeps the copies and links out of git through the repo's `.git/info/exclude` (only your clone; the repo's `.gitignore` doesn't change). Nothing shows up in `git status`. A repo that already has `skilllib.json` still gets new skills here.
197
+ - **`skilllib.json` in the repo (shared).** Only when you share a skill with the team: `skilllib add <name> --shared`, or *Pin it for the team* in the app. Commit it with the skill folders, and teammates get that skill.
198
198
 
199
- `skilllib add <name> --personal` or `--shared` picks the place, and moves an existing pin without changing its version. In the app, a library skill's actions offer *Make it personal* or *Pin it for the team*, and its tag says `personal v2`. A copy git already has can't become personal. A skill pinned in both places uses the repo's pin; doctor offers to drop yours (a choice: yours would keep the skill if the team unpinned it).
199
+ `--personal` moves a shared pin back to yours, without changing its version. A copy git already has can't become personal. A skill pinned in both places uses the repo's pin; doctor offers to drop yours (a choice: yours would keep the skill if the team unpinned it). The app tags a personal skill `personal v2`.
200
200
 
201
201
  **On another machine:** keep `~/.skilllib/pins/` and `~/.skilllib/library/` in git (a dotfiles repo, say). Clone a repo there and run `skilllib sync`: your personal skills come back, out of git again. A repo without a remote keeps its pins by folder, so they stay on that machine until it gets one: adding a remote (or renaming it) keeps them. Only a repo's top folder can have personal pins, not a monorepo package's.
202
202
 
@@ -249,7 +249,7 @@ Every plugin issue is *your call*: turning plugins on or off, or updating them,
249
249
 
250
250
  The details list its skills with their uses, what else it brings, where it's installed and its version. Its actions:
251
251
 
252
- - **Turn it on / off**: `claude plugin enable|disable --scope <scope>`, run in its repo for a project or local install. A plugin synced from claude.ai is switched by its `"<name>@synced"` line in `~/.claude/settings.json`; so is any plugin when the `claude` command isn't available.
252
+ - **Turn it on / off**: `skilllib plugin off <id>` and `skilllib plugin on <id>` (`name@marketplace`). That switches the copy that loads in every repo and leaves a per-repo install on. The same switch is `claude plugin enable|disable --scope <scope>`, run in its repo for a project or local install. A plugin synced from claude.ai is switched by its `"<name>@synced"` line in `~/.claude/settings.json`; so is any plugin when the `claude` command isn't available.
253
253
  - **Update to vN**, when its marketplace (as Claude Code last fetched it) declares a newer version or commit: `claude plugin update`. If the marketplace asks to run a command, skilllib doesn't confirm it for you: it tells you to run the update yourself.
254
254
  - **Uninstall**: `claude plugin uninstall --keep-data`, where it's installed. Settings › Backups reinstalls it, in the same repo for a project install.
255
255
  - **Replace *plugin* with library skills…** copies all its skills into your library (grouped under the plugin), adds them to the repos you tick, and uninstalls the plugin. A plugin can't be half removed, so it's all its skills at once; the confirmation says what else it brings that goes too. If the `claude` command isn't available, skilllib turns the plugin off instead and tells you to finish with `/plugin uninstall`. Plugins synced from claude.ai can't be removed from here: skilllib copies their skills, turns the plugin off, and tells you to remove it on claude.ai.
@@ -409,6 +409,8 @@ skilllib new <name> [desc] create a skill
409
409
 
410
410
  skilllib projects your projects and their skills
411
411
  skilllib usage [--days N] which skills Claude Code used (a plugin's copy counts for the skill's name)
412
+ skilllib check [--days N] what loaded skills cost on every prompt, and which had no use
413
+ skilllib plugin off|on <id> turn a Claude Code plugin off or on (it stays installed)
412
414
  skilllib doctor [--fix] find (and fix) problems
413
415
  skilllib apply <id> [choice] apply one problem's fix, or the choice you pick (ids from status or doctor --json)
414
416
  skilllib restore [name] put back something skilllib moved out (copies moved together come back together)
@@ -424,7 +426,7 @@ Flags: `--personal` / `--shared` (for `add`: pin in your personal pins or in the
424
426
 
425
427
  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, a link something else is in the way of (a file or another copy), an unknown `tidy` name or `--keep` folder, or (with `--all`, and `projects`) a repo left out because its `skilllib.json` can't be read. What it did do is still done, and listed. An unknown or misspelled flag is an error too, so a typo never runs silently. With `--json`, an error that stops a command prints `{"error": "…"}` on stdout as well as the message on stderr. The commands SKILL.md lists as messages-only print nothing on stdout otherwise: read their exit code.
426
428
 
427
- **`--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:
429
+ **`--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`, `agent-skill`, `check` and `plugin`. The output is shaped to keep agents fast and cheap:
428
430
 
429
431
  - **`status --json`** answers everything about the repo in one call:
430
432
  - the repo's skills, with their source, version, state and git state
@@ -455,11 +457,13 @@ A field is only removed or changed in a major version.
455
457
 
456
458
  | Path | What |
457
459
  |---|---|
458
- | `<repo>/skilllib.json` | The repo's shared skills and pinned versions. **Commit it.** |
460
+ | `<repo>/skilllib.json` | Skills you shared with the team (`--shared`). **Commit it** with the skill folders. Absent until then |
459
461
  | `~/.skilllib/pins/` | Your personal pins, one file per repo (by its remote). Keep it in git with your library to use them on other machines. |
460
462
  | `~/.skilllib/library/` | Your skills (master copies). Worth putting under git. |
461
463
  | `~/.skilllib/store/` | Every version of every skill, immutable |
462
464
  | `~/.skilllib/config.json` | Your agents, project folders, hidden projects, skills you keep global |
465
+ | `~/.skilllib/projects.json` | Repos skilllib has seen. Rewritten only when that list changes |
466
+ | `~/.skilllib/usage-cache.json` | Parsed Claude Code transcripts, so the next `status` doesn't read them again. Safe to delete |
463
467
  | `~/.skilllib/trash/`, `global-backup/`, `tidy-backup/`, `edit-backup/` | Everything skilllib removed, restorable from Settings › Backups |
464
468
  | `~/.skilllib/plugin-backup.json` | Plugins skilllib uninstalled (with their scope and repo), so Settings › Backups can reinstall them |
465
469
  | `~/.skilllib/review-prompt.md` | The last review prompt you copied |
@@ -469,11 +473,11 @@ Environment variables: `SKILLLIB_HOME` moves `~/.skilllib`, `$VISUAL` / `$EDITOR
469
473
 
470
474
  ## FAQ
471
475
 
472
- **Does it change my repos?** Only when you tell it to: adding, removing, linking or tidying a skill, which updates `skilllib.json` (or, for personal pins, your `~/.skilllib/pins/` and the repo's `.git/info/exclude`). It asks before linking into a folder git tracks, and before tidy changes a committed file. It never deletes the only copy of a repo's own skill.
476
+ **Does it change my repos?** Only when you tell it to: adding, removing, linking or tidying a skill. That updates your `~/.skilllib/pins/` and the repo's `.git/info/exclude`, or `skilllib.json` when you share a skill with the team. It asks before linking a shared skill into a folder git tracks, and before tidy changes a committed file. It never deletes the only copy of a repo's own skill.
473
477
 
474
478
  **Can I share skills with my team?** Commit `skilllib.json` along with the skill folders (`.agents/skills`, and `.claude/skills` if you use Claude Code). Or keep `~/.skilllib/library` in a git repo of its own (the rest of `~/.skilllib` is personal: config, backups, the version store), and run `skilllib import` on teammates' machines from a clone of it.
475
479
 
476
- **Do I have to commit `skilllib.json`?** No. In a repo without one, skilllib pins in your personal pins and keeps its copies out of git, so the repo doesn't change for anyone else. See [Personal or shared pins](#personal-or-shared-pins).
480
+ **Do I have to commit `skilllib.json`?** Only for a skill you shared with the team (`skilllib add <name> --shared`). Everything else stays in your personal pins, out of git. See [Personal or shared pins](#personal-or-shared-pins).
477
481
 
478
482
  **Why does usage only count Claude Code?** It's the only agent whose transcripts skilllib reads. Grok keeps sessions under `~/.grok/sessions`, and Cursor doesn't keep them locally. Codex's format hasn't been tested yet. That's why "unused" is a strong hint, not proof.
479
483
 
@@ -434,15 +434,25 @@ function indexFile(name) {
434
434
  return join4(storeDir(name), "versions.json");
435
435
  }
436
436
  function versionHistory(name) {
437
- return readState(indexFile(name)) ?? [];
437
+ const hit = historyCache.get(name);
438
+ if (hit) return hit;
439
+ const history = readState(indexFile(name)) ?? [];
440
+ historyCache.set(name, history);
441
+ return history;
438
442
  }
439
443
  function versionDir(name, version) {
440
444
  return join4(storeDir(name), String(version));
441
445
  }
442
446
  var latestCache = /* @__PURE__ */ new Map();
447
+ var historyCache = /* @__PURE__ */ new Map();
443
448
  function forgetLatest(name) {
444
- if (name) latestCache.delete(name);
445
- else latestCache.clear();
449
+ if (name) {
450
+ latestCache.delete(name);
451
+ historyCache.delete(name);
452
+ } else {
453
+ latestCache.clear();
454
+ historyCache.clear();
455
+ }
446
456
  }
447
457
  function latestVersion(name) {
448
458
  if (latestCache.has(name)) return latestCache.get(name) ?? null;
@@ -459,7 +469,9 @@ function latestVersion(name) {
459
469
  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`);
460
470
  mkdirSync2(storeDir(name), { recursive: true });
461
471
  copySkill(source, versionDir(name, next.version));
462
- writeState(indexFile(name), [...history, next]);
472
+ const recorded = [...history, next];
473
+ writeState(indexFile(name), recorded);
474
+ historyCache.set(name, recorded);
463
475
  result = next;
464
476
  }
465
477
  }
@@ -585,26 +597,63 @@ function normalizeRemote(url) {
585
597
  }
586
598
  var repos = /* @__PURE__ */ new Map();
587
599
  var changedAt = (path) => statSync3(path, { throwIfNoEntry: false })?.mtimeMs ?? -1;
600
+ function gitCommon(root) {
601
+ const gitPath = join5(root, ".git");
602
+ const st = statSync3(gitPath, { throwIfNoEntry: false });
603
+ if (!st) return null;
604
+ if (st.isDirectory()) return existsSync4(join5(gitPath, "HEAD")) ? gitPath : null;
605
+ if (!st.isFile()) return null;
606
+ const gitdir = readFileSync3(gitPath, "utf-8").match(/^gitdir:\s*(.+?)\s*$/m)?.[1];
607
+ if (!gitdir) return null;
608
+ const dir = resolve3(root, gitdir);
609
+ if (!existsSync4(dir)) return null;
610
+ let common = dir;
611
+ try {
612
+ const resolved = resolve3(dir, readFileSync3(join5(dir, "commondir"), "utf-8").trim());
613
+ if (existsSync4(resolved)) common = resolved;
614
+ } catch {
615
+ }
616
+ return existsSync4(join5(common, "HEAD")) ? common : null;
617
+ }
618
+ function remoteUrls(configPath) {
619
+ let text;
620
+ try {
621
+ text = readFileSync3(configPath, "utf-8");
622
+ } catch {
623
+ return [];
624
+ }
625
+ if (/^\s*\[include/m.test(text)) return null;
626
+ const urls = [];
627
+ let section = "";
628
+ for (const line of text.split(/\r?\n/)) {
629
+ const header = line.match(/^\s*\[([^\]]+)\]\s*$/);
630
+ if (header) {
631
+ const remote = header[1].match(/^remote\s+"(.+)"$/) ?? header[1].match(/^remote\s+(\S+)$/);
632
+ section = remote?.[1] ?? "";
633
+ continue;
634
+ }
635
+ const url = section ? line.match(/^\s*url\s*=\s*(.*?)\s*$/) : null;
636
+ if (url) urls.push([`remote.${section}.url`, url[1]]);
637
+ }
638
+ return urls;
639
+ }
588
640
  function repoOf(root) {
589
641
  const real = realPath(root);
590
642
  const hit = repos.get(real);
591
643
  if (hit && (hit.config ? changedAt(hit.config) === hit.seen : !existsSync4(join5(root, ".git")))) return hit.repo;
592
- const out2 = gitIn(root, ["rev-parse", "--show-prefix", "--git-common-dir"])?.split("\n");
593
- if (!out2) {
644
+ const commonPath = gitCommon(root);
645
+ if (!commonPath) {
594
646
  repos.set(real, { repo: null, config: null, seen: 0 });
595
647
  return null;
596
648
  }
597
- const common = realNative(resolve3(root, out2[1].trim())) ?? resolve3(root, out2[1].trim());
649
+ const common = realNative(commonPath) ?? commonPath;
598
650
  const folder = basename2(common) === ".git" ? dirname2(common) : common;
599
651
  const config = join5(common, "config");
600
652
  const seen = changedAt(config);
601
- let repo = null;
602
- if (out2[0] === "") {
603
- const urls = (gitIn(root, ["config", "--get-regexp", "^remote\\..*\\.url$"]) ?? "").split("\n").filter(Boolean).map((l) => [l.slice(0, l.indexOf(" ")), l.slice(l.indexOf(" ") + 1)]);
604
- const url = (urls.find(([k]) => k === "remote.origin.url") ?? urls[0])?.[1];
605
- const remote = url ? normalizeRemote(url) : void 0;
606
- repo = { file: pinsFileFor(root, remote, folder), folder, common, ...remote ? { remote } : {}, root };
607
- }
653
+ const urls = remoteUrls(config) ?? (gitIn(root, ["config", "--get-regexp", "^remote\\..*\\.url$"]) ?? "").split("\n").filter(Boolean).map((l) => [l.slice(0, l.indexOf(" ")), l.slice(l.indexOf(" ") + 1)]);
654
+ const url = (urls.find(([k]) => k === "remote.origin.url") ?? urls[0])?.[1];
655
+ const remote = url ? normalizeRemote(url) : void 0;
656
+ const repo = { file: pinsFileFor(root, remote, folder), folder, common, ...remote ? { remote } : {}, root };
608
657
  repos.set(real, { repo, config, seen });
609
658
  return repo;
610
659
  }
@@ -696,7 +745,10 @@ function isProjectCandidate(root) {
696
745
  function rememberProjects(roots) {
697
746
  const incoming = roots.map((r) => resolve3(r)).filter((r) => isProjectCandidate(r) && !isAgentWorktree(r));
698
747
  const all = onePerFolder([...knownProjects(), ...incoming]).sort();
699
- writeState(projectsFile(), all);
748
+ const path = projectsFile();
749
+ const prev = readState(path);
750
+ if (Array.isArray(prev) && prev.length === all.length && prev.every((p, i) => p === all[i])) return;
751
+ writeState(path, all);
700
752
  }
701
753
  function readableRoots(roots) {
702
754
  const unreadable = [];
@@ -1249,7 +1301,9 @@ function trackBlocker(root, name) {
1249
1301
  return `${MANIFEST_FILE} can't be read`;
1250
1302
  }
1251
1303
  const stuck = firstStuck(root, dependencyDirs(root, name, recorded));
1252
- return stuck ? blockerSaid(root, stuck) : null;
1304
+ if (stuck) return blockerSaid(root, stuck);
1305
+ if (!recorded && !personalPinsFile(root) && gitRepoAbove(root)) return "only the top folder of a git repo can have personal pins";
1306
+ return null;
1253
1307
  }
1254
1308
  function pinsSkill(root, name) {
1255
1309
  try {
@@ -1598,10 +1652,20 @@ function addSkill(root, name, { force = false, version, allowTracked = false, pe
1598
1652
  ...moved ? { moved } : {}
1599
1653
  };
1600
1654
  }
1655
+ function gitRepoAbove(dir) {
1656
+ let at = resolve6(dir);
1657
+ while (true) {
1658
+ if (existsSync7(join8(at, ".git", "HEAD"))) return true;
1659
+ const parent = dirname3(at);
1660
+ if (parent === at) return false;
1661
+ at = parent;
1662
+ }
1663
+ }
1601
1664
  function pinsWhere(root, recorded, personal) {
1602
- const want = personal ?? (recorded ? !!recorded.personal : !existsSync7(join8(root, MANIFEST_FILE)));
1603
- if (!want || personalPinsFile(root)) return want;
1604
- return personal ? "only the top folder of a git repo can have personal pins" : false;
1665
+ const want = personal ?? (recorded ? !!recorded.personal : !!personalPinsFile(root));
1666
+ if (want && !personalPinsFile(root)) return "only the top folder of a git repo can have personal pins";
1667
+ if (personal === void 0 && !recorded && !personalPinsFile(root) && gitRepoAbove(root)) return "only the top folder of a git repo can have personal pins";
1668
+ return want;
1605
1669
  }
1606
1670
  var personalOf = (manifest) => Object.fromEntries(Object.entries(manifest.skills).filter(([, d]) => d.personal));
1607
1671
  function removeSkill(root, name, { force = false } = {}) {
@@ -1924,9 +1988,9 @@ function refreshAgentSkill() {
1924
1988
  }
1925
1989
 
1926
1990
  // src/commands.ts
1927
- import { existsSync as existsSync15, readFileSync as readFileSync7, rmSync as rmSync7 } from "fs";
1991
+ import { existsSync as existsSync15, readFileSync as readFileSync7, rmSync as rmSync8 } from "fs";
1928
1992
  import { parseArgs as parseFlags } from "util";
1929
- import { basename as basename11, join as join16, resolve as resolve9, sep as sep10 } from "path";
1993
+ import { basename as basename11, join as join17, resolve as resolve9, sep as sep10 } from "path";
1930
1994
 
1931
1995
  // src/git.ts
1932
1996
  import { execFileSync as execFileSync3 } from "child_process";
@@ -1945,11 +2009,13 @@ function git(root, args, input) {
1945
2009
  }
1946
2010
  function gitInfo(root, extraDirs = []) {
1947
2011
  let inRepo;
1948
- try {
1949
- inRepo = execFileSync3("git", ["rev-parse", "--show-prefix"], { cwd: root, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
1950
- } catch {
1951
- if (!existsSync9(join10(root, ".git"))) return null;
1952
- inRepo = "";
2012
+ if (existsSync9(join10(root, ".git"))) inRepo = "";
2013
+ else {
2014
+ try {
2015
+ inRepo = execFileSync3("git", ["rev-parse", "--show-prefix"], { cwd: root, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
2016
+ } catch {
2017
+ return null;
2018
+ }
1953
2019
  }
1954
2020
  const real = (p) => {
1955
2021
  try {
@@ -2535,7 +2601,7 @@ function intoLibrary(path, force = false) {
2535
2601
  return r.name;
2536
2602
  }
2537
2603
  var pinsOf = (where, personal) => personal ? "your personal pins" : `${where}'s skilllib.json`;
2538
- var newPinsOf = (root, where) => pinsOf(where, !existsSync12(join13(root, MANIFEST_FILE)) && personalPinsFile(root) !== null);
2604
+ var newPinsOf = (root, where) => pinsOf(where, personalPinsFile(root) !== null);
2539
2605
  function track(root, name) {
2540
2606
  const c = addSkill(root, name);
2541
2607
  if (c.action === "skipped") throw new Error(`${name}: ${c.reason}`);
@@ -3278,13 +3344,13 @@ function repoIssues(root, ctx) {
3278
3344
  id: `local:${root}:${s.name}`,
3279
3345
  severity: "suggestion",
3280
3346
  title: `${s.name} in ${where} isn't in your library`,
3281
- 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, or your personal pins when it has none).",
3347
+ detail: `Import it into the library to reuse it in other projects and keep it backed up. The repo then tracks it in ${newPinsOf(root, where)}.`,
3282
3348
  at: rows(root, [s.name]),
3283
3349
  choices: [
3284
3350
  {
3285
3351
  label: "Import into the library",
3286
3352
  // Tracking it writes skilllib.json.
3287
- hint: `copies it into Your skills${gitNote(root, [join13(root, MANIFEST_FILE)])}`,
3353
+ hint: `copies it into Your skills${personalPinsFile(root) ? "" : gitNote(root, [join13(root, MANIFEST_FILE)])}`,
3288
3354
  run: () => {
3289
3355
  intoLibrary(s.path);
3290
3356
  track(root, s.name);
@@ -3341,7 +3407,7 @@ function repoIssues(root, ctx) {
3341
3407
  id: `npx:${root}:${s.name}`,
3342
3408
  severity: "suggestion",
3343
3409
  title: `${s.name} in ${where} came from npx skills, not your library`,
3344
- 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, or your personal pins when it has none)`}.`,
3410
+ 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 ${newPinsOf(root, where)}`}.`,
3345
3411
  at: rows(root, [s.name]),
3346
3412
  choices: [
3347
3413
  {
@@ -3612,8 +3678,8 @@ function libraryFor(currentProject) {
3612
3678
  }
3613
3679
 
3614
3680
  // src/usage.ts
3615
- import { createReadStream, existsSync as existsSync13, readdirSync as readdirSync10, statSync as statSync6 } from "fs";
3616
- import { join as join14 } from "path";
3681
+ import { createReadStream, existsSync as existsSync13, mkdirSync as mkdirSync9, readdirSync as readdirSync10, renameSync as renameSync4, rmSync as rmSync6, statSync as statSync6, writeFileSync as writeFileSync6 } from "fs";
3682
+ import { dirname as dirname7, join as join14 } from "path";
3617
3683
  import { createInterface } from "readline";
3618
3684
  var BUILTIN_COMMANDS = /* @__PURE__ */ new Set([
3619
3685
  "add-dir",
@@ -3715,21 +3781,58 @@ function transcriptFiles(sinceMs) {
3715
3781
  });
3716
3782
  });
3717
3783
  }
3784
+ function usageCacheFile() {
3785
+ return join14(skilllibHome(), "usage-cache.json");
3786
+ }
3787
+ function readUsageCache() {
3788
+ const data = readJson(usageCacheFile());
3789
+ return data?.files && typeof data.files === "object" ? data : { files: {} };
3790
+ }
3791
+ function writeUsageCache(cache) {
3792
+ const path = usageCacheFile();
3793
+ const temp = `${path}.${process.pid}.tmp`;
3794
+ try {
3795
+ mkdirSync9(dirname7(path), { recursive: true });
3796
+ writeFileSync6(temp, JSON.stringify(cache));
3797
+ renameSync4(temp, path);
3798
+ } catch {
3799
+ rmSync6(temp, { force: true });
3800
+ }
3801
+ }
3802
+ async function usesIn(file) {
3803
+ const uses = [];
3804
+ const lines = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
3805
+ for await (const line of lines) uses.push(...usesFromTranscriptLine(line));
3806
+ return uses;
3807
+ }
3718
3808
  async function scanUsage(days) {
3719
3809
  const sinceMs = Date.now() - days * 24 * 60 * 60 * 1e3;
3810
+ const files = transcriptFiles(sinceMs);
3811
+ const cache = readUsageCache();
3812
+ let dirty = false;
3813
+ for (const file of Object.keys(cache.files)) {
3814
+ if (!files.includes(file) && !existsSync13(file)) {
3815
+ delete cache.files[file];
3816
+ dirty = true;
3817
+ }
3818
+ }
3720
3819
  const seen = /* @__PURE__ */ new Map();
3721
- for (const file of transcriptFiles(sinceMs)) {
3820
+ for (const file of files) {
3722
3821
  try {
3723
- const lines = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
3724
- for await (const line of lines) {
3725
- for (const use of usesFromTranscriptLine(line)) {
3726
- if (Date.parse(use.at) >= sinceMs) seen.set(use.key, use);
3727
- }
3822
+ const st = statSync6(file);
3823
+ const mtimeMs = Math.round(st.mtimeMs);
3824
+ const hit = cache.files[file];
3825
+ const uses = hit && hit.size === st.size && hit.mtimeMs === mtimeMs && Array.isArray(hit.uses) ? hit.uses : await usesIn(file);
3826
+ if (uses !== hit?.uses) {
3827
+ cache.files[file] = { size: st.size, mtimeMs, uses };
3828
+ dirty = true;
3728
3829
  }
3830
+ for (const use of uses) if (Date.parse(use.at) >= sinceMs) seen.set(use.key, use);
3729
3831
  } catch {
3730
3832
  continue;
3731
3833
  }
3732
3834
  }
3835
+ if (dirty) writeUsageCache(cache);
3733
3836
  return [...seen.values()];
3734
3837
  }
3735
3838
  var skillOf = (key) => key.split(":").pop();
@@ -3761,7 +3864,7 @@ function usesByProject(uses, projectOf) {
3761
3864
 
3762
3865
  // src/install.ts
3763
3866
  import { execFileSync as execFileSync5 } from "child_process";
3764
- import { createWriteStream, existsSync as existsSync14, lstatSync as lstatSync5, mkdirSync as mkdirSync9, mkdtempSync, readdirSync as readdirSync11, rmSync as rmSync6, statSync as statSync7 } from "fs";
3867
+ import { createWriteStream, existsSync as existsSync14, lstatSync as lstatSync5, mkdirSync as mkdirSync10, mkdtempSync, readdirSync as readdirSync11, rmSync as rmSync7, statSync as statSync7 } from "fs";
3765
3868
  import { tmpdir as tmpdir2 } from "os";
3766
3869
  import { basename as basename9, join as join15, resolve as resolve8, sep as sep9 } from "path";
3767
3870
  import { Readable } from "stream";
@@ -3851,16 +3954,16 @@ async function downloadGitHub(owner, repo) {
3851
3954
  try {
3852
3955
  res = await fetch(url, { redirect: "follow" });
3853
3956
  } catch (err) {
3854
- rmSync6(dest, { recursive: true, force: true });
3957
+ rmSync7(dest, { recursive: true, force: true });
3855
3958
  throw new Error(`couldn't download ${owner}/${repo} (${err instanceof Error ? err.message : err})`);
3856
3959
  }
3857
3960
  if (!res.ok || !res.body) {
3858
- rmSync6(dest, { recursive: true, force: true });
3961
+ rmSync7(dest, { recursive: true, force: true });
3859
3962
  throw new Error(res.status === 404 ? `${owner}/${repo} not found on GitHub` : `couldn't download ${owner}/${repo} (${res.status})`);
3860
3963
  }
3861
3964
  const host = new URL(res.url).hostname;
3862
3965
  if (host !== "codeload.github.com" && host !== "github.com") {
3863
- rmSync6(dest, { recursive: true, force: true });
3966
+ rmSync7(dest, { recursive: true, force: true });
3864
3967
  throw new Error(`refusing to download ${owner}/${repo} from ${host}`);
3865
3968
  }
3866
3969
  let bytes = 0;
@@ -3882,11 +3985,11 @@ async function downloadGitHub(owner, repo) {
3882
3985
  );
3883
3986
  execFileSync5("tar", ["-xzf", archive, "-C", dest], { stdio: "ignore" });
3884
3987
  } catch (err) {
3885
- rmSync6(dest, { recursive: true, force: true });
3988
+ rmSync7(dest, { recursive: true, force: true });
3886
3989
  if (err instanceof Error && /larger than|couldn't download/.test(err.message)) throw err;
3887
3990
  throw new Error(`couldn't extract ${owner}/${repo}`);
3888
3991
  }
3889
- rmSync6(archive, { force: true });
3992
+ rmSync7(archive, { force: true });
3890
3993
  const top = readdirSync11(dest);
3891
3994
  const root = top.length === 1 && statSync7(join15(dest, top[0])).isDirectory() ? join15(dest, top[0]) : dest;
3892
3995
  return { tmp: dest, root };
@@ -3904,19 +4007,19 @@ function installGlobal(name, { force = false } = {}) {
3904
4007
  return { ok: false, reason: `${name} is already global, with edits that aren't in your library (pass --force to replace them)` };
3905
4008
  }
3906
4009
  if (there !== latest.hash) {
3907
- if (isLink(dest)) rmSync6(dest, { force: true });
3908
- mkdirSync9(primary, { recursive: true });
4010
+ if (isLink(dest)) rmSync7(dest, { force: true });
4011
+ mkdirSync10(primary, { recursive: true });
3909
4012
  copySkill(versionDir(name, latest.version), dest);
3910
4013
  }
3911
4014
  const wrote = [dest];
3912
4015
  for (const dir of links) {
3913
4016
  const link2 = join15(dir, name);
3914
- if (isLink(link2) && realpathOrNull(link2) === null) rmSync6(link2, { force: true });
4017
+ if (isLink(link2) && realpathOrNull(link2) === null) rmSync7(link2, { force: true });
3915
4018
  else if (entryExists(link2)) {
3916
4019
  if (realpathOrNull(link2) === realpathOrNull(dest)) wrote.push(link2);
3917
4020
  continue;
3918
4021
  }
3919
- mkdirSync9(dir, { recursive: true });
4022
+ mkdirSync10(dir, { recursive: true });
3920
4023
  linkDir(realpathOrNull(dest) ?? dest, join15(realpathOrNull(dir) ?? dir, name));
3921
4024
  wrote.push(link2);
3922
4025
  }
@@ -3964,6 +4067,87 @@ async function resolveInstallDir(spec) {
3964
4067
  return { tmp: got.tmp, dirs, source, many: !spec.skill && dirs.length > 1 };
3965
4068
  }
3966
4069
 
4070
+ // src/check.ts
4071
+ import { join as join16 } from "path";
4072
+ function promptTokens(name, description) {
4073
+ return Math.max(1, Math.ceil((name.length + 1 + description.length) / 4));
4074
+ }
4075
+ function freshLabel(state, version, latest) {
4076
+ if (state === "update available") return `v${version} \u2192 v${latest}`;
4077
+ if (state === "edited locally, update available") return `edited here, library has v${latest}`;
4078
+ if (state === "edited locally") return "edited here";
4079
+ if (state === "out of sync") return "not the pinned version";
4080
+ if (state === "folder missing") return "folder missing";
4081
+ if (state === "ok" || state === "repo skill, in library" || state === "untracked copy of library skill") return "current";
4082
+ if (state === "untracked, differs from library" || state === "repo skill, differs from library") return "differs from your library";
4083
+ return "not in your library";
4084
+ }
4085
+ function libraryFresh(path, name) {
4086
+ const lib = join16(libraryDir(), name);
4087
+ if (!isSkillDir(lib)) return "not in your library";
4088
+ const here = treeHash(path);
4089
+ return here && here === treeHash(lib) ? "matches your library" : "differs from your library";
4090
+ }
4091
+ var settled = /* @__PURE__ */ new Set(["current", "matches your library", "vendor", "skilllib"]);
4092
+ function byWeight(rows2) {
4093
+ const rank = (r) => (r.uses === 0 ? 0 : 1) * 2 + (settled.has(r.fresh) ? 1 : 0);
4094
+ return [...rows2].sort((a, b) => rank(a) - rank(b) || b.everyPrompt - a.everyPrompt || a.name.localeCompare(b.name));
4095
+ }
4096
+ function tokenSum(rows2, where) {
4097
+ return rows2.filter((r) => !where || r.where === where).reduce((n, r) => n + r.everyPrompt, 0);
4098
+ }
4099
+ function rough(n) {
4100
+ const rounded = n >= 100 ? Math.round(n / 100) * 100 : n;
4101
+ return String(rounded).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
4102
+ }
4103
+ function checkBrief(rows2, ctx) {
4104
+ const global = rows2.filter((r) => r.where === "global");
4105
+ const repo = rows2.filter((r) => r.where === "repo");
4106
+ const globalTokens = tokenSum(global);
4107
+ const unused = rows2.filter((r) => r.uses === 0);
4108
+ const unusedGlobal = global.filter((r) => r.uses === 0 && r.lever !== "skilllib");
4109
+ const unusedTokens = tokenSum(unusedGlobal);
4110
+ const n = (count) => `${count} skill${count === 1 ? "" : "s"}`;
4111
+ const lines = [
4112
+ `${n(rows2.length)}. ~${rough(globalTokens)} tokens of names and descriptions in every prompt${ctx.repo ? `, plus ~${rough(tokenSum(repo))} from this repo` : ""}.`,
4113
+ `${unused.length} had no Claude Code use in ${ctx.days} days. A zero is a hint: Cursor and Codex aren't in these transcripts.`
4114
+ ];
4115
+ if (!globalTokens) return [...lines, "Nothing global is loading."];
4116
+ lines.push(
4117
+ unusedTokens * 2 >= globalTokens ? "Heavy. More than half of every prompt's skill list had no use." : unusedTokens * 4 >= globalTokens ? "Mixed. A good share of every prompt is skills with no use." : "Fine. Most of what loads has been used."
4118
+ );
4119
+ const plugins = Object.entries(Object.groupBy(global.filter((r) => r.lever === "plugin"), (r) => r.via ?? r.name)).map(([id, skills = []]) => ({
4120
+ id,
4121
+ tokens: tokenSum(skills),
4122
+ idle: tokenSum(skills.filter((s) => s.uses === 0)),
4123
+ skills: skills.length,
4124
+ used: skills.filter((s) => s.uses > 0).length
4125
+ })).filter((p) => p.idle > 0 && p.idle * 2 >= p.tokens).sort((a, b) => b.tokens - a.tokens);
4126
+ const yours = unusedGlobal.filter((r) => r.lever === "yours");
4127
+ const synced = unusedGlobal.filter((r) => r.lever === "claude.ai");
4128
+ const drop = plugins.reduce((n2, p) => n2 + p.tokens, 0) + tokenSum(yours) + tokenSum(synced);
4129
+ if (drop > 0) lines.push(`You can drop ~${rough(drop)} tokens.`);
4130
+ for (const p of plugins.slice(0, 3)) {
4131
+ lines.push(
4132
+ p.used ? `~${rough(p.tokens)} is ${p.id} (${p.skills} skills, ${p.used} used). Turning the plugin off drops all of it, including ${p.used === 1 ? "that one" : "those"}.` : `~${rough(p.tokens)} is ${p.id} (${p.skills} skills, no use). Turn the plugin off.`
4133
+ );
4134
+ }
4135
+ if (plugins.length > 3) lines.push(`${plugins.length - 3} more plugins are in the same spot.`);
4136
+ if (plugins.length) lines.push("Turn a Claude Code plugin off with skilllib plugin off <id>. Cursor plugins turn off in Cursor.");
4137
+ if (yours.length) {
4138
+ const names = yours.sort((a, b) => b.everyPrompt - a.everyPrompt).slice(0, 5).map((r) => r.name);
4139
+ const more = yours.length > 5 ? `, and ${yours.length - 5} more` : "";
4140
+ lines.push(`~${rough(tokenSum(yours))} are your own global skills with no use (${names.join(", ")}${more}). Move them into the repos that need them, or delete them.`);
4141
+ }
4142
+ if (synced.length) lines.push(`~${rough(tokenSum(synced))} are claude.ai skills with no use. Turn them off on claude.ai.`);
4143
+ const builtins = unusedGlobal.filter((r) => r.lever === "built-in");
4144
+ if (builtins.length) {
4145
+ const who = [...new Set(builtins.map((r) => r.via ?? "the agent"))].join(", ");
4146
+ lines.push(`~${rough(tokenSum(builtins))} are ${who} built-ins with no use. They ship with the agent. skilllib can't unload them.`);
4147
+ }
4148
+ return lines;
4149
+ }
4150
+
3967
4151
  // src/move.ts
3968
4152
  import { basename as basename10 } from "path";
3969
4153
  function moveGlobalSkills(skills, roots, {
@@ -4116,7 +4300,7 @@ function requireProject() {
4116
4300
  if (!isProjectCandidate(findProjectRoot())) {
4117
4301
  const top = findProjectRoot();
4118
4302
  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.`);
4119
- const stray = existsSync15(join16(userHome(), MANIFEST_FILE)) ? `, though ${tildify(join16(userHome(), MANIFEST_FILE))} makes it look like one` : "";
4303
+ const stray = existsSync15(join17(userHome(), MANIFEST_FILE)) ? `, though ${tildify(join17(userHome(), MANIFEST_FILE))} makes it look like one` : "";
4120
4304
  throw new Error(`Your home folder isn't a project${stray}: skills added here would load in every repo. Run this inside a project.`);
4121
4305
  }
4122
4306
  throw new Error("Not in a git repo. Run this inside a project (or `git init` first).");
@@ -4300,7 +4484,7 @@ function add(args) {
4300
4484
  const pins = readManifest(root).skills;
4301
4485
  const done = changes.filter((c) => c.action !== "skipped" && pins[c.name]);
4302
4486
  if (!args.json && args.personal === void 0 && done.some((c) => c.action === "installed" && !c.moved && pins[c.name].personal))
4303
- info(dim(`Pinned in your personal pins (${tildify(personalPinsDir())}) and kept out of git. --shared pins in skilllib.json for the team.`));
4487
+ info(dim(`Pinned for you (${tildify(personalPinsDir())}), kept out of git. To share it with the team: skilllib add <name> --shared, then commit skilllib.json and the skill folders.`));
4304
4488
  rememberProjects([root]);
4305
4489
  }
4306
4490
  function remove(args) {
@@ -4384,7 +4568,7 @@ function importSkills(args) {
4384
4568
  success(`${name} ${dim(result === "unchanged" ? "already in library" : result === "added" ? "added to library" : "library updated")}`);
4385
4569
  const root = resolve9(dir, "..", "..", "..");
4386
4570
  const dep = projectHere(root) === root ? readManifest(root).skills[name] : void 0;
4387
- if (dep && realpathOrNull(join16(root, dep.dir ?? PROJECT_SKILLS_DIR, name)) === realpathOrNull(dir) && dep.hash !== treeHash(dir)) {
4571
+ if (dep && realpathOrNull(join17(root, dep.dir ?? PROJECT_SKILLS_DIR, name)) === realpathOrNull(dir) && dep.hash !== treeHash(dir)) {
4388
4572
  const change = addSkill(root, name);
4389
4573
  if (change.action !== "skipped") info(dim(` ${basename11(root)} now depends on ${name} v${change.to}`));
4390
4574
  }
@@ -4402,7 +4586,7 @@ function scan(args) {
4402
4586
  const library = new Set(librarySkills().map((s) => s.name));
4403
4587
  const skillNames = (root) => [
4404
4588
  // Not a folder that links out of the repo: what's in it isn't the repo's (as in status).
4405
- .../* @__PURE__ */ new Set([...projectDirsInUse(enabledHarnesses()).filter((d) => !notWritable(root, d)).flatMap((d) => skillDirsIn(join16(root, d))).map((d) => basename11(d)), ...nestedSkills(root).map((s) => s.name)])
4589
+ .../* @__PURE__ */ new Set([...projectDirsInUse(enabledHarnesses()).filter((d) => !notWritable(root, d)).flatMap((d) => skillDirsIn(join17(root, d))).map((d) => basename11(d)), ...nestedSkills(root).map((s) => s.name)])
4406
4590
  ];
4407
4591
  const withSkills = found.filter((root) => skillNames(root).length > 0);
4408
4592
  info(`Scanned ${roots.map((r) => tildify(r)).join(", ")}: ${found.length} project${found.length === 1 ? "" : "s"}, ${withSkills.length} with skills
@@ -4466,7 +4650,7 @@ function newSkill(args) {
4466
4650
  if (!r.ok) {
4467
4651
  throw new Error(`Can't create ${name}: ${r.reason}`);
4468
4652
  }
4469
- success(`Created ${tildify(join16(r.dir, "SKILL.md"))}`);
4653
+ success(`Created ${tildify(join17(r.dir, "SKILL.md"))}`);
4470
4654
  }
4471
4655
  function restore(args) {
4472
4656
  const backups = [...listBackups(), ...pluginBackups()];
@@ -4587,7 +4771,7 @@ async function usage(args) {
4587
4771
  info(`No skill usage in Claude Code transcripts from the last ${args.days} days.`);
4588
4772
  return;
4589
4773
  }
4590
- info(`Skill usage, last ${args.days} days ${dim(`(from ${tildify(join16(claudeDir(), "projects"))})`)}
4774
+ info(`Skill usage, last ${args.days} days ${dim(`(from ${tildify(join17(claudeDir(), "projects"))})`)}
4591
4775
  `);
4592
4776
  table(
4593
4777
  summaries.map((s) => ({
@@ -4603,9 +4787,117 @@ async function usage(args) {
4603
4787
  warn(`Library skills unused in the last ${args.days} days: ${unused.join(", ")}`);
4604
4788
  }
4605
4789
  }
4790
+ var LEVER = { plugin: "plugin", "built-in": "built-in", "claude.ai": "claude.ai", system: "system", skilllib: "skilllib" };
4791
+ async function check(args) {
4792
+ const root = projectHere();
4793
+ const usage2 = await usageBySkill(args.days);
4794
+ const kept = keptGlobal();
4795
+ const machine = machineSkills().filter((m) => !m.broken);
4796
+ const loaded = root ? skillsLoadedIn(root, machine) : machine;
4797
+ const local = root ? projectStatus(root) : [];
4798
+ const localByName = new Map(local.map((s) => [s.name, s]));
4799
+ const seen = /* @__PURE__ */ new Set();
4800
+ const rows2 = [];
4801
+ for (const g of loaded) {
4802
+ seen.add(g.name);
4803
+ const here = localByName.get(g.name);
4804
+ const u = usage2.get(g.name);
4805
+ const repoFresh = here && here.state !== "folder missing" ? freshLabel(here.state, here.version, here.latest) : null;
4806
+ const lever = LEVER[g.kind] ?? "yours";
4807
+ rows2.push({
4808
+ name: g.name,
4809
+ where: "global",
4810
+ description: g.description,
4811
+ everyPrompt: promptTokens(g.name, g.description),
4812
+ uses: u?.uses ?? 0,
4813
+ lastUsed: u ? u.lastUsed.slice(0, 10) : null,
4814
+ projects: [...u?.projects ?? []].map((p) => basename11(p)),
4815
+ fresh: lever === "yours" ? repoFresh ?? libraryFresh(g.path, g.name) : lever,
4816
+ path: g.path,
4817
+ lever,
4818
+ ...lever !== "yours" && lever !== "skilllib" ? { via: g.origin } : {},
4819
+ ...here ? { alsoInRepo: true } : {},
4820
+ ...kept.has(g.name) ? { keptGlobal: true } : {}
4821
+ });
4822
+ }
4823
+ for (const s of local) {
4824
+ if (seen.has(s.name) || s.state === "folder missing") continue;
4825
+ const u = usage2.get(s.name);
4826
+ const { description } = readSkillInfo(s.path);
4827
+ rows2.push({
4828
+ name: s.name,
4829
+ where: "repo",
4830
+ description,
4831
+ everyPrompt: promptTokens(s.name, description),
4832
+ uses: u?.uses ?? 0,
4833
+ lastUsed: u ? u.lastUsed.slice(0, 10) : null,
4834
+ projects: [...u?.projects ?? []].map((p) => basename11(p)),
4835
+ fresh: freshLabel(s.state, s.version, s.latest),
4836
+ path: s.path,
4837
+ lever: "repo"
4838
+ });
4839
+ }
4840
+ const skills = byWeight(rows2);
4841
+ const repoName = root ? basename11(root) : null;
4842
+ const brief = checkBrief(skills, { days: args.days, repo: repoName });
4843
+ const report = {
4844
+ days: args.days,
4845
+ repo: repoName,
4846
+ everyPrompt: { global: tokenSum(skills, "global"), repo: tokenSum(skills, "repo") },
4847
+ note: "Uses are Claude Code transcripts only, so 0 means it wasn't seen. everyPrompt is a rough token count (chars/4) of the name and description, which sit in every prompt. The rest of SKILL.md loads only when the skill is used. brief is the report.",
4848
+ brief,
4849
+ skills: skills.map(({ lastUsed: lastUsed2, projects: projects2, via, alsoInRepo, keptGlobal: keptGlobal2, ...rest }) => ({
4850
+ ...rest,
4851
+ ...lastUsed2 ? { lastUsed: lastUsed2 } : {},
4852
+ ...projects2.length ? { projects: projects2 } : {},
4853
+ ...via ? { via } : {},
4854
+ ...alsoInRepo ? { alsoInRepo } : {},
4855
+ ...keptGlobal2 ? { keptGlobal: keptGlobal2 } : {}
4856
+ }))
4857
+ };
4858
+ if (args.json) return json("check", report);
4859
+ if (!skills.length) return info("No skills loaded.");
4860
+ info(brief.join("\n"));
4861
+ }
4862
+ function pluginCommand(args) {
4863
+ const [action, id, extra] = args.positional;
4864
+ if (action !== "off" && action !== "on" || !id || extra) throw new Error("Usage: skilllib plugin off|on <id>");
4865
+ const on = action === "on";
4866
+ const targets = pluginTargets(id);
4867
+ const results = targets.map((p) => setPluginOn(p, on));
4868
+ const ok = results.every((r) => r.ok);
4869
+ const message = [...results.map((r) => r.message), stillOnElsewhere(targets)].filter(Boolean).join(" ");
4870
+ if (!ok) process.exitCode = 1;
4871
+ const body = { id: targets[0].id, on, ok, message };
4872
+ if (args.json) return json("plugin", body);
4873
+ if (ok) success(message);
4874
+ else error(message);
4875
+ }
4876
+ function pluginTargets(id) {
4877
+ const claude2 = installedPlugins().filter((p) => p.agent === "claude-code");
4878
+ const exact = claude2.filter((p) => p.id === id);
4879
+ const matches = exact.length ? exact : claude2.filter((p) => pluginNameOf(p.id) === id);
4880
+ if (!matches.length) {
4881
+ const cursor = cursorPlugins().some((p) => p.id === id || pluginNameOf(p.id) === id);
4882
+ throw new Error(cursor ? `${id} is a Cursor plugin. Turn it off in Cursor (Settings \u2192 Plugins).` : `No Claude Code plugin ${id}.`);
4883
+ }
4884
+ const ids = [...new Set(matches.map((p) => p.id))];
4885
+ if (ids.length > 1) throw new Error(`Several plugins match ${id}: ${ids.join(", ")}. Pass the id.`);
4886
+ const everywhere = matches.filter((p) => !p.projectPath);
4887
+ if (everywhere.length) return everywhere;
4888
+ if (matches.length === 1) return matches;
4889
+ const where = matches.map((p) => `${basename11(p.projectPath)} (${p.scope})`).join(", ");
4890
+ throw new Error(`${matches[0].id} is installed in more than one repo (${where}). This command switches the copy that loads in every repo, and these are per-repo installs.`);
4891
+ }
4892
+ function stillOnElsewhere(targets) {
4893
+ const id = targets[0].id;
4894
+ const switched = new Set(targets.map((p) => p.projectPath).filter((p) => !!p));
4895
+ const left = installedPlugins().filter((p) => p.agent === "claude-code" && p.id === id && p.projectPath && !switched.has(p.projectPath) && p.on !== false);
4896
+ return left.length ? `Still on in ${left.map((p) => `${basename11(p.projectPath)} (${p.scope})`).join(", ")}.` : "";
4897
+ }
4606
4898
  function show(args) {
4607
4899
  const name = args.positional[0];
4608
- const dir = name ? join16(libraryDir(), name) : "";
4900
+ const dir = name ? join17(libraryDir(), name) : "";
4609
4901
  if (!name || !isSkillDir(dir)) {
4610
4902
  throw new Error(name ? `${name} is not in the library` : "Usage: skilllib show <skill-name>");
4611
4903
  }
@@ -4618,7 +4910,7 @@ function show(args) {
4618
4910
  latest: latestVersion(name)?.version ?? null,
4619
4911
  origin: libraryOrigins()[name] ?? "",
4620
4912
  projects: projectsUsing(name).map((p) => basename11(p)),
4621
- skillMd: readFileSync7(join16(dir, "SKILL.md"), "utf-8")
4913
+ skillMd: readFileSync7(join17(dir, "SKILL.md"), "utf-8")
4622
4914
  });
4623
4915
  }
4624
4916
  info(`${name} ${dim(tildify(dir))}`);
@@ -4793,7 +5085,7 @@ function tidy(args) {
4793
5085
  }
4794
5086
  if (args.dryRun) info(dim("\nNothing changed (--dry-run)."));
4795
5087
  else if (changed) info(dim(`
4796
- Replaced copies are in ${tildify(join16(skilllibHome(), "tidy-backup"))}; \`skilllib restore\` puts them back.`));
5088
+ Replaced copies are in ${tildify(join17(skilllibHome(), "tidy-backup"))}; \`skilllib restore\` puts them back.`));
4797
5089
  if (held) {
4798
5090
  process.exitCode = 1;
4799
5091
  info(dim(`${held} change${held === 1 ? "" : "s"} held back because git would see them. Re-run with --allow-git to make them.`));
@@ -4914,7 +5206,7 @@ async function install(args) {
4914
5206
  }
4915
5207
  }
4916
5208
  } finally {
4917
- if (resolved.tmp) rmSync7(resolved.tmp, { recursive: true, force: true });
5209
+ if (resolved.tmp) rmSync8(resolved.tmp, { recursive: true, force: true });
4918
5210
  }
4919
5211
  }
4920
5212
  if (args.json) json("install", results);
@@ -5036,6 +5328,8 @@ export {
5036
5328
  doctor,
5037
5329
  apply,
5038
5330
  usage,
5331
+ check,
5332
+ pluginCommand,
5039
5333
  show,
5040
5334
  harnessesCommand,
5041
5335
  link,
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  add,
5
5
  agentSkill,
6
6
  apply,
7
+ check,
7
8
  doctor,
8
9
  error,
9
10
  folders,
@@ -19,6 +20,7 @@ import {
19
20
  outdated,
20
21
  overview,
21
22
  parseArgs,
23
+ pluginCommand,
22
24
  projects,
23
25
  refreshAgentSkill,
24
26
  remove,
@@ -33,7 +35,7 @@ import {
33
35
  unhide,
34
36
  update,
35
37
  usage
36
- } from "./chunk-C6TSR7NT.js";
38
+ } from "./chunk-XMXC2XM2.js";
37
39
 
38
40
  // src/index.ts
39
41
  import { readFileSync } from "fs";
@@ -59,8 +61,8 @@ Set up:
59
61
  In a project (a git repo or a folder with skilllib.json; never your home folder):
60
62
  skilllib status This project's skills, which agents load them, and issues
61
63
  skilllib add <name>... Install library skills where your harnesses look
62
- (pinned in skilllib.json when the repo has one, else in your
63
- personal pins, kept out of git; --personal / --shared to choose)
64
+ (your personal pins, kept out of git; --shared writes
65
+ skilllib.json to commit with the team)
64
66
  skilllib remove <name>... Remove skills from this project
65
67
  skilllib link [--all] Make every skill here usable by all your agents (adds links)
66
68
  skilllib tidy [--all|--global] One real copy per skill, plus only the links your agents need
@@ -76,6 +78,8 @@ Library:
76
78
  skilllib new <name> [desc] Create a skill in the library
77
79
  skilllib projects Your projects and their skills
78
80
  skilllib usage [--days N] Which skills agents used (from Claude Code transcripts)
81
+ skilllib check [--days N] What global skills and this repo's cost, and whether each was used or is current
82
+ skilllib plugin off|on <id> Turn a Claude Code plugin off or on (it stays installed)
79
83
 
80
84
  Health:
81
85
  skilllib doctor [--fix] Broken links, duplicates, out-of-date projects
@@ -88,7 +92,7 @@ Agents:
88
92
  Teach your agents to use skilllib, so you can ask them
89
93
  "which skills can you use here?" in any repo
90
94
  --json Machine-readable output (status list show projects outdated
91
- usage doctor apply add remove sync update import global move agent-skill install search)
95
+ usage doctor apply add remove sync update import global move agent-skill install search check plugin)
92
96
 
93
97
  Options: --force (overwrite local edits) --allow-tracked (link into git-tracked skill folders) --days N
94
98
  `.trim();
@@ -125,13 +129,15 @@ var FLAGS_OF = {
125
129
  doctor: ["fix", "days"],
126
130
  apply: ["days"],
127
131
  usage: ["days"],
132
+ check: ["days"],
133
+ plugin: [],
128
134
  install: ["force", "global", "all", "allow-tracked"],
129
135
  search: [],
130
136
  "agent-skill": [],
131
137
  version: [],
132
138
  help: []
133
139
  };
134
- var NO_ARGS = /* @__PURE__ */ new Set(["", "ui", "status", "outdated", "list", "projects", "link", "doctor", "usage", "version"]);
140
+ var NO_ARGS = /* @__PURE__ */ new Set(["", "ui", "status", "outdated", "list", "projects", "link", "doctor", "usage", "check", "version"]);
135
141
  async function main() {
136
142
  const argv = process.argv.slice(2);
137
143
  setJsonMode(argv.includes("--json"));
@@ -148,10 +154,10 @@ async function main() {
148
154
  if (!["--version", "-v", "version", "help", "--help", "-h"].includes(command ?? "")) refreshAgentSkill();
149
155
  switch (command) {
150
156
  case void 0:
151
- return process.stdin.isTTY && process.stdout.isTTY ? (await import("./tui-3MBVYSVM.js")).tui() : overview(args);
157
+ return process.stdin.isTTY && process.stdout.isTTY ? (await import("./tui-N6NRJYLZ.js")).tui() : overview(args);
152
158
  case "ui":
153
159
  if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("`skilllib ui` needs an interactive terminal. Agents and scripts: use `skilllib status --json`.");
154
- return (await import("./tui-3MBVYSVM.js")).tui();
160
+ return (await import("./tui-N6NRJYLZ.js")).tui();
155
161
  case "status":
156
162
  return status(args);
157
163
  case "add":
@@ -196,6 +202,10 @@ async function main() {
196
202
  return apply(args);
197
203
  case "usage":
198
204
  return usage(args);
205
+ case "check":
206
+ return check(args);
207
+ case "plugin":
208
+ return pluginCommand(args);
199
209
  case "agent-skill":
200
210
  return agentSkill(args);
201
211
  case "install":
package/dist/json.d.cts CHANGED
@@ -896,6 +896,43 @@ type InstallJson = {
896
896
  /** Global folders written, when `--global`. */
897
897
  global?: string[];
898
898
  }[];
899
+ /** `skilllib check --json`. Computed while the command runs. Nothing is stored. */
900
+ type CheckJson = {
901
+ days: number;
902
+ repo: string | null;
903
+ /** Rough tokens of names and descriptions. Global skills load in every repo; repo skills add to that here. */
904
+ everyPrompt: {
905
+ global: number;
906
+ repo: number;
907
+ };
908
+ /** How to read uses and everyPrompt. */
909
+ note: string;
910
+ /** The short report. The skill list is there if you need a name. */
911
+ brief: string[];
912
+ skills: {
913
+ name: string;
914
+ where: "global" | "repo";
915
+ description: string;
916
+ everyPrompt: number;
917
+ uses: number;
918
+ lastUsed?: string;
919
+ projects?: string[];
920
+ fresh: string;
921
+ lever: "yours" | "plugin" | "built-in" | "claude.ai" | "system" | "skilllib" | "repo";
922
+ via?: string;
923
+ path: string;
924
+ alsoInRepo?: boolean;
925
+ keptGlobal?: boolean;
926
+ }[];
927
+ };
928
+ /** `skilllib plugin off|on <id> --json`. One Claude Code install: the copy that loads in every repo, or the only per-repo install. */
929
+ type PluginJson = {
930
+ id: string;
931
+ /** The state just set. */
932
+ on: boolean;
933
+ ok: boolean;
934
+ message: string;
935
+ };
899
936
  /** `skilllib agent-skill [install|remove] --json` */
900
937
  type AgentSkillJson = {
901
938
  state: AgentSkillState;
@@ -905,4 +942,4 @@ type AgentSkillJson = {
905
942
  removed?: string[];
906
943
  };
907
944
 
908
- export type { AgentSkillJson, AgentSkillState, AllChangesJson, ApplyJson, ChangeJson, ChangesJson, DoctorJson, ErrorJson, GitState, GlobalMoveJson, HarnessId, ImportJson, InstallJson, ListJson, OutdatedJson, ProjectSource, ProjectsJson, SearchJson, ShowJson, SkillState, SourceKind, StatusGlobalGroup, StatusIssue, StatusJson, StatusSkillGroup, UsageJson };
945
+ export type { AgentSkillJson, AgentSkillState, AllChangesJson, ApplyJson, ChangeJson, ChangesJson, CheckJson, DoctorJson, ErrorJson, GitState, GlobalMoveJson, HarnessId, ImportJson, InstallJson, ListJson, OutdatedJson, PluginJson, ProjectSource, ProjectsJson, SearchJson, ShowJson, SkillState, SourceKind, StatusGlobalGroup, StatusIssue, StatusJson, StatusSkillGroup, UsageJson };
package/dist/json.d.ts CHANGED
@@ -896,6 +896,43 @@ type InstallJson = {
896
896
  /** Global folders written, when `--global`. */
897
897
  global?: string[];
898
898
  }[];
899
+ /** `skilllib check --json`. Computed while the command runs. Nothing is stored. */
900
+ type CheckJson = {
901
+ days: number;
902
+ repo: string | null;
903
+ /** Rough tokens of names and descriptions. Global skills load in every repo; repo skills add to that here. */
904
+ everyPrompt: {
905
+ global: number;
906
+ repo: number;
907
+ };
908
+ /** How to read uses and everyPrompt. */
909
+ note: string;
910
+ /** The short report. The skill list is there if you need a name. */
911
+ brief: string[];
912
+ skills: {
913
+ name: string;
914
+ where: "global" | "repo";
915
+ description: string;
916
+ everyPrompt: number;
917
+ uses: number;
918
+ lastUsed?: string;
919
+ projects?: string[];
920
+ fresh: string;
921
+ lever: "yours" | "plugin" | "built-in" | "claude.ai" | "system" | "skilllib" | "repo";
922
+ via?: string;
923
+ path: string;
924
+ alsoInRepo?: boolean;
925
+ keptGlobal?: boolean;
926
+ }[];
927
+ };
928
+ /** `skilllib plugin off|on <id> --json`. One Claude Code install: the copy that loads in every repo, or the only per-repo install. */
929
+ type PluginJson = {
930
+ id: string;
931
+ /** The state just set. */
932
+ on: boolean;
933
+ ok: boolean;
934
+ message: string;
935
+ };
899
936
  /** `skilllib agent-skill [install|remove] --json` */
900
937
  type AgentSkillJson = {
901
938
  state: AgentSkillState;
@@ -905,4 +942,4 @@ type AgentSkillJson = {
905
942
  removed?: string[];
906
943
  };
907
944
 
908
- export type { AgentSkillJson, AgentSkillState, AllChangesJson, ApplyJson, ChangeJson, ChangesJson, DoctorJson, ErrorJson, GitState, GlobalMoveJson, HarnessId, ImportJson, InstallJson, ListJson, OutdatedJson, ProjectSource, ProjectsJson, SearchJson, ShowJson, SkillState, SourceKind, StatusGlobalGroup, StatusIssue, StatusJson, StatusSkillGroup, UsageJson };
945
+ export type { AgentSkillJson, AgentSkillState, AllChangesJson, ApplyJson, ChangeJson, ChangesJson, CheckJson, DoctorJson, ErrorJson, GitState, GlobalMoveJson, HarnessId, ImportJson, InstallJson, ListJson, OutdatedJson, PluginJson, ProjectSource, ProjectsJson, SearchJson, ShowJson, SkillState, SourceKind, StatusGlobalGroup, StatusIssue, StatusJson, StatusSkillGroup, UsageJson };
@@ -88,7 +88,7 @@ import {
88
88
  versionHistory,
89
89
  visibleProjects,
90
90
  withoutForceHint
91
- } from "./chunk-C6TSR7NT.js";
91
+ } from "./chunk-XMXC2XM2.js";
92
92
 
93
93
  // src/tui/index.tsx
94
94
  import { render } from "ink";
@@ -303,17 +303,6 @@ function copyText(text) {
303
303
  return { copied: false, savedTo };
304
304
  }
305
305
 
306
- // src/search.ts
307
- function matchScore(text, query) {
308
- if (!query) return 0;
309
- const t = text.toLowerCase();
310
- const q = query.toLowerCase();
311
- if (t.startsWith(q)) return 0;
312
- if (t.includes(q)) return 1;
313
- const initials = t.split(/[-_\s./]+/).filter(Boolean).map((w) => w[0]).join("");
314
- return q.length >= 2 && initials.startsWith(q) ? 2 : null;
315
- }
316
-
317
306
  // src/tui/world.ts
318
307
  var failed = (message) => ({ message, failed: true });
319
308
  var isFailure = (r) => typeof r !== "string" && "failed" in r;
@@ -624,7 +613,12 @@ function groupLabel(g, names) {
624
613
  return `${top} + ${names.length - 1} more \xB7 ${g.label}`;
625
614
  }
626
615
  function matches(name, query) {
627
- return matchScore(name, query) !== null;
616
+ if (!query) return true;
617
+ const text = name.toLowerCase();
618
+ const q = query.toLowerCase();
619
+ if (text.includes(q)) return true;
620
+ const initials = text.split(/[-_\s./]+/).filter(Boolean).map((w) => w[0]).join("");
621
+ return q.length >= 2 && initials.startsWith(q);
628
622
  }
629
623
  function addRows(w, target, q) {
630
624
  const taken = new Set(target ? project(w, target).skills.map((s) => s.name) : []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skilllib",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "Manage your agent skills like dependencies. One library, install from skills.sh, and every agent you use can see them.",
5
5
  "keywords": [
6
6
  "agent-skills",
@@ -6,7 +6,7 @@ allowed-tools: Bash(skilllib:*)
6
6
 
7
7
  # skilllib
8
8
 
9
- The user manages agent skills with the `skilllib` CLI. Their **library** (`~/.skilllib/library`, "my skills", "the registry") holds versioned skills that load nowhere until added to a repo. A repo pins its skills in `skilllib.json` (shared, committed), or in the user's personal pins (`~/.skilllib/pins`, kept out of git: `personal: true` in the output).
9
+ The user manages agent skills with the `skilllib` CLI. Their **library** (`~/.skilllib/library`, "my skills", "the registry") holds versioned skills that load nowhere until added to a repo. Adding a skill pins it in the user's personal pins (`~/.skilllib/pins`, kept out of git: `personal: true`). `skilllib.json` is only for sharing a skill with the team, and only when they ask: `--shared` writes it, and they commit it.
10
10
 
11
11
  Always pass `--json` (one line of JSON on stdout; messages go to stderr). A failure exits 1 with `{"error": "..."}` as that line. `link`, `tidy`, `global` (its listing, `keep` and `unkeep`), `restore`, `new`, `folders`, `harnesses`, `scan`, `search` (without `--json`) and `unhide` print messages only (read the exit code). Unknown flags are errors. Never run bare `skilllib` or `skilllib ui`: that's an interactive app.
12
12
 
@@ -23,9 +23,11 @@ The **Now** section at the end has this repo's `status` and `list` output. Answe
23
23
 
24
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
+ `check --json` reads the global skills and, in a repo, that repo's. It stores nothing. `brief` is the report: how many tokens the names and descriptions cost on every prompt, whether that is heavy, and the cuts that can actually be made. A Claude Code plugin it names turns off with `plugin off <id> --json`, and `plugin on <id>` puts it back. Run that only after the user agrees: the switch is global, and a skill in the plugin that was used goes too. Cursor plugins turn off in Cursor. claude.ai skills turn off on claude.ai. Built-ins ship with Cursor, Codex, and Grok, and skilllib can't unload them. Your own unused globals can move into the repos that need them. `uses` is Claude Code only, so 0 means it wasn't seen. Read `brief` to the user. The `skills` list is there if they ask about one.
27
+
26
28
  ## Changing (ask first unless the user asked for this exact change)
27
29
 
28
- `add <name>...` (pins in `skilllib.json` when the repo has one, else in personal pins; `--personal` / `--shared` choose, and move an existing pin without changing its version) · `remove <name>...` (skills skilllib manages, `source: library`) · `update [name...]` · `sync` (installs what's pinned, in `skilllib.json` and your personal pins; takes no names). Pass several names in one call. Their result is `[{name, action, from, to, dirs, personal, moved, reason, blocked, uncommitted, inTheWay, backedUp}]` (`personal: true` when the pin is in your personal pins; `moved` when `--personal`/`--shared` moved it), which is the whole result: there's no need to check files afterwards. `import <dir>...` (into the library) prints `[{path, name, status}]` and exits 1 if a folder was skipped (`exists`, `no SKILL.md`). `doctor --fix` prints doctor's issues, each fix with `fixed: {ok, message}`.
30
+ `plugin off|on <id>` (a Claude Code plugin, `name@marketplace` from `check`; a name alone works when only one plugin has it; it stays installed. The copy that loads in every repo. A per-repo install is left on, and the message says where) · `add <name>...` (personal pins; `--shared` writes `skilllib.json` for the team, and only when they asked to share it; `--personal` moves a shared pin back. Either flag moves an existing pin without changing its version. A pin stays where it already is) · `remove <name>...` (skills skilllib manages, `source: library`) · `update [name...]` · `sync` (installs what's pinned, personal pins and `skilllib.json`; takes no names). Pass several names in one call. Their result is `[{name, action, from, to, dirs, personal, moved, reason, blocked, uncommitted, inTheWay, backedUp}]` (`personal: true` when the pin is in your personal pins; `moved` when `--personal`/`--shared` moved it), which is the whole result: there's no need to check files afterwards. `import <dir>...` (into the library) prints `[{path, name, status}]` and exits 1 if a folder was skipped (`exists`, `no SKILL.md`). `doctor --fix` prints doctor's issues, each fix with `fixed: {ok, message}`.
29
31
 
30
32
  - `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
33
  - `removed` + `reason`: removed from skilllib.json, but something was left as it is (its copy behind a folder that links out of the repo, a file of the user's, or a link in such a folder). Tell the user what the reason says is still there.
@@ -33,11 +35,11 @@ Also: `usage --json` (Claude Code usage only, so "unused" is a hint; each skill
33
35
  - `inTheWay`: something else is where a link goes (a file, or another copy), so that agent may not run this copy there, and the command exits 1. Tell the user what's there; once they move it, `skilllib link` adds the link. (A skills folder that's a file or links out of the repo makes the skill `skipped` instead, with the reason.)
34
36
  - `blocked`: git tracks that folder. Re-run with `--allow-tracked` (for that command only) only with their OK.
35
37
  - `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.
36
- - Changes apply from the agent's next session. For a change without `personal`, remind the user to commit `skilllib.json` and the folders in `dirs`; a `personal` one has nothing to commit.
38
+ - Changes apply from the agent's next session. A `personal` change has nothing to commit. Without `personal` (`--shared`, or a pin that was already in `skilllib.json`), remind the user to commit `skilllib.json` and the folders in `dirs`.
37
39
 
38
40
  **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:
39
41
 
40
- - `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, left, projects: [{project, path, change}]}]` (`left`: links in a repo or its packages that now point at nothing, though a copy in the repo still serves them; tell the user to remove them); `moved: false` means it still loads globally, and `reason` says why (for example, git tracks the folder a link goes in, so an agent couldn't see it there: re-run with `--allow-tracked` only with the user's OK).
42
+ - `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, left, projects: [{project, path, change}]}]` (`left`: links in a repo or its packages that now point at nothing, though a copy in the repo still serves them; tell the user to remove them); `moved: false` means it still loads globally, and `reason` says why.
41
43
  - `global keep <name>...`: the user wants it everywhere; skilllib stops flagging it.
42
44
 
43
45
  **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.