@kaynooo/cli-doctor 0.6.0 → 0.8.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
@@ -10,16 +10,23 @@ doctor -q # only the failing checks, nothing when all pass
10
10
  doctor --only tea --only bun # just these checks (a package such as cli-forgejo, tea, hooks, bun, completions)
11
11
  doctor --fix --dry-run # what --fix would run
12
12
  doctor --fix # run the fixes, then check again
13
+ doctor --install # pick the packages to add from a checklist
14
+ doctor --install forgejo check hooks # or name them: a package, one of its bins, hooks, all
13
15
  ```
14
16
 
15
17
  | Check | Passes when | Fix |
16
18
  | --- | --- | --- |
17
19
  | each `@kaynooo/cli-*` package | it is installed globally, its bins are on the PATH and it is no older than the version `bun info` serves | `bun install -g <package>@<latest>`, and one line installing every outdated package at once |
18
- | `tea` | tea is installed, logged in to git.kaynooo.fr and `tea api version` answers, since `ft` goes through it | `tea login add --url https://git.kaynooo.fr --oauth` |
20
+ | `needs` | the system tools the installed packages need are on the PATH: glab for `gl`, docker for `dc`, `dlog`, `dx` and `sf` | install them by hand, doctor never installs a system package |
21
+ | `tea` | `ft` is installed, tea is installed, logged in to git.kaynooo.fr and `tea api version` answers, since `ft` goes through it | `tea login add --url https://git.kaynooo.fr --oauth` |
19
22
  | `hooks` | `~/.claude/settings.json` wires the hooks one way: the `cli-hooks@kaynooo-cli` plugin enabled, or the `cli` mod listed in `CLAUDE_CODE_PLUGIN_DIRS`; both at once fails, every call being checked twice | `claude plugin install cli-hooks --marketplace ...`, or `claude plugin disable` for the doubled one |
20
23
  | `bun` | Bun satisfies the `engines.bun` range of every installed package | `bun upgrade` |
21
24
 
22
- A registry that does not answer leaves a package passing, with a note. Exit codes: 0 every check passed, 1 one failed, 2 a usage error.
25
+ Only the packages that are installed are checked: a package left out is no failure, and the last line counts them. A registry that does not answer leaves a package passing, with a note. Exit codes: 0 every check passed, 1 one failed, 2 a usage error.
26
+
27
+ ## --install
28
+
29
+ `--install` adds packages: the names given (a package such as `forgejo`, one of its bins such as `ft`, `hooks` for the Claude Code plugin, or `all`), else a checklist on a terminal that shows each package's bins, what it does and what it needs. Off a terminal, names are required. Before installing, each package is checked against its needs: a missing required tool (tea for `ft`, glab for `gl`, docker for `dc`) leaves the package out unless `--force`, and `hooks` installs `guard` first. The installs run in one `bun install -g`, then the plugin, then the checks and fixes of what was installed. `--list` prints the choices and `--dry-run` what would run. Exits 1 when a package was left out.
23
30
 
24
31
  ## --fix
25
32
 
package/dist/cli-bin.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // @bun
3
3
 
4
4
  // src/cli-bin.ts
5
- import process12 from "process";
5
+ import process15 from "process";
6
6
  // ../kit/src/args.ts
7
7
  import process5 from "process";
8
8
  import { parseArgs } from "util";
@@ -274,6 +274,11 @@ function le(i = {}) {
274
274
  }
275
275
  var t = le();
276
276
 
277
+ // ../kit/src/theme.ts
278
+ var STYLES = new Set(["bold", "dim", "italic", "underline", "inverse", "strikethrough", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "gray", "grey", "blackBright", "redBright", "greenBright", "yellowBright", "blueBright", "magentaBright", "cyanBright", "whiteBright"]);
279
+ var MAX_FILE_BYTES = 64 * 1024;
280
+ var warned = new Set;
281
+
277
282
  // ../kit/src/style.ts
278
283
  function takesControlCodes(stream, env = process2.env) {
279
284
  return stream.isTTY === true && env.NO_COLOR === undefined && env.NODE_DISABLE_COLORS === undefined && env.TERM !== "dumb";
@@ -281,14 +286,14 @@ function takesControlCodes(stream, env = process2.env) {
281
286
  function paint(stream, env = process2.env) {
282
287
  return le({ stream: { isTTY: takesControlCodes(stream, env) }, env });
283
288
  }
284
- var STATUSES = {
285
- passed: { symbol: "\u2713", words: ["passed", "success", "succeeded", "ok"], paint: (colors) => colors.green },
286
- failed: { symbol: "\u2717", words: ["failed", "failure", "error", "errored"], paint: (colors) => colors.red },
287
- running: { symbol: "\u25CF", words: ["running", "in_progress", "started"], paint: (colors) => colors.cyan },
288
- queued: { symbol: "\u25CB", words: ["queued", "waiting", "pending", "created", "blocked", "scheduled"], paint: (colors) => colors.yellow },
289
- skipped: { symbol: "-", words: ["skipped", "cancelled", "canceled", "manual"], paint: (colors) => colors.dim }
289
+ var STATUS_WORDS = {
290
+ passed: ["passed", "success", "succeeded", "ok"],
291
+ failed: ["failed", "failure", "error", "errored"],
292
+ running: ["running", "in_progress", "started"],
293
+ queued: ["queued", "waiting", "pending", "created", "blocked", "scheduled"],
294
+ skipped: ["skipped", "cancelled", "canceled", "manual"]
290
295
  };
291
- var STATUS_OF = new Map(Object.entries(STATUSES).flatMap(([status, { words }]) => words.map((word) => [word, status])));
296
+ var STATUS_OF = new Map(Object.entries(STATUS_WORDS).flatMap(([status, words]) => words.map((word) => [word, status])));
292
297
 
293
298
  // ../kit/src/format.ts
294
299
  function upTo(text, separator) {
@@ -303,7 +308,7 @@ var ESCAPE = String.fromCharCode(27);
303
308
  function textWidth(text) {
304
309
  return Bun.stringWidth(text);
305
310
  }
306
- function table(rows, { cellWidth, header = false, align = [], fit } = {}) {
311
+ function table(rows, { cellWidth, header = false, align = [], fit, stream = process3.stdout } = {}) {
307
312
  const cells = cellWidth === undefined ? rows : rows.map((row) => row.map((cell) => truncate(cell.replaceAll(LINE_BREAKS, " "), cellWidth)));
308
313
  const widths = [];
309
314
  for (const row of cells) {
@@ -311,7 +316,7 @@ function table(rows, { cellWidth, header = false, align = [], fit } = {}) {
311
316
  widths[index] = Math.max(widths[index] ?? 0, textWidth(cell));
312
317
  }
313
318
  const shown = fit === undefined ? cells : narrow(cells, widths, fit);
314
- const colors = paint(process3.stdout);
319
+ const colors = paint(stream);
315
320
  const lines = shown.map((row) => row.map((cell, index) => {
316
321
  const width = widths[index] ?? 0;
317
322
  const gap = " ".repeat(Math.max(0, width - textWidth(cell)));
@@ -319,7 +324,8 @@ function table(rows, { cellWidth, header = false, align = [], fit } = {}) {
319
324
  return `${gap}${cell}`;
320
325
  return index === row.length - 1 ? cell : `${cell}${gap}`;
321
326
  }).join(" ").trimEnd());
322
- return lines.map((line, index) => header && index === 0 ? colors.bold(line) : line).join(`
327
+ const rule = header && takesControlCodes(stream) && lines.length > 1 ? [colors.dim("\u2500".repeat(Math.max(...lines.map(textWidth))))] : [];
328
+ return lines.flatMap((line, index) => header && index === 0 ? [colors.bold(line), ...rule] : [line]).join(`
323
329
  `);
324
330
  }
325
331
  function narrow(rows, widths, limit) {
@@ -446,6 +452,10 @@ class Exit extends Error {
446
452
  this.hint = hint;
447
453
  }
448
454
  }
455
+ function fail(message, code = 2, hint) {
456
+ throw new Exit(message, code, hint);
457
+ }
458
+
449
459
  class UsageError extends Exit {
450
460
  constructor(message, hint) {
451
461
  super(message, 2, hint);
@@ -1159,12 +1169,46 @@ import process10 from "process";
1159
1169
  // src/checks.ts
1160
1170
  import { existsSync, readFileSync as readFileSync2 } from "fs";
1161
1171
  import { basename, join as join3 } from "path";
1172
+
1173
+ // src/catalog.ts
1174
+ var HOOKS_REQUIRES = "@kaynooo/cli-guard";
1175
+ var ABOUT = {
1176
+ "@kaynooo/cli-base": "run a command at a ref in a throwaway worktree, and prove a test fails before a change",
1177
+ "@kaynooo/cli-changelog": "release notes from conventional commits, published as a Forgejo release",
1178
+ "@kaynooo/cli-check": "the repo's check, lint and test steps, its fixers, error lines from logs and a recipe picker",
1179
+ "@kaynooo/cli-code": "source read by spans, TODO comments and the files worth splitting",
1180
+ "@kaynooo/cli-db": "read-only SQL against Postgres, MySQL and MariaDB, credentials never shown",
1181
+ "@kaynooo/cli-dev": "passwords, JWTs, timestamps, UUIDs, base64 and file hashes",
1182
+ "@kaynooo/cli-docker": "docker compose on the stack found here, service health, shells and Symfony commands",
1183
+ "@kaynooo/cli-doctor": "this install check, and the list of every bin",
1184
+ "@kaynooo/cli-edit": "replace text in files, writing nothing unless every match count is right",
1185
+ "@kaynooo/cli-env": ".env files listed and edited with secrets masked, and a secrets filter for pipes",
1186
+ "@kaynooo/cli-forgejo": "Forgejo CI runs, logs, releases and issue checklists",
1187
+ "@kaynooo/cli-git": "repository state in one line, commits of exact paths, a branch picker",
1188
+ "@kaynooo/cli-gitlab": "GitLab pipelines and their failed tests",
1189
+ "@kaynooo/cli-guard": "the safety rules a Claude Code Bash call is held to",
1190
+ "@kaynooo/cli-out": "shortens a noisy command's output, keeping the errors",
1191
+ "@kaynooo/cli-pick": "a fuzzy selector for pipes",
1192
+ "@kaynooo/cli-pkg": "vets npm packages before they are added, and bumps a lib in its consumers",
1193
+ "@kaynooo/cli-probe": "a site's HTTP version, redirects, timings and TLS",
1194
+ "@kaynooo/cli-proc": "what a process is, who listens on a port, leftover dev processes",
1195
+ "@kaynooo/cli-stack": "a stable port per service name, and a compose release audit",
1196
+ "@kaynooo/cli-view": "markdown, source and git diffs in the colours of the other bins",
1197
+ "@kaynooo/cli-wait": "wait for a line, a port, a URL or a process; a machine-wide slot for heavy jobs",
1198
+ "@kaynooo/cli-workspace": "the repositories under a root needing attention, reclaimable folders and known vulnerabilities"
1199
+ };
1200
+ var NEEDS = {
1201
+ "@kaynooo/cli-forgejo": [{ bin: "tea", why: "ft goes through it", how: "https://gitea.com/gitea/tea/releases" }],
1202
+ "@kaynooo/cli-gitlab": [{ bin: "glab", why: "gl goes through it", how: "https://gitlab.com/gitlab-org/cli" }],
1203
+ "@kaynooo/cli-docker": [{ bin: "docker", why: "dc, dlog, dx and sf run docker compose", how: "https://docs.docker.com/engine/install/" }]
1204
+ };
1205
+
1206
+ // src/checks.ts
1162
1207
  var TOOLS = {
1163
- "@kaynooo/cli-audit": ["audit"],
1164
1208
  "@kaynooo/cli-base": ["base", "proof"],
1165
1209
  "@kaynooo/cli-changelog": ["changelog"],
1166
1210
  "@kaynooo/cli-check": ["check", "fix", "errs", "j"],
1167
- "@kaynooo/cli-code": ["peek", "todo-scan", "hotspots"],
1211
+ "@kaynooo/cli-code": ["src", "peek", "todo-scan", "hotspots"],
1168
1212
  "@kaynooo/cli-db": ["db"],
1169
1213
  "@kaynooo/cli-docker": ["dc", "dlog", "dx", "sf"],
1170
1214
  "@kaynooo/cli-doctor": ["doctor", "cli"],
@@ -1179,12 +1223,11 @@ var TOOLS = {
1179
1223
  "@kaynooo/cli-pick": ["pick"],
1180
1224
  "@kaynooo/cli-pkg": ["pkg"],
1181
1225
  "@kaynooo/cli-probe": ["probe"],
1182
- "@kaynooo/cli-proc": ["pid", "ports", "procs"],
1183
- "@kaynooo/cli-repos": ["repos"],
1226
+ "@kaynooo/cli-proc": ["proc", "pid", "ports", "procs"],
1184
1227
  "@kaynooo/cli-stack": ["stack"],
1185
- "@kaynooo/cli-sweep": ["sweep"],
1186
1228
  "@kaynooo/cli-view": ["view"],
1187
- "@kaynooo/cli-wait": ["waitfor", "slot"]
1229
+ "@kaynooo/cli-wait": ["waitfor", "slot"],
1230
+ "@kaynooo/cli-workspace": ["ws", "repos", "sweep", "audit"]
1188
1231
  };
1189
1232
  var FORGE = "git.kaynooo.fr";
1190
1233
  var FORGE_HOSTS = new Set([FORGE, "git.kevyn.fr"]);
@@ -1212,6 +1255,9 @@ function firstLine(text) {
1212
1255
  function installed(host, name) {
1213
1256
  return readJson(join3(host.globalModules, name, "package.json"));
1214
1257
  }
1258
+ function isInstalled(host, name) {
1259
+ return installed(host, name)?.version !== undefined;
1260
+ }
1215
1261
  function ownBin(host, name, bin) {
1216
1262
  const declared = installed(host, name)?.bin;
1217
1263
  const file = typeof declared === "string" ? declared : declared?.[bin];
@@ -1250,10 +1296,16 @@ async function checkTea(host) {
1250
1296
  return { check: "tea", ok: false, detail: `${forge} did not answer: ${firstLine(version.stderr || version.stdout)}`, fix: `tea login edit ${login.name} --token <token>, or check the network` };
1251
1297
  return { check: "tea", ok: true, detail: `logged in to ${forge} as ${login.name}, Forgejo ${served}` };
1252
1298
  }
1253
- function checkHooks(host) {
1299
+ function hooksWiring(host) {
1254
1300
  const settings = readJson(join3(host.home, ".claude", "settings.json")) ?? {};
1255
1301
  const plugin = settings.enabledPlugins?.[PLUGIN] === true;
1256
1302
  const mod = (settings.env?.CLAUDE_CODE_PLUGIN_DIRS ?? "").split(":").map((dir) => dir.replace(LEADING_TILDE, host.home)).find((dir) => basename(dir) === "cli" && existsSync(join3(dir, "hooks", "hooks.json")));
1303
+ return { plugin, mod };
1304
+ }
1305
+ function checkHooks(host) {
1306
+ const { plugin, mod } = hooksWiring(host);
1307
+ if ((plugin || mod !== undefined) && !isInstalled(host, HOOKS_REQUIRES))
1308
+ return { check: "hooks", ok: false, detail: `wired, but ${HOOKS_REQUIRES} is not installed, so every hook finds no guard`, fix: `bun install -g ${HOOKS_REQUIRES}` };
1257
1309
  if (plugin && mod !== undefined)
1258
1310
  return { check: "hooks", ok: false, detail: `both the ${PLUGIN} plugin and the mod in ${mod} run guard, so every call is checked twice`, fix: `claude plugin disable ${PLUGIN}` };
1259
1311
  if (plugin)
@@ -1278,19 +1330,29 @@ function checkCompletions(host) {
1278
1330
  return { check: "completions", ok: false, detail: `no fish completion in ${dir} for ${missing.join(", ")}`, fix: "cli completions fish --install" };
1279
1331
  return { check: "completions", ok: true, detail: dir };
1280
1332
  }
1333
+ function checkNeeds(host) {
1334
+ const missing = Object.entries(NEEDS).flatMap(([name, needs]) => isInstalled(host, name) ? needs.filter((need) => need.bin !== "tea" && host.which(need.bin) === null) : []);
1335
+ if (missing.length > 0)
1336
+ return { check: "needs", ok: false, detail: `missing ${missing.map((need) => `${need.bin} (${need.why})`).join(", ")}`, fix: `install ${missing.map((need) => `${need.bin}: ${need.how}`).join("; ")}` };
1337
+ return { check: "needs", ok: true, detail: "every tool the installed packages need is on the PATH" };
1338
+ }
1281
1339
  var toolCheck = (name) => name.slice(name.indexOf("/") + 1);
1282
- var OTHER_CHECKS = ["tea", "hooks", "bun", "completions"];
1340
+ var OTHER_CHECKS = ["tea", "hooks", "needs", "bun", "completions"];
1341
+ var FORGEJO = "@kaynooo/cli-forgejo";
1283
1342
  var CHECK_NAMES = [...Object.keys(TOOLS).map(toolCheck), ...OTHER_CHECKS];
1284
1343
  async function runChecks(host, only = []) {
1285
1344
  const wanted = (check) => only.length === 0 || only.includes(check);
1286
- const entries = Object.entries(TOOLS).filter(([name]) => wanted(toolCheck(name)));
1345
+ const entries = Object.entries(TOOLS).filter(([name]) => only.length === 0 ? isInstalled(host, name) : wanted(toolCheck(name)));
1346
+ const { plugin, mod } = hooksWiring(host);
1347
+ const hooks = only.length > 0 || plugin || mod !== undefined || isInstalled(host, HOOKS_REQUIRES);
1287
1348
  const tools = [];
1288
1349
  for (let at = 0;at < entries.length; at += INFO_AT_ONCE)
1289
1350
  tools.push(...await Promise.all(entries.slice(at, at + INFO_AT_ONCE).map(async ([name, bins]) => checkTool(host, name, bins))));
1290
1351
  return [
1291
1352
  ...tools,
1292
- ...wanted("tea") ? [await checkTea(host)] : [],
1293
- ...wanted("hooks") ? [checkHooks(host)] : [],
1353
+ ...wanted("tea") && (only.length > 0 || isInstalled(host, FORGEJO)) ? [await checkTea(host)] : [],
1354
+ ...wanted("hooks") && hooks ? [checkHooks(host)] : [],
1355
+ ...wanted("needs") ? [checkNeeds(host)] : [],
1294
1356
  ...wanted("bun") ? [checkBun(host)] : [],
1295
1357
  ...wanted("completions") ? [checkCompletions(host)] : []
1296
1358
  ];
@@ -1355,6 +1417,46 @@ function missingCompletions(host, dir) {
1355
1417
  return installedBins(host).filter((bin) => !existsSync2(join4(dir, `${bin}.fish`)));
1356
1418
  }
1357
1419
 
1420
+ // src/doctor.ts
1421
+ import process14 from "process";
1422
+
1423
+ // src/fix.ts
1424
+ var INSTALL = /^bun install -g (\S+)$/;
1425
+ var RUNNABLE = /^(?:cli completions fish --install|claude plugin (?:install \S+ --marketplace \S+|disable \S+))$/;
1426
+ function planFixes(results) {
1427
+ const steps = [];
1428
+ const installs = [];
1429
+ for (const { ok, fix } of results) {
1430
+ if (ok || fix === undefined)
1431
+ continue;
1432
+ const install = INSTALL.exec(fix);
1433
+ if (install !== null)
1434
+ installs.push(install[1] ?? "");
1435
+ else
1436
+ steps.push({ command: fix, argv: RUNNABLE.test(fix) ? fix.split(" ") : undefined });
1437
+ }
1438
+ if (installs.length === 0)
1439
+ return steps;
1440
+ const command = `bun install -g ${installs.join(" ")}`;
1441
+ return [{ command, argv: command.split(" ") }, ...steps];
1442
+ }
1443
+ async function applyFixes(host, steps, dry) {
1444
+ const outcomes = [];
1445
+ for (const { command, argv } of steps) {
1446
+ if (argv === undefined) {
1447
+ outcomes.push({ command, status: "not run", detail: "not a command doctor runs: do it by hand" });
1448
+ continue;
1449
+ }
1450
+ if (dry) {
1451
+ outcomes.push({ command, status: "dry-run" });
1452
+ continue;
1453
+ }
1454
+ const ran = await host.run(argv);
1455
+ outcomes.push(ran.code === 0 ? { command, status: "ran" } : { command, status: "failed", detail: `exit ${ran.code}: ${firstLine(ran.stderr || ran.stdout)}` });
1456
+ }
1457
+ return outcomes;
1458
+ }
1459
+
1358
1460
  // src/host.ts
1359
1461
  import { homedir as homedir2 } from "os";
1360
1462
  import { join as join5 } from "path";
@@ -1377,6 +1479,356 @@ function hostHere() {
1377
1479
  };
1378
1480
  }
1379
1481
 
1482
+ // src/install.ts
1483
+ import process13 from "process";
1484
+
1485
+ // ../kit/src/prompt.ts
1486
+ import process12 from "process";
1487
+ var KEY_SEQUENCES = /\x1B\[[0-9;]*[A-Z~]|\x1BO[A-Z]|[\s\S]/giu;
1488
+ var CONTROL = /^[\x00-\x1F\x7F]/;
1489
+ var WORDS = /\s+/;
1490
+ var MAX_ROWS = 10;
1491
+ var KEYS = { "\x1B[A": "up", "\x1BOA": "up", "\x1B[B": "down", "\x1BOB": "down", "\r": "enter", "\n": "enter", "\x03": "cancel", "\x1B": "cancel", "\x7F": "erase", "\b": "erase" };
1492
+ function readKeys(input) {
1493
+ return (input.match(KEY_SEQUENCES) ?? []).flatMap((sequence) => KEYS[sequence] ?? (CONTROL.test(sequence) ? [] : [{ text: sequence }]));
1494
+ }
1495
+ function flip(checked, value) {
1496
+ const next = new Set(checked);
1497
+ if (!next.delete(value))
1498
+ next.add(value);
1499
+ return next;
1500
+ }
1501
+ async function checklist(question, choices, { initial = new Set, min = 0, toggle = flip, ...settings } = {}) {
1502
+ const list = new List(choices, 0);
1503
+ let checked = initial;
1504
+ let invalid;
1505
+ const accepted = await ask(question, settings, (colors, state, rows) => state === "asking" ? [asking(colors, question, list.query, "type to filter, space toggles, enter accepts"), ...list.rows(colors, rows, (choice) => checked.has(choice.value) ? `${colors.cyan("\u25C9")} ` : `${colors.dim("\u25EF")} `), ...invalid === undefined ? [] : [` ${colors.red(invalid)}`]] : [answer(colors, question, state, choices.filter((choice) => checked.has(choice.value)).map((choice) => choice.label).join(", ") || "none")], (key) => {
1506
+ if (key === "cancel")
1507
+ return key;
1508
+ invalid = undefined;
1509
+ if (key === "enter") {
1510
+ invalid = checked.size < min ? `pick at least ${min}` : undefined;
1511
+ return invalid === undefined ? "enter" : undefined;
1512
+ }
1513
+ const current = list.current();
1514
+ if (typeof key === "object" && key.text === " ") {
1515
+ if (current !== undefined)
1516
+ checked = toggle(checked, current.value);
1517
+ return;
1518
+ }
1519
+ list.press(key);
1520
+ return;
1521
+ });
1522
+ return accepted ? checked : undefined;
1523
+ }
1524
+ class List {
1525
+ choices;
1526
+ query = "";
1527
+ matches;
1528
+ cursor;
1529
+ top = 0;
1530
+ width;
1531
+ constructor(choices, initial) {
1532
+ this.choices = choices;
1533
+ this.matches = choices;
1534
+ this.cursor = Math.min(Math.max(initial, 0), choices.length - 1);
1535
+ this.width = Math.max(0, ...choices.map((choice) => choice.label.length));
1536
+ }
1537
+ current() {
1538
+ return this.matches[this.cursor];
1539
+ }
1540
+ press(key) {
1541
+ if (key === "up" || key === "down") {
1542
+ if (this.matches.length > 0)
1543
+ this.cursor = (this.cursor + (key === "up" ? this.matches.length - 1 : 1)) % this.matches.length;
1544
+ return;
1545
+ }
1546
+ if (key === "erase")
1547
+ this.filter([...this.query].slice(0, -1).join(""));
1548
+ else if (typeof key === "object")
1549
+ this.filter(this.query + key.text);
1550
+ }
1551
+ filter(query) {
1552
+ const words = query.toLowerCase().split(WORDS).filter((word) => word !== "");
1553
+ const current = this.current();
1554
+ this.query = query;
1555
+ this.matches = this.choices.filter(({ label, hint }) => words.every((word) => `${label} ${hint}`.toLowerCase().includes(word)));
1556
+ this.cursor = current === undefined ? 0 : Math.max(this.matches.indexOf(current), 0);
1557
+ this.top = 0;
1558
+ }
1559
+ rows(colors, { columns, rows }, marker) {
1560
+ if (this.matches.length === 0)
1561
+ return [colors.dim(" no match")];
1562
+ const height = Math.max(1, Math.min(MAX_ROWS, rows - 3, this.matches.length));
1563
+ this.top = Math.min(Math.max(this.top, this.cursor - height + 1), this.cursor);
1564
+ const shown = this.matches.slice(this.top, this.top + height).map((choice, index) => {
1565
+ const here = this.top + index === this.cursor;
1566
+ const mark = marker(choice);
1567
+ const head = `${here ? "\u276F" : " "} ${mark}`;
1568
+ const label = choice.label.padEnd(this.width);
1569
+ const room = columns - 5 - (mark === "" ? 0 : 2) - this.width;
1570
+ return `${here ? colors.cyan(head) : head}${here ? colors.cyan(label) : label} ${colors.dim(truncate(choice.hint, Math.max(room, 0)))}`;
1571
+ });
1572
+ const below = this.matches.length - this.top - height;
1573
+ return [...this.top > 0 ? [colors.dim(` \u2026 ${this.top} more`)] : [], ...shown, ...below > 0 ? [colors.dim(` \u2026 ${below} more`)] : []];
1574
+ }
1575
+ }
1576
+ function asking(colors, question, typed, keys) {
1577
+ return `${colors.cyan("?")} ${question} ${typed === "" ? colors.dim(`(${keys})`) : typed}`;
1578
+ }
1579
+ function answer(colors, question, state, picked) {
1580
+ return state === "enter" ? `${colors.green("\u2713")} ${question} ${colors.cyan(picked)}` : `${colors.red("\u2717")} ${question} ${colors.dim("cancelled")}`;
1581
+ }
1582
+ async function ask(question, { hint, terminal = process12 }, render, press) {
1583
+ const { stdin, stderr } = terminal;
1584
+ if (stdin.isTTY !== true || stderr.isTTY !== true)
1585
+ fail(`cannot ask "${question}" off a terminal`, 2, hint);
1586
+ const colors = paint(stderr);
1587
+ let drawn = 0;
1588
+ let outcome;
1589
+ const draw = (state) => {
1590
+ const frame = render(colors, state, { columns: stderr.columns || 80, rows: stderr.rows || 24 });
1591
+ stderr.write(`${drawn > 1 ? `\x1B[${drawn - 1}A` : ""}\r\x1B[0J${frame.join(`
1592
+ `)}`);
1593
+ drawn = frame.length;
1594
+ };
1595
+ try {
1596
+ stdin.setRawMode(true);
1597
+ stdin.resume();
1598
+ stderr.write("\x1B[?25l");
1599
+ draw("asking");
1600
+ return await new Promise((resolve) => {
1601
+ function onData(data) {
1602
+ for (const key of readKeys(data.toString())) {
1603
+ const pressed = press(key);
1604
+ if (pressed !== undefined) {
1605
+ settle(pressed);
1606
+ return;
1607
+ }
1608
+ }
1609
+ draw("asking");
1610
+ }
1611
+ function onEnd() {
1612
+ settle("cancel");
1613
+ }
1614
+ function settle(settled) {
1615
+ outcome = settled;
1616
+ stdin.off("data", onData);
1617
+ stdin.off("end", onEnd);
1618
+ resolve(settled === "enter");
1619
+ }
1620
+ stdin.on("data", onData);
1621
+ stdin.on("end", onEnd);
1622
+ });
1623
+ } finally {
1624
+ draw(outcome ?? "cancel");
1625
+ stderr.write(`
1626
+ \x1B[?25h`);
1627
+ stdin.setRawMode(false);
1628
+ stdin.pause();
1629
+ }
1630
+ }
1631
+
1632
+ // src/install.ts
1633
+ var HOOKS = "hooks";
1634
+ var PLUGIN_INSTALL = ["claude", "plugin", "install", "cli-hooks", "--marketplace", `https://${FORGE}/kaynooo/cli.git`];
1635
+ var CLI_PREFIX = /^cli-/;
1636
+ var shortName = (name) => name.slice(name.indexOf("/") + 1).replace(CLI_PREFIX, "");
1637
+ function candidates2(host) {
1638
+ const packages = Object.entries(TOOLS).filter(([name]) => !isInstalled(host, name)).map(([name, bins]) => {
1639
+ const needs = (NEEDS[name] ?? []).map((need) => need.bin);
1640
+ return { id: name, names: [shortName(name), name, `cli-${shortName(name)}`, ...bins], label: shortName(name), hint: `${bins.join(" ")}: ${ABOUT[name] ?? ""}${needs.length === 0 ? "" : `, needs ${needs.join(", ")}`}`, packages: [name] };
1641
+ });
1642
+ const { plugin, mod } = hooksWiring(host);
1643
+ if (plugin || mod !== undefined)
1644
+ return packages;
1645
+ return [...packages, { id: HOOKS, names: [HOOKS, "plugin", "cli-hooks"], label: "hooks", hint: `the Claude Code plugin running guard on every tool call, needs ${HOOKS_REQUIRES}`, packages: isInstalled(host, HOOKS_REQUIRES) ? [] : [HOOKS_REQUIRES] }];
1646
+ }
1647
+ function choose(pool, names) {
1648
+ const chosen = new Set;
1649
+ for (const name of names) {
1650
+ if (name === "all") {
1651
+ for (const each of pool)
1652
+ chosen.add(each);
1653
+ continue;
1654
+ }
1655
+ const found = pool.find((each) => each.names.includes(name));
1656
+ if (found !== undefined)
1657
+ chosen.add(found);
1658
+ }
1659
+ return [...chosen];
1660
+ }
1661
+ function plan(host, picked, force) {
1662
+ const blocked = [];
1663
+ const warnings = [];
1664
+ const packages = [];
1665
+ for (const name of new Set(picked.flatMap((each) => each.packages))) {
1666
+ const lacking = (NEEDS[name] ?? []).filter((need) => host.which(need.bin) === null);
1667
+ if (lacking.length > 0 && !force) {
1668
+ for (const need of lacking)
1669
+ blocked.push({ package: shortName(name), need: need.bin, why: need.why, how: need.how });
1670
+ continue;
1671
+ }
1672
+ for (const need of lacking)
1673
+ warnings.push({ package: shortName(name), need: need.bin, why: need.why });
1674
+ packages.push(name);
1675
+ }
1676
+ const guardBlocked = blocked.some((each) => `@kaynooo/cli-${each.package}` === HOOKS_REQUIRES);
1677
+ return { packages, plugin: picked.some((each) => each.id === HOOKS) && !guardBlocked, blocked, warnings };
1678
+ }
1679
+ function checksFor(packages, plugin) {
1680
+ return [...packages.map((name) => name.slice(name.indexOf("/") + 1)), ...plugin ? ["hooks"] : [], "needs", "completions", ...packages.includes("@kaynooo/cli-forgejo") ? ["tea"] : []];
1681
+ }
1682
+ async function ask2(pool) {
1683
+ if (process13.stdin.isTTY !== true || process13.stderr.isTTY !== true)
1684
+ badUsage("name what to install, or all: doctor --install forgejo check hooks (doctor --install --list shows the choices)");
1685
+ const choices = pool.map((each) => ({ value: each, label: each.label, hint: each.hint }));
1686
+ const picked = await checklist("install which?", choices, { hint: "name them instead: doctor --install forgejo check" });
1687
+ return picked === undefined ? [] : [...picked];
1688
+ }
1689
+ async function install(host, options, recheck) {
1690
+ const pool = candidates2(host);
1691
+ const { dim, green, red, yellow } = paint(process13.stdout);
1692
+ if (options.list) {
1693
+ for (const each of pool)
1694
+ console.log(`${each.label.padEnd(12)} ${dim(each.hint)}`);
1695
+ if (pool.length === 0)
1696
+ console.log("everything is installed");
1697
+ return 0;
1698
+ }
1699
+ if (pool.length === 0) {
1700
+ console.log("everything is installed");
1701
+ return 0;
1702
+ }
1703
+ const unknown = options.names.filter((name) => name !== "all" && !Object.values(candidatesAll(host)).some((each) => each.names.includes(name)));
1704
+ if (unknown.length > 0)
1705
+ badUsage(`nothing to install is named ${unknown.join(", ")}: doctor --install --list`);
1706
+ const picked = options.names.length === 0 ? await ask2(pool) : choose(pool, options.names);
1707
+ const skipped = options.names.filter((name) => name !== "all" && !pool.some((each) => each.names.includes(name)));
1708
+ for (const name of skipped)
1709
+ console.log(`${dim("already")} ${name} is installed`);
1710
+ if (picked.length === 0) {
1711
+ if (skipped.length === 0)
1712
+ console.log("nothing picked");
1713
+ return 0;
1714
+ }
1715
+ const planned = plan(host, picked, options.force);
1716
+ for (const each of planned.blocked)
1717
+ console.log(`${red("blocked")} ${each.package} needs ${each.need} (${each.why}): ${each.how}; --force installs it anyway`);
1718
+ for (const each of planned.warnings)
1719
+ console.log(`${yellow("warn ")} ${each.package}: ${each.need} is missing, ${each.why}`);
1720
+ if (planned.packages.length === 0 && !planned.plugin)
1721
+ return 1;
1722
+ const command = ["bun", "install", "-g", ...planned.packages];
1723
+ if (planned.packages.length > 0)
1724
+ console.log(`${options.dryRun ? dim("would ") : green("install")} ${command.join(" ")}`);
1725
+ if (planned.plugin)
1726
+ console.log(`${options.dryRun ? dim("would ") : green("install")} ${PLUGIN_INSTALL.join(" ")}`);
1727
+ if (options.dryRun)
1728
+ return planned.blocked.length > 0 ? 1 : 0;
1729
+ if (planned.packages.length > 0) {
1730
+ const ran = await host.run(command);
1731
+ if (ran.code !== 0) {
1732
+ console.error(`${red("failed")} bun install -g: exit ${ran.code}: ${firstLine(ran.stderr || ran.stdout)}`);
1733
+ return 1;
1734
+ }
1735
+ }
1736
+ if (planned.plugin) {
1737
+ if (host.which("claude") === null) {
1738
+ console.log(`${dim("not run")} claude is not on the PATH: ${PLUGIN_INSTALL.join(" ")}`);
1739
+ } else {
1740
+ const ran = await host.run(PLUGIN_INSTALL);
1741
+ if (ran.code !== 0)
1742
+ console.error(`${red("failed")} ${PLUGIN_INSTALL.join(" ")}: exit ${ran.code}: ${firstLine(ran.stderr || ran.stdout)}`);
1743
+ }
1744
+ }
1745
+ console.log(`
1746
+ after the install:`);
1747
+ const code = await recheck(checksFor(planned.packages, planned.plugin));
1748
+ return planned.blocked.length > 0 ? 1 : code;
1749
+ }
1750
+ function candidatesAll(host) {
1751
+ const open = candidates2(host);
1752
+ const done = Object.entries(TOOLS).filter(([name]) => isInstalled(host, name)).map(([name, bins]) => ({ id: name, names: [shortName(name), name, `cli-${shortName(name)}`, ...bins], label: shortName(name), hint: "", packages: [name] }));
1753
+ return [...open, ...done];
1754
+ }
1755
+
1756
+ // src/doctor.ts
1757
+ var USAGE = `doctor [--only <check>] [--quiet] [--fix | --install [<name>...|all]] [--list] [--dry-run] [--force] [--json]: checks this host's @kaynooo/cli-* install, one line per check, the command that fixes a failing one beside it, and installs the packages it picks
1758
+
1759
+ Each @kaynooo/cli-* package installed globally with its bins on the PATH and no older than the registry's latest; tea logged in to git.kaynooo.fr and reaching it when ft is installed; the system tools the installed packages need (glab, docker); the Claude Code hooks wired through the cli-hooks plugin or the cli mod, not both, with guard installed; Bun at least the version the tools ask for; fish completions for every bin. A package not installed is no failure, the last line counts them.
1760
+ --fix runs the printed fix: lines it may (global installs of the packages, the completions, the hooks plugin), the installs in one call, then checks again; a line that installs a system package or only describes a step is printed as not run. --dry-run shows what --fix would run.
1761
+ --install adds packages: the names given (a package such as forgejo, one of its bins such as ft, hooks for the Claude Code plugin, or all), else a checklist on a terminal. Each is checked against what it needs first: a missing required tool (tea for ft, glab for gl, docker for dc) leaves that package out unless --force, and hooks brings guard along. doctor never installs a system package. Then it checks and fixes what it installed. --list prints the choices, --dry-run what would be installed.
1762
+ --quiet prints only the failing checks, nothing when all pass. --only limits the run to the checks named, e.g. --only tea --only bun.`;
1763
+ var EXITS = [[0, "every check passes (after --fix, the ones it fixed too)"], [1, "a check fails"], [2, "a usage error"]];
1764
+ var INSTALL2 = "bun install -g ";
1765
+ var OPTIONS = {
1766
+ json: jsonOption("[{check, ok, detail, fix?}], as the checks stand after --fix; the fixes it ran go to stderr"),
1767
+ only: { type: "string", multiple: true, choices: CHECK_NAMES, description: "run only this check (a package such as cli-forgejo, tea, hooks, bun or completions), repeatable" },
1768
+ quiet: { type: "boolean", short: "q", description: "print only the failing checks" },
1769
+ fix: { type: "boolean", description: "run the fix: lines doctor may run, then check again" },
1770
+ "dry-run": { type: "boolean", description: "with --fix or --install, show what would run without running it (alone, it implies --fix)" },
1771
+ install: { type: "boolean", description: "install the packages named, or picked from a checklist: forgejo, ft, hooks, all" },
1772
+ list: { type: "boolean", description: "with --install, print the choices" },
1773
+ force: { type: "boolean", description: "with --install, install a package even though a tool it requires is missing" }
1774
+ };
1775
+ function print(results) {
1776
+ if (results.length === 0)
1777
+ return;
1778
+ const { green, red, dim } = paint(process14.stdout);
1779
+ const width = Math.max(...results.map((result) => result.check.length));
1780
+ for (const { check, ok, detail, fix } of results)
1781
+ console.log(`${ok ? green("ok ") : red("FAIL")} ${check.padEnd(width)} ${detail}${fix === undefined ? "" : ` ${dim("fix:")} ${fix}`}`);
1782
+ const installs = results.flatMap((result) => result.fix?.startsWith(INSTALL2) === true ? [result.fix.slice(INSTALL2.length)] : []);
1783
+ if (installs.length > 1)
1784
+ console.log(`
1785
+ ${dim("fix them all:")} ${INSTALL2}${installs.join(" ")}`);
1786
+ }
1787
+ function printOutcomes(outcomes, log) {
1788
+ const { green, red, dim } = paint(process14.stdout);
1789
+ const marks = { ran: green("ran "), failed: red("failed "), "dry-run": dim("would "), "not run": dim("not run") };
1790
+ for (const { command, status, detail } of outcomes)
1791
+ log(`${marks[status]} ${command}${detail === undefined ? "" : ` ${dim(detail)}`}`);
1792
+ }
1793
+ async function main(args, host = hostHere()) {
1794
+ const { values, positionals } = parse(args, USAGE, OPTIONS, { positionals: ["[name...]"], exits: EXITS, replaces: "checking each bin's version, tea's login and the hooks by hand", examples: ["doctor", ["doctor --json", "the checks for a script"], ["doctor -q", "silent unless something fails"], ["doctor --fix --dry-run", "what --fix would run"], ["doctor --install", "pick the packages to add"], ["doctor --install forgejo check hooks", "three named ones"], ["doctor --install all --dry-run", "what installing everything would run"]] });
1795
+ const only = values.only ?? [];
1796
+ if (values.install !== true && (positionals.length > 0 || values.list === true || values.force === true))
1797
+ badUsage("names, --list and --force go with --install");
1798
+ if (values.install === true) {
1799
+ if (values.fix === true || only.length > 0 || values.json === true || values.quiet === true)
1800
+ badUsage("--install does not combine with --fix, --only, --json or --quiet");
1801
+ return install(host, { names: positionals, list: values.list === true, dryRun: values["dry-run"] === true, force: values.force === true }, async (checks) => main(["--fix", ...checks.flatMap((check) => ["--only", check])], host));
1802
+ }
1803
+ const dry = values["dry-run"] === true;
1804
+ const json = values.json === true;
1805
+ let results = await runChecks(host, only);
1806
+ let fixed = false;
1807
+ if ((values.fix === true || dry) && results.some((result) => !result.ok)) {
1808
+ if (!json)
1809
+ print(values.quiet === true ? results.filter((result) => !result.ok) : results);
1810
+ const steps = planFixes(results);
1811
+ printOutcomes(await applyFixes(host, steps, dry), json ? console.error : console.log);
1812
+ if (dry)
1813
+ return 1;
1814
+ results = await runChecks(host, only);
1815
+ fixed = true;
1816
+ }
1817
+ const shown = values.quiet === true ? results.filter((result) => !result.ok) : results;
1818
+ if (fixed && !json && shown.length > 0)
1819
+ console.log(`
1820
+ after the fixes:`);
1821
+ if (json)
1822
+ printJson(shown);
1823
+ else
1824
+ print(shown);
1825
+ const absent = Object.keys(TOOLS).filter((name) => !isInstalled(host, name)).length;
1826
+ if (!json && only.length === 0 && absent > 0)
1827
+ console.log(`
1828
+ ${paint(process14.stdout).dim(`${absent} packages not installed: doctor --install`)}`);
1829
+ return results.every((result) => result.ok) ? 0 : 1;
1830
+ }
1831
+
1380
1832
  // src/list.ts
1381
1833
  var USAGE_WORD = /^Usage:\s*/;
1382
1834
  async function describeBin(host, bin, name) {
@@ -1401,8 +1853,8 @@ Exits 0 when it printed the list, 2 on a usage error.`;
1401
1853
  var LIST_OPTIONS = {
1402
1854
  json: jsonOption("[{bin, package, installed, synopsis?, summary?, replaces?, examples?}]")
1403
1855
  };
1404
- function print(listed) {
1405
- const { cyan, dim } = paint(process12.stdout);
1856
+ function print2(listed) {
1857
+ const { cyan, dim } = paint(process15.stdout);
1406
1858
  const width = Math.max(...listed.map((each) => each.bin.length));
1407
1859
  for (const each of listed) {
1408
1860
  const what = !each.installed ? dim(`not installed: bun install -g ${each.package}`) : `${each.summary ?? ""}${each.replaces === undefined ? "" : dim(`; instead of ${each.replaces}`)}`;
@@ -1415,7 +1867,7 @@ async function list(args, host = hostHere()) {
1415
1867
  if (values.json === true)
1416
1868
  printJson(listed);
1417
1869
  else
1418
- print(listed);
1870
+ print2(listed);
1419
1871
  return 0;
1420
1872
  }
1421
1873
  var COMPLETIONS_USAGE = `cli completions <fish|bash> [--install] [--dir <folder>]: shell completions for every installed bin of the @kaynooo/cli-* packages
@@ -1485,12 +1937,13 @@ ${table(summary.usageErrors.map((each) => [`${each.bin}${each.command === undefi
1485
1937
  return 0;
1486
1938
  }
1487
1939
  if (import.meta.main) {
1488
- process12.exitCode = await runCli({
1940
+ process15.exitCode = await runCli({
1489
1941
  name: "cli",
1490
1942
  about: "the bins of the @kaynooo/cli-* packages, described by their own help",
1491
1943
  replaces: "running each bin's --help to find the one that fits",
1492
1944
  examples: ["cli list", ["cli list --json", "with each bin's synopsis and examples"]],
1493
1945
  commands: {
1946
+ doctor: { summary: "checks this host's install of the bins, one line per check, the command that fixes a failing one beside it", run: async (args) => main(args) },
1494
1947
  list: { summary: "every bin in one line: what it does and what it is reached for instead of", run: async (args) => list(args) },
1495
1948
  stats: { summary: "how often each bin ran and the options usage errors did not know", run: async (args) => stats(args) },
1496
1949
  completions: { summary: "fish completions for every installed bin, printed or written to the folder fish reads", run: async (args) => completions(args) }
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // @bun
3
3
 
4
4
  // src/cli.ts
5
- import process12 from "process";
5
+ import process15 from "process";
6
6
  // ../kit/src/args.ts
7
7
  import process5 from "process";
8
8
  import { parseArgs } from "util";
@@ -274,6 +274,11 @@ function le(i = {}) {
274
274
  }
275
275
  var t = le();
276
276
 
277
+ // ../kit/src/theme.ts
278
+ var STYLES = new Set(["bold", "dim", "italic", "underline", "inverse", "strikethrough", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "gray", "grey", "blackBright", "redBright", "greenBright", "yellowBright", "blueBright", "magentaBright", "cyanBright", "whiteBright"]);
279
+ var MAX_FILE_BYTES = 64 * 1024;
280
+ var warned = new Set;
281
+
277
282
  // ../kit/src/style.ts
278
283
  function takesControlCodes(stream, env = process2.env) {
279
284
  return stream.isTTY === true && env.NO_COLOR === undefined && env.NODE_DISABLE_COLORS === undefined && env.TERM !== "dumb";
@@ -281,14 +286,14 @@ function takesControlCodes(stream, env = process2.env) {
281
286
  function paint(stream, env = process2.env) {
282
287
  return le({ stream: { isTTY: takesControlCodes(stream, env) }, env });
283
288
  }
284
- var STATUSES = {
285
- passed: { symbol: "\u2713", words: ["passed", "success", "succeeded", "ok"], paint: (colors) => colors.green },
286
- failed: { symbol: "\u2717", words: ["failed", "failure", "error", "errored"], paint: (colors) => colors.red },
287
- running: { symbol: "\u25CF", words: ["running", "in_progress", "started"], paint: (colors) => colors.cyan },
288
- queued: { symbol: "\u25CB", words: ["queued", "waiting", "pending", "created", "blocked", "scheduled"], paint: (colors) => colors.yellow },
289
- skipped: { symbol: "-", words: ["skipped", "cancelled", "canceled", "manual"], paint: (colors) => colors.dim }
289
+ var STATUS_WORDS = {
290
+ passed: ["passed", "success", "succeeded", "ok"],
291
+ failed: ["failed", "failure", "error", "errored"],
292
+ running: ["running", "in_progress", "started"],
293
+ queued: ["queued", "waiting", "pending", "created", "blocked", "scheduled"],
294
+ skipped: ["skipped", "cancelled", "canceled", "manual"]
290
295
  };
291
- var STATUS_OF = new Map(Object.entries(STATUSES).flatMap(([status, { words }]) => words.map((word) => [word, status])));
296
+ var STATUS_OF = new Map(Object.entries(STATUS_WORDS).flatMap(([status, words]) => words.map((word) => [word, status])));
292
297
 
293
298
  // ../kit/src/format.ts
294
299
  function upTo(text, separator) {
@@ -303,7 +308,7 @@ var ESCAPE = String.fromCharCode(27);
303
308
  function textWidth(text) {
304
309
  return Bun.stringWidth(text);
305
310
  }
306
- function table(rows, { cellWidth, header = false, align = [], fit } = {}) {
311
+ function table(rows, { cellWidth, header = false, align = [], fit, stream = process3.stdout } = {}) {
307
312
  const cells = cellWidth === undefined ? rows : rows.map((row) => row.map((cell) => truncate(cell.replaceAll(LINE_BREAKS, " "), cellWidth)));
308
313
  const widths = [];
309
314
  for (const row of cells) {
@@ -311,7 +316,7 @@ function table(rows, { cellWidth, header = false, align = [], fit } = {}) {
311
316
  widths[index] = Math.max(widths[index] ?? 0, textWidth(cell));
312
317
  }
313
318
  const shown = fit === undefined ? cells : narrow(cells, widths, fit);
314
- const colors = paint(process3.stdout);
319
+ const colors = paint(stream);
315
320
  const lines = shown.map((row) => row.map((cell, index) => {
316
321
  const width = widths[index] ?? 0;
317
322
  const gap = " ".repeat(Math.max(0, width - textWidth(cell)));
@@ -319,7 +324,8 @@ function table(rows, { cellWidth, header = false, align = [], fit } = {}) {
319
324
  return `${gap}${cell}`;
320
325
  return index === row.length - 1 ? cell : `${cell}${gap}`;
321
326
  }).join(" ").trimEnd());
322
- return lines.map((line, index) => header && index === 0 ? colors.bold(line) : line).join(`
327
+ const rule = header && takesControlCodes(stream) && lines.length > 1 ? [colors.dim("\u2500".repeat(Math.max(...lines.map(textWidth))))] : [];
328
+ return lines.flatMap((line, index) => header && index === 0 ? [colors.bold(line), ...rule] : [line]).join(`
323
329
  `);
324
330
  }
325
331
  function narrow(rows, widths, limit) {
@@ -446,6 +452,10 @@ class Exit extends Error {
446
452
  this.hint = hint;
447
453
  }
448
454
  }
455
+ function fail(message, code = 2, hint) {
456
+ throw new Exit(message, code, hint);
457
+ }
458
+
449
459
  class UsageError extends Exit {
450
460
  constructor(message, hint) {
451
461
  super(message, 2, hint);
@@ -1151,10 +1161,46 @@ var SECRET_WORDS = new Set(["key", "pass", "pwd", "auth", "salt", "cookie", "ses
1151
1161
  var {Glob } = globalThis.Bun;
1152
1162
  var SESSION_FILES = new Glob("*.json");
1153
1163
  var ACCOUNT_DIRS = new Glob("*/sessions");
1164
+ // src/doctor.ts
1165
+ import process14 from "process";
1166
+
1154
1167
  // src/checks.ts
1155
1168
  import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
1156
1169
  import { basename, join as join4 } from "path";
1157
1170
 
1171
+ // src/catalog.ts
1172
+ var HOOKS_REQUIRES = "@kaynooo/cli-guard";
1173
+ var ABOUT = {
1174
+ "@kaynooo/cli-base": "run a command at a ref in a throwaway worktree, and prove a test fails before a change",
1175
+ "@kaynooo/cli-changelog": "release notes from conventional commits, published as a Forgejo release",
1176
+ "@kaynooo/cli-check": "the repo's check, lint and test steps, its fixers, error lines from logs and a recipe picker",
1177
+ "@kaynooo/cli-code": "source read by spans, TODO comments and the files worth splitting",
1178
+ "@kaynooo/cli-db": "read-only SQL against Postgres, MySQL and MariaDB, credentials never shown",
1179
+ "@kaynooo/cli-dev": "passwords, JWTs, timestamps, UUIDs, base64 and file hashes",
1180
+ "@kaynooo/cli-docker": "docker compose on the stack found here, service health, shells and Symfony commands",
1181
+ "@kaynooo/cli-doctor": "this install check, and the list of every bin",
1182
+ "@kaynooo/cli-edit": "replace text in files, writing nothing unless every match count is right",
1183
+ "@kaynooo/cli-env": ".env files listed and edited with secrets masked, and a secrets filter for pipes",
1184
+ "@kaynooo/cli-forgejo": "Forgejo CI runs, logs, releases and issue checklists",
1185
+ "@kaynooo/cli-git": "repository state in one line, commits of exact paths, a branch picker",
1186
+ "@kaynooo/cli-gitlab": "GitLab pipelines and their failed tests",
1187
+ "@kaynooo/cli-guard": "the safety rules a Claude Code Bash call is held to",
1188
+ "@kaynooo/cli-out": "shortens a noisy command's output, keeping the errors",
1189
+ "@kaynooo/cli-pick": "a fuzzy selector for pipes",
1190
+ "@kaynooo/cli-pkg": "vets npm packages before they are added, and bumps a lib in its consumers",
1191
+ "@kaynooo/cli-probe": "a site's HTTP version, redirects, timings and TLS",
1192
+ "@kaynooo/cli-proc": "what a process is, who listens on a port, leftover dev processes",
1193
+ "@kaynooo/cli-stack": "a stable port per service name, and a compose release audit",
1194
+ "@kaynooo/cli-view": "markdown, source and git diffs in the colours of the other bins",
1195
+ "@kaynooo/cli-wait": "wait for a line, a port, a URL or a process; a machine-wide slot for heavy jobs",
1196
+ "@kaynooo/cli-workspace": "the repositories under a root needing attention, reclaimable folders and known vulnerabilities"
1197
+ };
1198
+ var NEEDS = {
1199
+ "@kaynooo/cli-forgejo": [{ bin: "tea", why: "ft goes through it", how: "https://gitea.com/gitea/tea/releases" }],
1200
+ "@kaynooo/cli-gitlab": [{ bin: "glab", why: "gl goes through it", how: "https://gitlab.com/gitlab-org/cli" }],
1201
+ "@kaynooo/cli-docker": [{ bin: "docker", why: "dc, dlog, dx and sf run docker compose", how: "https://docs.docker.com/engine/install/" }]
1202
+ };
1203
+
1158
1204
  // src/completions.ts
1159
1205
  import { existsSync, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, rmSync } from "fs";
1160
1206
  import { join as join3 } from "path";
@@ -1219,11 +1265,10 @@ function missingCompletions(host, dir) {
1219
1265
 
1220
1266
  // src/checks.ts
1221
1267
  var TOOLS = {
1222
- "@kaynooo/cli-audit": ["audit"],
1223
1268
  "@kaynooo/cli-base": ["base", "proof"],
1224
1269
  "@kaynooo/cli-changelog": ["changelog"],
1225
1270
  "@kaynooo/cli-check": ["check", "fix", "errs", "j"],
1226
- "@kaynooo/cli-code": ["peek", "todo-scan", "hotspots"],
1271
+ "@kaynooo/cli-code": ["src", "peek", "todo-scan", "hotspots"],
1227
1272
  "@kaynooo/cli-db": ["db"],
1228
1273
  "@kaynooo/cli-docker": ["dc", "dlog", "dx", "sf"],
1229
1274
  "@kaynooo/cli-doctor": ["doctor", "cli"],
@@ -1238,12 +1283,11 @@ var TOOLS = {
1238
1283
  "@kaynooo/cli-pick": ["pick"],
1239
1284
  "@kaynooo/cli-pkg": ["pkg"],
1240
1285
  "@kaynooo/cli-probe": ["probe"],
1241
- "@kaynooo/cli-proc": ["pid", "ports", "procs"],
1242
- "@kaynooo/cli-repos": ["repos"],
1286
+ "@kaynooo/cli-proc": ["proc", "pid", "ports", "procs"],
1243
1287
  "@kaynooo/cli-stack": ["stack"],
1244
- "@kaynooo/cli-sweep": ["sweep"],
1245
1288
  "@kaynooo/cli-view": ["view"],
1246
- "@kaynooo/cli-wait": ["waitfor", "slot"]
1289
+ "@kaynooo/cli-wait": ["waitfor", "slot"],
1290
+ "@kaynooo/cli-workspace": ["ws", "repos", "sweep", "audit"]
1247
1291
  };
1248
1292
  var FORGE = "git.kaynooo.fr";
1249
1293
  var FORGE_HOSTS = new Set([FORGE, "git.kevyn.fr"]);
@@ -1271,6 +1315,9 @@ function firstLine(text) {
1271
1315
  function installed(host, name) {
1272
1316
  return readJson(join4(host.globalModules, name, "package.json"));
1273
1317
  }
1318
+ function isInstalled(host, name) {
1319
+ return installed(host, name)?.version !== undefined;
1320
+ }
1274
1321
  function ownBin(host, name, bin) {
1275
1322
  const declared = installed(host, name)?.bin;
1276
1323
  const file = typeof declared === "string" ? declared : declared?.[bin];
@@ -1309,10 +1356,16 @@ async function checkTea(host) {
1309
1356
  return { check: "tea", ok: false, detail: `${forge} did not answer: ${firstLine(version.stderr || version.stdout)}`, fix: `tea login edit ${login.name} --token <token>, or check the network` };
1310
1357
  return { check: "tea", ok: true, detail: `logged in to ${forge} as ${login.name}, Forgejo ${served}` };
1311
1358
  }
1312
- function checkHooks(host) {
1359
+ function hooksWiring(host) {
1313
1360
  const settings = readJson(join4(host.home, ".claude", "settings.json")) ?? {};
1314
1361
  const plugin = settings.enabledPlugins?.[PLUGIN] === true;
1315
1362
  const mod = (settings.env?.CLAUDE_CODE_PLUGIN_DIRS ?? "").split(":").map((dir) => dir.replace(LEADING_TILDE, host.home)).find((dir) => basename(dir) === "cli" && existsSync2(join4(dir, "hooks", "hooks.json")));
1363
+ return { plugin, mod };
1364
+ }
1365
+ function checkHooks(host) {
1366
+ const { plugin, mod } = hooksWiring(host);
1367
+ if ((plugin || mod !== undefined) && !isInstalled(host, HOOKS_REQUIRES))
1368
+ return { check: "hooks", ok: false, detail: `wired, but ${HOOKS_REQUIRES} is not installed, so every hook finds no guard`, fix: `bun install -g ${HOOKS_REQUIRES}` };
1316
1369
  if (plugin && mod !== undefined)
1317
1370
  return { check: "hooks", ok: false, detail: `both the ${PLUGIN} plugin and the mod in ${mod} run guard, so every call is checked twice`, fix: `claude plugin disable ${PLUGIN}` };
1318
1371
  if (plugin)
@@ -1337,19 +1390,29 @@ function checkCompletions(host) {
1337
1390
  return { check: "completions", ok: false, detail: `no fish completion in ${dir} for ${missing.join(", ")}`, fix: "cli completions fish --install" };
1338
1391
  return { check: "completions", ok: true, detail: dir };
1339
1392
  }
1393
+ function checkNeeds(host) {
1394
+ const missing = Object.entries(NEEDS).flatMap(([name, needs]) => isInstalled(host, name) ? needs.filter((need) => need.bin !== "tea" && host.which(need.bin) === null) : []);
1395
+ if (missing.length > 0)
1396
+ return { check: "needs", ok: false, detail: `missing ${missing.map((need) => `${need.bin} (${need.why})`).join(", ")}`, fix: `install ${missing.map((need) => `${need.bin}: ${need.how}`).join("; ")}` };
1397
+ return { check: "needs", ok: true, detail: "every tool the installed packages need is on the PATH" };
1398
+ }
1340
1399
  var toolCheck = (name) => name.slice(name.indexOf("/") + 1);
1341
- var OTHER_CHECKS = ["tea", "hooks", "bun", "completions"];
1400
+ var OTHER_CHECKS = ["tea", "hooks", "needs", "bun", "completions"];
1401
+ var FORGEJO = "@kaynooo/cli-forgejo";
1342
1402
  var CHECK_NAMES = [...Object.keys(TOOLS).map(toolCheck), ...OTHER_CHECKS];
1343
1403
  async function runChecks(host, only = []) {
1344
1404
  const wanted = (check) => only.length === 0 || only.includes(check);
1345
- const entries = Object.entries(TOOLS).filter(([name]) => wanted(toolCheck(name)));
1405
+ const entries = Object.entries(TOOLS).filter(([name]) => only.length === 0 ? isInstalled(host, name) : wanted(toolCheck(name)));
1406
+ const { plugin, mod } = hooksWiring(host);
1407
+ const hooks = only.length > 0 || plugin || mod !== undefined || isInstalled(host, HOOKS_REQUIRES);
1346
1408
  const tools = [];
1347
1409
  for (let at = 0;at < entries.length; at += INFO_AT_ONCE)
1348
1410
  tools.push(...await Promise.all(entries.slice(at, at + INFO_AT_ONCE).map(async ([name, bins]) => checkTool(host, name, bins))));
1349
1411
  return [
1350
1412
  ...tools,
1351
- ...wanted("tea") ? [await checkTea(host)] : [],
1352
- ...wanted("hooks") ? [checkHooks(host)] : [],
1413
+ ...wanted("tea") && (only.length > 0 || isInstalled(host, FORGEJO)) ? [await checkTea(host)] : [],
1414
+ ...wanted("hooks") && hooks ? [checkHooks(host)] : [],
1415
+ ...wanted("needs") ? [checkNeeds(host)] : [],
1353
1416
  ...wanted("bun") ? [checkBun(host)] : [],
1354
1417
  ...wanted("completions") ? [checkCompletions(host)] : []
1355
1418
  ];
@@ -1414,11 +1477,286 @@ function hostHere() {
1414
1477
  };
1415
1478
  }
1416
1479
 
1417
- // src/cli.ts
1418
- var USAGE = `doctor [--only <check>] [--quiet] [--fix [--dry-run]] [--json]: checks this host's @kaynooo/cli-* install, one line per check, the command that fixes a failing one beside it
1480
+ // src/install.ts
1481
+ import process13 from "process";
1419
1482
 
1420
- Each @kaynooo/cli-* package installed globally with its bins on the PATH and no older than the registry's latest; tea logged in to git.kaynooo.fr and reaching it; the Claude Code hooks wired through the cli-hooks plugin or the cli mod, not both; Bun at least the version the tools ask for; fish completions for every bin.
1483
+ // ../kit/src/prompt.ts
1484
+ import process12 from "process";
1485
+ var KEY_SEQUENCES = /\x1B\[[0-9;]*[A-Z~]|\x1BO[A-Z]|[\s\S]/giu;
1486
+ var CONTROL = /^[\x00-\x1F\x7F]/;
1487
+ var WORDS = /\s+/;
1488
+ var MAX_ROWS = 10;
1489
+ var KEYS = { "\x1B[A": "up", "\x1BOA": "up", "\x1B[B": "down", "\x1BOB": "down", "\r": "enter", "\n": "enter", "\x03": "cancel", "\x1B": "cancel", "\x7F": "erase", "\b": "erase" };
1490
+ function readKeys(input) {
1491
+ return (input.match(KEY_SEQUENCES) ?? []).flatMap((sequence) => KEYS[sequence] ?? (CONTROL.test(sequence) ? [] : [{ text: sequence }]));
1492
+ }
1493
+ function flip(checked, value) {
1494
+ const next = new Set(checked);
1495
+ if (!next.delete(value))
1496
+ next.add(value);
1497
+ return next;
1498
+ }
1499
+ async function checklist(question, choices, { initial = new Set, min = 0, toggle = flip, ...settings } = {}) {
1500
+ const list = new List(choices, 0);
1501
+ let checked = initial;
1502
+ let invalid;
1503
+ const accepted = await ask(question, settings, (colors, state, rows) => state === "asking" ? [asking(colors, question, list.query, "type to filter, space toggles, enter accepts"), ...list.rows(colors, rows, (choice) => checked.has(choice.value) ? `${colors.cyan("\u25C9")} ` : `${colors.dim("\u25EF")} `), ...invalid === undefined ? [] : [` ${colors.red(invalid)}`]] : [answer(colors, question, state, choices.filter((choice) => checked.has(choice.value)).map((choice) => choice.label).join(", ") || "none")], (key) => {
1504
+ if (key === "cancel")
1505
+ return key;
1506
+ invalid = undefined;
1507
+ if (key === "enter") {
1508
+ invalid = checked.size < min ? `pick at least ${min}` : undefined;
1509
+ return invalid === undefined ? "enter" : undefined;
1510
+ }
1511
+ const current = list.current();
1512
+ if (typeof key === "object" && key.text === " ") {
1513
+ if (current !== undefined)
1514
+ checked = toggle(checked, current.value);
1515
+ return;
1516
+ }
1517
+ list.press(key);
1518
+ return;
1519
+ });
1520
+ return accepted ? checked : undefined;
1521
+ }
1522
+ class List {
1523
+ choices;
1524
+ query = "";
1525
+ matches;
1526
+ cursor;
1527
+ top = 0;
1528
+ width;
1529
+ constructor(choices, initial) {
1530
+ this.choices = choices;
1531
+ this.matches = choices;
1532
+ this.cursor = Math.min(Math.max(initial, 0), choices.length - 1);
1533
+ this.width = Math.max(0, ...choices.map((choice) => choice.label.length));
1534
+ }
1535
+ current() {
1536
+ return this.matches[this.cursor];
1537
+ }
1538
+ press(key) {
1539
+ if (key === "up" || key === "down") {
1540
+ if (this.matches.length > 0)
1541
+ this.cursor = (this.cursor + (key === "up" ? this.matches.length - 1 : 1)) % this.matches.length;
1542
+ return;
1543
+ }
1544
+ if (key === "erase")
1545
+ this.filter([...this.query].slice(0, -1).join(""));
1546
+ else if (typeof key === "object")
1547
+ this.filter(this.query + key.text);
1548
+ }
1549
+ filter(query) {
1550
+ const words = query.toLowerCase().split(WORDS).filter((word) => word !== "");
1551
+ const current = this.current();
1552
+ this.query = query;
1553
+ this.matches = this.choices.filter(({ label, hint }) => words.every((word) => `${label} ${hint}`.toLowerCase().includes(word)));
1554
+ this.cursor = current === undefined ? 0 : Math.max(this.matches.indexOf(current), 0);
1555
+ this.top = 0;
1556
+ }
1557
+ rows(colors, { columns, rows }, marker) {
1558
+ if (this.matches.length === 0)
1559
+ return [colors.dim(" no match")];
1560
+ const height = Math.max(1, Math.min(MAX_ROWS, rows - 3, this.matches.length));
1561
+ this.top = Math.min(Math.max(this.top, this.cursor - height + 1), this.cursor);
1562
+ const shown = this.matches.slice(this.top, this.top + height).map((choice, index) => {
1563
+ const here = this.top + index === this.cursor;
1564
+ const mark = marker(choice);
1565
+ const head = `${here ? "\u276F" : " "} ${mark}`;
1566
+ const label = choice.label.padEnd(this.width);
1567
+ const room = columns - 5 - (mark === "" ? 0 : 2) - this.width;
1568
+ return `${here ? colors.cyan(head) : head}${here ? colors.cyan(label) : label} ${colors.dim(truncate(choice.hint, Math.max(room, 0)))}`;
1569
+ });
1570
+ const below = this.matches.length - this.top - height;
1571
+ return [...this.top > 0 ? [colors.dim(` \u2026 ${this.top} more`)] : [], ...shown, ...below > 0 ? [colors.dim(` \u2026 ${below} more`)] : []];
1572
+ }
1573
+ }
1574
+ function asking(colors, question, typed, keys) {
1575
+ return `${colors.cyan("?")} ${question} ${typed === "" ? colors.dim(`(${keys})`) : typed}`;
1576
+ }
1577
+ function answer(colors, question, state, picked) {
1578
+ return state === "enter" ? `${colors.green("\u2713")} ${question} ${colors.cyan(picked)}` : `${colors.red("\u2717")} ${question} ${colors.dim("cancelled")}`;
1579
+ }
1580
+ async function ask(question, { hint, terminal = process12 }, render, press) {
1581
+ const { stdin, stderr } = terminal;
1582
+ if (stdin.isTTY !== true || stderr.isTTY !== true)
1583
+ fail(`cannot ask "${question}" off a terminal`, 2, hint);
1584
+ const colors = paint(stderr);
1585
+ let drawn = 0;
1586
+ let outcome;
1587
+ const draw = (state) => {
1588
+ const frame = render(colors, state, { columns: stderr.columns || 80, rows: stderr.rows || 24 });
1589
+ stderr.write(`${drawn > 1 ? `\x1B[${drawn - 1}A` : ""}\r\x1B[0J${frame.join(`
1590
+ `)}`);
1591
+ drawn = frame.length;
1592
+ };
1593
+ try {
1594
+ stdin.setRawMode(true);
1595
+ stdin.resume();
1596
+ stderr.write("\x1B[?25l");
1597
+ draw("asking");
1598
+ return await new Promise((resolve) => {
1599
+ function onData(data) {
1600
+ for (const key of readKeys(data.toString())) {
1601
+ const pressed = press(key);
1602
+ if (pressed !== undefined) {
1603
+ settle(pressed);
1604
+ return;
1605
+ }
1606
+ }
1607
+ draw("asking");
1608
+ }
1609
+ function onEnd() {
1610
+ settle("cancel");
1611
+ }
1612
+ function settle(settled) {
1613
+ outcome = settled;
1614
+ stdin.off("data", onData);
1615
+ stdin.off("end", onEnd);
1616
+ resolve(settled === "enter");
1617
+ }
1618
+ stdin.on("data", onData);
1619
+ stdin.on("end", onEnd);
1620
+ });
1621
+ } finally {
1622
+ draw(outcome ?? "cancel");
1623
+ stderr.write(`
1624
+ \x1B[?25h`);
1625
+ stdin.setRawMode(false);
1626
+ stdin.pause();
1627
+ }
1628
+ }
1629
+
1630
+ // src/install.ts
1631
+ var HOOKS = "hooks";
1632
+ var PLUGIN_INSTALL = ["claude", "plugin", "install", "cli-hooks", "--marketplace", `https://${FORGE}/kaynooo/cli.git`];
1633
+ var CLI_PREFIX = /^cli-/;
1634
+ var shortName = (name) => name.slice(name.indexOf("/") + 1).replace(CLI_PREFIX, "");
1635
+ function candidates2(host) {
1636
+ const packages = Object.entries(TOOLS).filter(([name]) => !isInstalled(host, name)).map(([name, bins]) => {
1637
+ const needs = (NEEDS[name] ?? []).map((need) => need.bin);
1638
+ return { id: name, names: [shortName(name), name, `cli-${shortName(name)}`, ...bins], label: shortName(name), hint: `${bins.join(" ")}: ${ABOUT[name] ?? ""}${needs.length === 0 ? "" : `, needs ${needs.join(", ")}`}`, packages: [name] };
1639
+ });
1640
+ const { plugin, mod } = hooksWiring(host);
1641
+ if (plugin || mod !== undefined)
1642
+ return packages;
1643
+ return [...packages, { id: HOOKS, names: [HOOKS, "plugin", "cli-hooks"], label: "hooks", hint: `the Claude Code plugin running guard on every tool call, needs ${HOOKS_REQUIRES}`, packages: isInstalled(host, HOOKS_REQUIRES) ? [] : [HOOKS_REQUIRES] }];
1644
+ }
1645
+ function choose(pool, names) {
1646
+ const chosen = new Set;
1647
+ for (const name of names) {
1648
+ if (name === "all") {
1649
+ for (const each of pool)
1650
+ chosen.add(each);
1651
+ continue;
1652
+ }
1653
+ const found = pool.find((each) => each.names.includes(name));
1654
+ if (found !== undefined)
1655
+ chosen.add(found);
1656
+ }
1657
+ return [...chosen];
1658
+ }
1659
+ function plan(host, picked, force) {
1660
+ const blocked = [];
1661
+ const warnings = [];
1662
+ const packages = [];
1663
+ for (const name of new Set(picked.flatMap((each) => each.packages))) {
1664
+ const lacking = (NEEDS[name] ?? []).filter((need) => host.which(need.bin) === null);
1665
+ if (lacking.length > 0 && !force) {
1666
+ for (const need of lacking)
1667
+ blocked.push({ package: shortName(name), need: need.bin, why: need.why, how: need.how });
1668
+ continue;
1669
+ }
1670
+ for (const need of lacking)
1671
+ warnings.push({ package: shortName(name), need: need.bin, why: need.why });
1672
+ packages.push(name);
1673
+ }
1674
+ const guardBlocked = blocked.some((each) => `@kaynooo/cli-${each.package}` === HOOKS_REQUIRES);
1675
+ return { packages, plugin: picked.some((each) => each.id === HOOKS) && !guardBlocked, blocked, warnings };
1676
+ }
1677
+ function checksFor(packages, plugin) {
1678
+ return [...packages.map((name) => name.slice(name.indexOf("/") + 1)), ...plugin ? ["hooks"] : [], "needs", "completions", ...packages.includes("@kaynooo/cli-forgejo") ? ["tea"] : []];
1679
+ }
1680
+ async function ask2(pool) {
1681
+ if (process13.stdin.isTTY !== true || process13.stderr.isTTY !== true)
1682
+ badUsage("name what to install, or all: doctor --install forgejo check hooks (doctor --install --list shows the choices)");
1683
+ const choices = pool.map((each) => ({ value: each, label: each.label, hint: each.hint }));
1684
+ const picked = await checklist("install which?", choices, { hint: "name them instead: doctor --install forgejo check" });
1685
+ return picked === undefined ? [] : [...picked];
1686
+ }
1687
+ async function install(host, options, recheck) {
1688
+ const pool = candidates2(host);
1689
+ const { dim, green, red, yellow } = paint(process13.stdout);
1690
+ if (options.list) {
1691
+ for (const each of pool)
1692
+ console.log(`${each.label.padEnd(12)} ${dim(each.hint)}`);
1693
+ if (pool.length === 0)
1694
+ console.log("everything is installed");
1695
+ return 0;
1696
+ }
1697
+ if (pool.length === 0) {
1698
+ console.log("everything is installed");
1699
+ return 0;
1700
+ }
1701
+ const unknown = options.names.filter((name) => name !== "all" && !Object.values(candidatesAll(host)).some((each) => each.names.includes(name)));
1702
+ if (unknown.length > 0)
1703
+ badUsage(`nothing to install is named ${unknown.join(", ")}: doctor --install --list`);
1704
+ const picked = options.names.length === 0 ? await ask2(pool) : choose(pool, options.names);
1705
+ const skipped = options.names.filter((name) => name !== "all" && !pool.some((each) => each.names.includes(name)));
1706
+ for (const name of skipped)
1707
+ console.log(`${dim("already")} ${name} is installed`);
1708
+ if (picked.length === 0) {
1709
+ if (skipped.length === 0)
1710
+ console.log("nothing picked");
1711
+ return 0;
1712
+ }
1713
+ const planned = plan(host, picked, options.force);
1714
+ for (const each of planned.blocked)
1715
+ console.log(`${red("blocked")} ${each.package} needs ${each.need} (${each.why}): ${each.how}; --force installs it anyway`);
1716
+ for (const each of planned.warnings)
1717
+ console.log(`${yellow("warn ")} ${each.package}: ${each.need} is missing, ${each.why}`);
1718
+ if (planned.packages.length === 0 && !planned.plugin)
1719
+ return 1;
1720
+ const command = ["bun", "install", "-g", ...planned.packages];
1721
+ if (planned.packages.length > 0)
1722
+ console.log(`${options.dryRun ? dim("would ") : green("install")} ${command.join(" ")}`);
1723
+ if (planned.plugin)
1724
+ console.log(`${options.dryRun ? dim("would ") : green("install")} ${PLUGIN_INSTALL.join(" ")}`);
1725
+ if (options.dryRun)
1726
+ return planned.blocked.length > 0 ? 1 : 0;
1727
+ if (planned.packages.length > 0) {
1728
+ const ran = await host.run(command);
1729
+ if (ran.code !== 0) {
1730
+ console.error(`${red("failed")} bun install -g: exit ${ran.code}: ${firstLine(ran.stderr || ran.stdout)}`);
1731
+ return 1;
1732
+ }
1733
+ }
1734
+ if (planned.plugin) {
1735
+ if (host.which("claude") === null) {
1736
+ console.log(`${dim("not run")} claude is not on the PATH: ${PLUGIN_INSTALL.join(" ")}`);
1737
+ } else {
1738
+ const ran = await host.run(PLUGIN_INSTALL);
1739
+ if (ran.code !== 0)
1740
+ console.error(`${red("failed")} ${PLUGIN_INSTALL.join(" ")}: exit ${ran.code}: ${firstLine(ran.stderr || ran.stdout)}`);
1741
+ }
1742
+ }
1743
+ console.log(`
1744
+ after the install:`);
1745
+ const code = await recheck(checksFor(planned.packages, planned.plugin));
1746
+ return planned.blocked.length > 0 ? 1 : code;
1747
+ }
1748
+ function candidatesAll(host) {
1749
+ const open = candidates2(host);
1750
+ const done = Object.entries(TOOLS).filter(([name]) => isInstalled(host, name)).map(([name, bins]) => ({ id: name, names: [shortName(name), name, `cli-${shortName(name)}`, ...bins], label: shortName(name), hint: "", packages: [name] }));
1751
+ return [...open, ...done];
1752
+ }
1753
+
1754
+ // src/doctor.ts
1755
+ var USAGE = `doctor [--only <check>] [--quiet] [--fix | --install [<name>...|all]] [--list] [--dry-run] [--force] [--json]: checks this host's @kaynooo/cli-* install, one line per check, the command that fixes a failing one beside it, and installs the packages it picks
1756
+
1757
+ Each @kaynooo/cli-* package installed globally with its bins on the PATH and no older than the registry's latest; tea logged in to git.kaynooo.fr and reaching it when ft is installed; the system tools the installed packages need (glab, docker); the Claude Code hooks wired through the cli-hooks plugin or the cli mod, not both, with guard installed; Bun at least the version the tools ask for; fish completions for every bin. A package not installed is no failure, the last line counts them.
1421
1758
  --fix runs the printed fix: lines it may (global installs of the packages, the completions, the hooks plugin), the installs in one call, then checks again; a line that installs a system package or only describes a step is printed as not run. --dry-run shows what --fix would run.
1759
+ --install adds packages: the names given (a package such as forgejo, one of its bins such as ft, hooks for the Claude Code plugin, or all), else a checklist on a terminal. Each is checked against what it needs first: a missing required tool (tea for ft, glab for gl, docker for dc) leaves that package out unless --force, and hooks brings guard along. doctor never installs a system package. Then it checks and fixes what it installed. --list prints the choices, --dry-run what would be installed.
1422
1760
  --quiet prints only the failing checks, nothing when all pass. --only limits the run to the checks named, e.g. --only tea --only bun.`;
1423
1761
  var EXITS = [[0, "every check passes (after --fix, the ones it fixed too)"], [1, "a check fails"], [2, "a usage error"]];
1424
1762
  var INSTALL2 = "bun install -g ";
@@ -1427,12 +1765,15 @@ var OPTIONS = {
1427
1765
  only: { type: "string", multiple: true, choices: CHECK_NAMES, description: "run only this check (a package such as cli-forgejo, tea, hooks, bun or completions), repeatable" },
1428
1766
  quiet: { type: "boolean", short: "q", description: "print only the failing checks" },
1429
1767
  fix: { type: "boolean", description: "run the fix: lines doctor may run, then check again" },
1430
- "dry-run": { type: "boolean", description: "with --fix, show what would run without running it (alone, it implies --fix)" }
1768
+ "dry-run": { type: "boolean", description: "with --fix or --install, show what would run without running it (alone, it implies --fix)" },
1769
+ install: { type: "boolean", description: "install the packages named, or picked from a checklist: forgejo, ft, hooks, all" },
1770
+ list: { type: "boolean", description: "with --install, print the choices" },
1771
+ force: { type: "boolean", description: "with --install, install a package even though a tool it requires is missing" }
1431
1772
  };
1432
1773
  function print(results) {
1433
1774
  if (results.length === 0)
1434
1775
  return;
1435
- const { green, red, dim } = paint(process12.stdout);
1776
+ const { green, red, dim } = paint(process14.stdout);
1436
1777
  const width = Math.max(...results.map((result) => result.check.length));
1437
1778
  for (const { check, ok, detail, fix } of results)
1438
1779
  console.log(`${ok ? green("ok ") : red("FAIL")} ${check.padEnd(width)} ${detail}${fix === undefined ? "" : ` ${dim("fix:")} ${fix}`}`);
@@ -1442,14 +1783,21 @@ function print(results) {
1442
1783
  ${dim("fix them all:")} ${INSTALL2}${installs.join(" ")}`);
1443
1784
  }
1444
1785
  function printOutcomes(outcomes, log) {
1445
- const { green, red, dim } = paint(process12.stdout);
1786
+ const { green, red, dim } = paint(process14.stdout);
1446
1787
  const marks = { ran: green("ran "), failed: red("failed "), "dry-run": dim("would "), "not run": dim("not run") };
1447
1788
  for (const { command, status, detail } of outcomes)
1448
1789
  log(`${marks[status]} ${command}${detail === undefined ? "" : ` ${dim(detail)}`}`);
1449
1790
  }
1450
1791
  async function main(args, host = hostHere()) {
1451
- const { values } = parse(args, USAGE, OPTIONS, { positionals: [], exits: EXITS, replaces: "checking each bin's version, tea's login and the hooks by hand", examples: ["doctor", ["doctor --json", "the checks for a script"], ["doctor -q", "silent unless something fails"], ["doctor --fix --dry-run", "what --fix would run"]] });
1792
+ const { values, positionals } = parse(args, USAGE, OPTIONS, { positionals: ["[name...]"], exits: EXITS, replaces: "checking each bin's version, tea's login and the hooks by hand", examples: ["doctor", ["doctor --json", "the checks for a script"], ["doctor -q", "silent unless something fails"], ["doctor --fix --dry-run", "what --fix would run"], ["doctor --install", "pick the packages to add"], ["doctor --install forgejo check hooks", "three named ones"], ["doctor --install all --dry-run", "what installing everything would run"]] });
1452
1793
  const only = values.only ?? [];
1794
+ if (values.install !== true && (positionals.length > 0 || values.list === true || values.force === true))
1795
+ badUsage("names, --list and --force go with --install");
1796
+ if (values.install === true) {
1797
+ if (values.fix === true || only.length > 0 || values.json === true || values.quiet === true)
1798
+ badUsage("--install does not combine with --fix, --only, --json or --quiet");
1799
+ return install(host, { names: positionals, list: values.list === true, dryRun: values["dry-run"] === true, force: values.force === true }, async (checks) => main(["--fix", ...checks.flatMap((check) => ["--only", check])], host));
1800
+ }
1453
1801
  const dry = values["dry-run"] === true;
1454
1802
  const json = values.json === true;
1455
1803
  let results = await runChecks(host, only);
@@ -1472,10 +1820,12 @@ after the fixes:`);
1472
1820
  printJson(shown);
1473
1821
  else
1474
1822
  print(shown);
1823
+ const absent = Object.keys(TOOLS).filter((name) => !isInstalled(host, name)).length;
1824
+ if (!json && only.length === 0 && absent > 0)
1825
+ console.log(`
1826
+ ${paint(process14.stdout).dim(`${absent} packages not installed: doctor --install`)}`);
1475
1827
  return results.every((result) => result.ok) ? 0 : 1;
1476
1828
  }
1477
- if (import.meta.main)
1478
- process12.exitCode = await runMain({ name: "doctor", run: async (args) => main(args) });
1479
- export {
1480
- main
1481
- };
1829
+
1830
+ // src/cli.ts
1831
+ process15.exitCode = await runMain({ name: "doctor", run: async (args) => main(args) });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kaynooo/cli-doctor",
3
3
  "type": "module",
4
- "version": "0.6.0",
4
+ "version": "0.8.0",
5
5
  "description": "doctor: checks the @kaynooo/cli-* install of a host: each bin's version against the registry, tea's login to Forgejo, the Claude Code hooks and the Bun floor; cli list: every bin in one line, read from its own help.",
6
6
  "author": "Kaynooo <contact@kaynooo.fr>",
7
7
  "license": "MIT",
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "scripts": {
35
35
  "build": "$npm_execpath build src/cli.ts src/cli-bin.ts --target bun --outdir dist",
36
+ "cli": "$npm_execpath src/cli-bin.ts",
36
37
  "doctor": "$npm_execpath src/cli.ts"
37
38
  },
38
39
  "devDependencies": {