@kaynooo/guard-tools 0.3.0 → 0.4.1

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Kaynooo <kaynooo@kevyn.fr>
3
+ Copyright (c) 2026 Kaynooo <contact@kaynooo.fr>
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -13,7 +13,7 @@ guard check --json --cwd "$PWD" - < command.txt
13
13
  | Rule | Refuses | Lives in |
14
14
  | --- | --- | --- |
15
15
  | `remote` | `ssh`, `scp`, `sftp`, `mosh`, `rsync` to a host, `DOCKER_HOST=ssh://`, `docker -H ssh://` | `src/remote.ts` |
16
- | `git` | `git stash` (pointing at `base`), `git add -A`/`.`/`-u`, `git commit -a` in any flag cluster, `git push` the user did not ask for, `git push --tags` without `--atomic` | `src/git.ts` |
16
+ | `git` | `git stash` (pointing at `proof`, `base`, a commit first or `git worktree add`), `git add -A`/`.`/`-u`, `git commit -a` in any flag cluster, `git push` the user did not ask for, `git push --tags` without `--atomic` | `src/git.ts` |
17
17
  | `commit` | a commit message breaking the format: no Conventional Commit title, a trailer, a body over two lines, a bullet list, a hard wrap; `-m`, `-F <file>` and `-F -` with a heredoc are read; the refusal spells the rule out and points at `gc` | `src/commit.ts`, the validator in `@kaynooo/git-tools` |
18
18
  | `sql` | a SQL client, console or dump tool fed a write (INSERT, UPDATE, DELETE, DDL, migrations, fixtures) outside a test database | `@kaynooo/db-tools` (`src/clients.ts`, `src/guard.ts`) |
19
19
  | `env` | a command reading, writing or printing an env file or the whole environment; `guard path` refuses a file tool opening one | `@kaynooo/env-tools` (`src/guard.ts`) |
package/dist/cli.js CHANGED
@@ -298,6 +298,26 @@ function upTo(text, separator) {
298
298
  }
299
299
  var ESCAPE = String.fromCharCode(27);
300
300
 
301
+ // ../kit/src/text.ts
302
+ var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
303
+ function distance(a, b) {
304
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
305
+ for (const [i, left] of [...a].entries()) {
306
+ const current = [i + 1];
307
+ for (const [j, right] of [...b].entries())
308
+ current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (left === right ? 0 : 1)));
309
+ previous = current;
310
+ }
311
+ return previous[b.length] ?? 0;
312
+ }
313
+ function closest(name, names) {
314
+ const started = names.find((known) => known.startsWith(name));
315
+ if (started !== undefined)
316
+ return started;
317
+ const [best] = names.map((known) => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far);
318
+ return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
319
+ }
320
+
301
321
  // ../kit/src/args.ts
302
322
  function jsonOption(holds) {
303
323
  return { type: "boolean", description: holds === undefined ? "print JSON" : `print JSON: ${holds}` };
@@ -317,8 +337,8 @@ function fail(message, code = 2, hint) {
317
337
  }
318
338
 
319
339
  class UsageError extends Exit {
320
- constructor(message) {
321
- super(message, 2);
340
+ constructor(message, hint) {
341
+ super(message, 2, hint);
322
342
  }
323
343
  }
324
344
  function badUsage(message) {
@@ -340,13 +360,35 @@ class Completing {
340
360
  var completion = { active: false };
341
361
  var OPTIONAL = /\[[^\]]+\]/g;
342
362
  var USAGE_PREFIX = /^usage:\s*/i;
363
+ var FULL_STOP = /\.$/;
364
+ var DASHES = /^-+/;
343
365
  function usageLine(usage, colors = paint(process3.stdout)) {
344
366
  return `${colors.bold("Usage:")} ${usage.replace(USAGE_PREFIX, "").replaceAll(OPTIONAL, (part) => colors.dim(part))}`;
345
367
  }
346
- function helpText(usage, options, colors = paint(process3.stdout), examples = []) {
347
- return [usageLine(usage, colors), "", colors.bold("Options:"), ...optionRows(options, colors), ...exampleRows(examples, colors)].join(`
368
+ function helpText(usage, options, colors = paint(process3.stdout), { examples = [], replaces } = {}) {
369
+ return [usageLine(usage, colors), ...replacesRows(replaces, colors), "", colors.bold("Options:"), ...optionRows(options, colors), ...exampleRows(examples, colors)].join(`
348
370
  `);
349
371
  }
372
+ function replacesRows(replaces, colors = paint(process3.stdout)) {
373
+ return replaces === undefined ? [] : ["", `${colors.bold("Instead of:")} ${replaces}`];
374
+ }
375
+ function wantsHelpJson(env = process3.env) {
376
+ return env.KIT_HELP === "json";
377
+ }
378
+ function description(usage, { examples = [], replaces } = {}) {
379
+ const lines = usage.replace(USAGE_PREFIX, "").split(`
380
+ `);
381
+ const first = lines[0];
382
+ const colon = first.indexOf(": ");
383
+ const prose = lines[lines.indexOf("") + 1] ?? "";
384
+ const fallback = lines.includes("") ? `${prose.charAt(0).toLowerCase()}${prose.slice(1)}`.replace(FULL_STOP, "") : "";
385
+ return {
386
+ synopsis: colon === -1 ? first : first.slice(0, colon),
387
+ summary: colon === -1 ? fallback : first.slice(colon + 2),
388
+ ...replaces === undefined ? {} : { replaces },
389
+ examples: examples.map((example) => typeof example === "string" ? { line: example } : { line: example[0], note: example[1] })
390
+ };
391
+ }
350
392
  function exampleRows(examples, colors = paint(process3.stdout)) {
351
393
  if (examples.length === 0)
352
394
  return [];
@@ -356,10 +398,10 @@ function optionRows(options, colors = paint(process3.stdout)) {
356
398
  const rows = Object.entries(options).map(([name, option]) => {
357
399
  const short = option.short === undefined ? " " : `${colors.cyan(`-${option.short}`)}, `;
358
400
  const value = option.type === "string" ? ` ${colors.dim("<value>")}` : "";
359
- return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === "string" ? " <value>".length : 0), description: option.description };
401
+ return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === "string" ? " <value>".length : 0), description: option.description ?? "" };
360
402
  });
361
403
  const width = Math.max(0, ...rows.map((row) => row.width));
362
- return rows.map((row) => ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
404
+ return rows.map((row) => row.description === "" ? ` ${row.flag}` : ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
363
405
  }
364
406
  var OUTPUT_OPTIONS = {
365
407
  color: { type: "string", description: "auto (on a terminal alone), always or never" },
@@ -389,14 +431,15 @@ function parse(args, usage, options, settings = {}) {
389
431
  const rest = separator === -1 ? undefined : args.slice(separator + 1);
390
432
  let parsed;
391
433
  try {
392
- parsed = parseArgs({ args: own, options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
434
+ parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
393
435
  } catch (error) {
394
- badUsage(parseError(error));
436
+ const message = parseError(error);
437
+ throw new UsageError(message, nearestOption(message, all, settings.allowNegative === true));
395
438
  }
396
439
  const values = parsed.values;
397
440
  applyOutput("color" in output ? values.color : undefined, "plain" in output ? values.plain : undefined);
398
441
  if (values.help === true) {
399
- console.log(helpText(usage, all, undefined, settings.examples));
442
+ console.log(wantsHelpJson() ? JSON.stringify(description(usage, settings)) : helpText(usage, all, undefined, settings));
400
443
  throw new HelpShown;
401
444
  }
402
445
  if (settings.positionals !== undefined)
@@ -411,10 +454,45 @@ function checkPositionals(given, names) {
411
454
  if (!variadic && given.length > names.length)
412
455
  badUsage(`unexpected argument ${given[names.length]}`);
413
456
  }
457
+ function joinDashValues(args, options) {
458
+ const longOf = new Map(Object.entries(options).flatMap(([name, option]) => option.short === undefined ? [[`--${name}`, name]] : [[`--${name}`, name], [`-${option.short}`, name]]));
459
+ const isOption = (arg) => arg === "--" || longOf.has(arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg);
460
+ const joined = [];
461
+ for (let i = 0;i < args.length; i++) {
462
+ const arg = args[i] ?? "";
463
+ if (arg === "--")
464
+ return [...joined, ...args.slice(i)];
465
+ const name = longOf.get(arg);
466
+ const next = args[i + 1];
467
+ if (name !== undefined && options[name]?.type === "string" && next !== undefined && next.startsWith("-") && next !== "-" && !isOption(next)) {
468
+ joined.push(`--${name}=${next}`);
469
+ i++;
470
+ } else {
471
+ joined.push(arg);
472
+ }
473
+ }
474
+ return joined;
475
+ }
414
476
  function parseError(error) {
415
477
  const message = upTo(error instanceof Error ? error.message : String(error), ". ");
416
478
  return `${message.charAt(0).toLowerCase()}${message.slice(1)}`;
417
479
  }
480
+ var UNKNOWN_OPTION = /^unknown option '(-[^']+)'/;
481
+ function nearestOption(message, options, allowNegative) {
482
+ const typed = UNKNOWN_OPTION.exec(message)?.[1];
483
+ if (typed === undefined)
484
+ return;
485
+ const word = typed.replace(DASHES, "");
486
+ if (word.length < 2 && !typed.startsWith("--"))
487
+ return;
488
+ const known = Object.entries(options).flatMap(([name, option]) => [
489
+ ...option.short === undefined ? [] : [`-${option.short}`],
490
+ `--${name}`,
491
+ ...allowNegative && option.type === "boolean" ? [`--no-${name}`] : []
492
+ ]);
493
+ const meant = closest(word, known.map((flag) => flag.replace(DASHES, "")));
494
+ return meant === undefined ? undefined : `did you mean ${known.find((flag) => flag.replace(DASHES, "") === meant)}?`;
495
+ }
418
496
  async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
419
497
  if (path === "-") {
420
498
  if (process3.stdin.isTTY)
@@ -428,33 +506,19 @@ async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
428
506
  }
429
507
  // ../kit/src/cli.ts
430
508
  import process4 from "process";
431
-
432
- // ../kit/src/text.ts
433
- var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
434
- function distance(a, b) {
435
- let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
436
- for (const [i, left] of [...a].entries()) {
437
- const current = [i + 1];
438
- for (const [j, right] of [...b].entries())
439
- current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (left === right ? 0 : 1)));
440
- previous = current;
441
- }
442
- return previous[b.length] ?? 0;
443
- }
444
-
445
- // ../kit/src/cli.ts
446
509
  var LEVEL_OPTIONS = {
447
510
  ...OUTPUT_OPTIONS,
448
511
  help: { type: "boolean", short: "h", description: "show this help" }
449
512
  };
450
513
  var TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: "boolean", short: "v", description: "print the version" } };
451
- function commandRows(commands, colors) {
514
+ async function commandRows(commands, colors) {
452
515
  const width = Math.max(...Object.keys(commands).map((name) => name.length));
453
516
  const groups = new Map([["Commands:", []]]);
454
- for (const [name, { summary, group }] of Object.entries(commands)) {
517
+ const summaries = await Promise.all(Object.values(commands).map(async ({ summary }) => resolveSummary(summary)));
518
+ for (const [index, [name, { group }]] of Object.entries(commands).entries()) {
455
519
  const heading = group === undefined ? "Commands:" : `${group}:`;
456
520
  const rows = groups.get(heading) ?? [];
457
- rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`);
521
+ rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summaries[index])}`);
458
522
  groups.set(heading, rows);
459
523
  }
460
524
  return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [""], colors.bold(heading), ...rows]);
@@ -474,25 +538,38 @@ function takeOutputFlags(argv) {
474
538
  }
475
539
  return argv.slice(index);
476
540
  }
477
- function closest(name, names) {
478
- const started = names.find((known) => known.startsWith(name));
479
- if (started !== undefined)
480
- return started;
481
- const [best] = names.map((known) => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far);
482
- return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
541
+ async function resolveSummary(summary) {
542
+ return typeof summary === "string" ? summary : summary();
543
+ }
544
+ function scopeOf(commands, extra, aliases) {
545
+ return { commands: typeof commands === "function" ? {} : commands, lazy: [...typeof commands === "function" ? [commands] : [], ...extra === undefined ? [] : [extra]], aliases };
483
546
  }
484
- async function help({ path, header, commands, defaultCommand, examples }, stream) {
547
+ async function expand(scope, words) {
548
+ if (scope.lazy.length === 0)
549
+ return scope;
550
+ const commands = { ...scope.commands };
551
+ for (const added of await Promise.all(scope.lazy.map(async (lazy) => lazy(words)))) {
552
+ for (const [name, command] of Object.entries(added))
553
+ commands[name] ??= command;
554
+ }
555
+ return { ...scope, commands, lazy: [] };
556
+ }
557
+ async function help({ path, header, summary: lazySummary, commands, defaultCommand, examples, replaces }, stream) {
485
558
  const colors = paint(stream);
486
559
  const prefix = path.join(" ");
560
+ const summary = await resolveSummary(lazySummary);
561
+ if (wantsHelpJson())
562
+ return JSON.stringify(description(`${prefix} <command> [options]: ${summary}`, { examples, replaces }));
487
563
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
488
564
  const options = fallback !== undefined && "run" in fallback ? await optionsOf(fallback.run) : undefined;
489
565
  const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS));
490
566
  return [
491
- header(colors),
567
+ header(colors, summary),
492
568
  "",
493
569
  usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan("<command>") : `[${colors.cyan("<command>")}]`} [options]`, colors),
570
+ ...replacesRows(replaces, colors),
494
571
  "",
495
- ...commandRows(commands, colors),
572
+ ...await commandRows(commands, colors),
496
573
  ...own.length === 0 ? [] : ["", `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim("the command run when none is named")}`, ...optionRows(Object.fromEntries(own), colors)],
497
574
  "",
498
575
  colors.bold("Options:"),
@@ -511,13 +588,21 @@ function printError(prefix, message, hint, cause) {
511
588
  ${styled ? tint(label) : `${label}:`} ${text}`;
512
589
  console.error(`${head} ${message}${under("cause", cause, colors.dim)}${under("hint", hint, colors.yellow)}`);
513
590
  }
514
- function reportThrown(prefix, error, redact = (text) => text) {
591
+ function printJsonError(message, hint, cause) {
592
+ console.error(JSON.stringify({ error: message, ...hint === undefined ? {} : { hint }, ...cause === undefined ? {} : { cause } }));
593
+ }
594
+ function wantsJson(argv) {
595
+ const end = argv.indexOf("--");
596
+ return (end === -1 ? argv : argv.slice(0, end)).includes("--json");
597
+ }
598
+ function reportThrown(prefix, error, redact = (text) => text, json = false) {
515
599
  if (error instanceof HelpShown)
516
600
  return 0;
517
601
  const help = error instanceof UsageError ? `run ${prefix} --help for its options` : undefined;
518
602
  const named = error instanceof Error && !(error instanceof Exit) && error.name !== "Error" ? `${error.name}: ${error.message}` : undefined;
519
603
  const cause = error instanceof Error && error.cause instanceof Error ? redact(error.cause.message) : undefined;
520
- printError(prefix, redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause);
604
+ const report = json ? printJsonError : (message, hint, causeText) => printError(prefix, message, hint, causeText);
605
+ report(redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause);
521
606
  return error instanceof Exit ? error.code : 1;
522
607
  }
523
608
  async function dispatch(level, typed, { version, redact }) {
@@ -530,11 +615,11 @@ async function dispatch(level, typed, { version, redact }) {
530
615
  }
531
616
  const [name, ...args] = argv;
532
617
  if (name === undefined && level.path.length > 1) {
533
- console.error(await help(level, process4.stderr));
618
+ console.error(await help(await expand(level, [...level.path.slice(1), ...argv]), process4.stderr));
534
619
  return 2;
535
620
  }
536
621
  if (name === undefined || name === "help" || name === "--help" || name === "-h") {
537
- console.log(await help(level, process4.stdout));
622
+ console.log(await help(await expand(level, [...level.path.slice(1), ...argv]), process4.stdout));
538
623
  return 0;
539
624
  }
540
625
  if (level.path.length === 1 && (name === "--version" || name === "-v")) {
@@ -543,6 +628,10 @@ async function dispatch(level, typed, { version, redact }) {
543
628
  }
544
629
  let command = level.commands[level.aliases?.[name] ?? name];
545
630
  let rest = args;
631
+ if (command === undefined && level.lazy.length > 0) {
632
+ level = await expand(level, [...level.path.slice(1), ...argv]);
633
+ command = level.commands[level.aliases?.[name] ?? name];
634
+ }
546
635
  if (command === undefined && level.defaultCommand !== undefined) {
547
636
  command = level.commands[level.defaultCommand];
548
637
  rest = argv;
@@ -554,12 +643,12 @@ async function dispatch(level, typed, { version, redact }) {
554
643
  }
555
644
  if ("commands" in command) {
556
645
  const { summary } = command;
557
- return dispatch({ path: [...level.path, name], header: (colors) => `${colors.bold(`${prefix} ${name}`)} ${summary}`, commands: command.commands, aliases: command.aliases, defaultCommand: undefined, examples: command.examples }, rest, { version, redact });
646
+ return dispatch({ path: [...level.path, name], header: (colors, text) => `${colors.bold(`${prefix} ${name}`)} ${text}`, summary, ...scopeOf(command.commands, command.extraCommands, command.aliases), defaultCommand: undefined, examples: command.examples, replaces: undefined }, rest, { version, redact });
558
647
  }
559
648
  try {
560
649
  return await command.run(rest);
561
650
  } catch (error) {
562
- return reportThrown(`${prefix} ${name}`, error, redact);
651
+ return reportThrown(`${prefix} ${name}`, error, redact, wantsJson(rest));
563
652
  }
564
653
  }
565
654
  var COMPLETE = "__complete";
@@ -579,18 +668,24 @@ async function optionsOf(run) {
579
668
  async function leafCandidates(run, words) {
580
669
  if (!(words.at(-1) ?? "").startsWith("-"))
581
670
  return [FILES];
582
- return Object.entries(await optionsOf(run)).map(([name, option]) => `--${name} ${option.description}`);
671
+ return Object.entries(await optionsOf(run)).map(([name, option]) => option.description === undefined ? `--${name}` : `--${name} ${option.description}`);
583
672
  }
584
- async function candidates(commands, aliases, defaultCommand, words) {
673
+ async function candidates(scope, defaultCommand, words, before = []) {
585
674
  const [word = "", ...rest] = words;
586
- const command = commands[aliases?.[word] ?? word];
587
- if (rest.length === 0 && !word.startsWith("-"))
588
- return Object.entries(commands).map(([name, { summary }]) => `${name} ${summary}`);
675
+ const listing = rest.length === 0 && !word.startsWith("-");
676
+ let { commands, aliases } = scope;
677
+ let command = commands[aliases?.[word] ?? word];
678
+ if (listing || command === undefined) {
679
+ ({ commands, aliases } = await expand(scope, [...before, ...words]));
680
+ command = commands[aliases?.[word] ?? word];
681
+ }
682
+ if (listing)
683
+ return Promise.all(Object.entries(commands).map(async ([name, { summary }]) => `${name} ${await resolveSummary(summary)}`));
589
684
  if (command === undefined) {
590
685
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
591
686
  return fallback !== undefined && "run" in fallback ? leafCandidates(fallback.run, words) : [];
592
687
  }
593
- return "commands" in command ? candidates(command.commands, command.aliases, undefined, rest) : leafCandidates(command.run, rest);
688
+ return "commands" in command ? candidates(scopeOf(command.commands, command.extraCommands, command.aliases), undefined, rest, [...before, word]) : leafCandidates(command.run, rest);
594
689
  }
595
690
  async function printCandidates(found) {
596
691
  for (const line of await found)
@@ -599,8 +694,8 @@ async function printCandidates(found) {
599
694
  }
600
695
  async function runCli(cli, argv = process4.argv.slice(2)) {
601
696
  if (argv[0] === COMPLETE)
602
- return printCandidates(candidates(cli.commands, cli.aliases, cli.defaultCommand, argv.slice(1)));
603
- return dispatch({ path: [cli.name], header: (colors) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${cli.about}`, commands: cli.commands, aliases: cli.aliases, defaultCommand: cli.defaultCommand, examples: cli.examples }, argv, { version: cli.version, redact: cli.redact });
697
+ return printCandidates(candidates(scopeOf(cli.commands, cli.extraCommands, cli.aliases), cli.defaultCommand, argv.slice(1)));
698
+ return dispatch({ path: [cli.name], header: (colors, about) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${about}`, summary: cli.about, ...scopeOf(cli.commands, cli.extraCommands, cli.aliases), defaultCommand: cli.defaultCommand, examples: cli.examples, replaces: cli.replaces }, argv, { version: cli.version, redact: cli.redact });
604
699
  }
605
700
  // ../kit/src/process.ts
606
701
  import { constants } from "os";
@@ -624,7 +719,7 @@ async function runCommand(argv, { cwd, env, stdin = "ignore" } = {}) {
624
719
  return { code: exitCodeOf(proc.exitCode, proc.signalCode), chunks, output, text: output.toString() };
625
720
  }
626
721
  // package.json
627
- var version = "0.3.0";
722
+ var version = "0.4.1";
628
723
 
629
724
  // ../kit/src/shell.ts
630
725
  var SEPARATORS = new Set([`
@@ -1120,7 +1215,7 @@ var EXEC_VALUE_OPTIONS = new Set(["-e", "--env", "--env-file", "-u", "--user", "
1120
1215
  var CONFIG_LISTS = new Set(["--services", "--volumes", "--profiles", "--images", "--networks", "--hash", "-q", "--quiet"]);
1121
1216
  var FORMAT_OPTIONS = new Set(["-f", "--format"]);
1122
1217
  var WHOLE_FORMAT = /Env|\{\{\s*json\s+\.\s*\}\}|^json$/;
1123
- var USE_EV = '.env files hold secrets that must never reach the transcript, so they are not read, edited or printed directly. Use `ev` instead: `ev list`, `ev get KEY` (masked), `ev grep <regex>` for the keys matching it, `ev show <rev>:<file>` for one at a git revision, `printf %s "$value" | ev set KEY [file]` (`--empty` for an empty value), `ev unset KEY`, `ev mv KEY --from .env --to .env.local`, `ev diff a b`, `ev missing`; and pipe output that may carry a secret through `redact`, as `docker compose exec <svc> printenv | redact` or `docker compose config | redact`. If `ev` is missing: `bun install -g @kaynooo/env-tools`.';
1218
+ var USE_EV = '.env files hold secrets that must never reach the transcript, so they are not read, edited or printed directly. Use `ev` instead: `ev list`, `ev get KEY` (masked), `ev grep <regex>` for the keys matching it, `ev show <rev>:<file>` for one at a git revision, `printf %s "$value" | ev set KEY [file]` (`--empty` for an empty value), `ev unset KEY`, `ev cp KEY --from .env --to .env.local`, `ev mv KEY --from .env --to .env.local`, `ev diff a b`, `ev missing`; and pipe output that may carry a secret through `redact`, as `docker compose exec <svc> printenv | redact` or `docker compose config | redact`. If `ev` is missing: `bun install -g @kaynooo/env-tools`.';
1124
1219
  function isEnvFile(path) {
1125
1220
  const name = path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf(":")) + 1);
1126
1221
  return (ENV_FILE.test(name) || name === ".env*") && !TEMPLATE.test(name);
@@ -1523,7 +1618,7 @@ var SHORT_CLUSTER = /^-[A-Z]+$/i;
1523
1618
  var STAGES_ALL = /[Au]/;
1524
1619
  var ALL_TAGS = new Set(["--tags", "--follow-tags"]);
1525
1620
  var STAGE_BY_PATH = `Stage by path: the tree carries unrelated work and peer sessions edit it live. ${USE_GC}`;
1526
- var STASH = "Never `git stash`: a pop with nothing saved applies someone else's old stash. To see how a test behaves without your change, run it at HEAD with `base -- <cmd>` (a temporary worktree, the working tree untouched), `--keep <path>` to carry the new test over, `--ref <rev>` for another commit.";
1621
+ var STASH = "Never `git stash`: a pop with nothing saved applies someone else's old stash. To prove a test fails without your change, `proof --keep <test> -- <cmd>` runs it at HEAD in a temporary worktree and again here; `base -- <cmd>` runs any command at HEAD (`--ref <rev>` another commit), the working tree untouched. To set edits aside before a cherry-pick, a rebase or a checkout, commit them with `gc` first, or do the other work in `git worktree add`.";
1527
1622
  var ADD_ALL = `\`git add\` over the whole tree is refused. ${STAGE_BY_PATH}`;
1528
1623
  var COMMIT_ALL = `\`git commit -a\` stages the whole tree and is refused. ${STAGE_BY_PATH}`;
1529
1624
  var PUSH = "`git push` runs only when the user asked for a push in their latest prompt: say the commit is ready and let them ask.";
@@ -2092,18 +2187,25 @@ function checkPath(path) {
2092
2187
  var BINS = {
2093
2188
  check: "`check`: runs the repo's check, typecheck, lint and test steps and prints only what failed, each full log saved to a file; validate a change with it rather than chaining the scripts (`--only lint,test`, `--list`).",
2094
2189
  fix: "`fix`: runs the repo's fixers and formatters (its fix, lint:fix and format scripts, just fmt, go fmt, cargo fmt, ruff) and lists the files they changed.",
2190
+ errs: "`errs [log]`, `errs -- <cmd>`: turns tsc, tsgo, eslint, bun check and bun test output into one `file:line:col: message (rule)` line per error, counting the lines it did not recognise (`--json`); use it instead of reading raw type, lint or test logs.",
2095
2191
  db: '`db "<sql>"`: one read-only statement against the Postgres in DATABASE_URL (`-e KEY` for another) or, without one, the compose project\'s running Postgres service (`-s <service>`, `-d <database>`), credentials never shown, a write printed for the user to run; `db tables`, `db describe <table>`, `db dbs`.',
2096
2192
  ev: "`ev`: .env files read and edited with values masked (`ev list`, `ev get KEY`, `ev grep <regex>`, `ev show <rev>:<file>`, `ev set`, `ev diff`, `ev missing`); `ev audit [dir]` finds env files others can read or git tracks, and compose files writing secrets out.",
2097
2193
  redact: "`redact`: masks secrets in piped output, `<cmd> | redact`.",
2098
2194
  pkg: "`pkg <name>[@version]`: checks an npm package before it is added: whether the name exists or is a near miss of a popular one, the newest version at least two weeks old, licence, install scripts; `bun add`, `npm install`, `pnpm add` and `yarn add` run it first and are refused on what it flags. `pkg bump <@scope/lib> [version]` waits until the registry serves a release, then `bun add --exact`s it in every consumer under ~/Workspace (`--dry-run`), `pkg consumers <name>` lists them; use it instead of a `bun info` and sleep loop and a `bun add` per repo.",
2099
2195
  stack: "`stack port <name>`: the port a service name always hashes to, for every host port a compose file publishes; `stack audit [dir]` cross-checks release.config.ts against its compose files.",
2100
- ft: "`ft`: Forgejo CI runs, logs and waits, releases, issue checklists and claims, in a repo whose origin is on Forgejo.",
2196
+ ft: "`ft`: Forgejo CI runs, logs and waits, releases, issue checklists and claims, milestones (`ft milestones`, `ft milestone <m>`, `ft new -m`), in a repo whose origin is on Forgejo.",
2101
2197
  sub: "`sub --from <text> --to <text> <file>...`: replaces text, writing nothing unless each match count is as expected (1, `-n N`, `--all`), `--regex`, `--pairs <json|->` for several in order, `--dry-run --diff`; use it instead of sed -i, a bun -e or python editor.",
2102
2198
  base: "`base [--ref <rev>] [--keep <path>...] -- <cmd>`: runs a command at HEAD (or a ref) in a temporary worktree, removed afterwards, ignored env files and node_modules/vendor linked; use it instead of git stash to see whether a test fails without your change (`--keep` the new test).",
2199
+ proof: "`proof [--ref <rev>] [--keep <path>...] -- <cmd>`: runs the command at HEAD (or a ref) in a temporary worktree, then in the working tree, and exits 0 only when it fails before and passes now, printing the failing side's output; use it to prove a new test fails without your change (`--keep` the test).",
2103
2200
  gst: "`gst [--log N]`: branch, upstream, changed counts and HEAD in one line, then the changed paths by staged, unstaged and untracked; use it instead of `git status --short` and `git log --oneline`.",
2104
2201
  gc: "`gc <title> [-m <line>]... [--ticket <line>]... -- <path>...`: commits exactly those paths, refusing with nothing staged when the message breaks a commit rule, a path has no change or other files are already staged; use it instead of chaining git add, status and commit.",
2105
2202
  waitfor: "`waitfor --file <log> --until <regex> [--fail <regex>]`, `--port <n>`, `--url <url>`, `--pid <n>` (`-t 5m`): waits until the line shows, the port listens, the URL answers 2xx or the process exits, exits 1 on a --fail line with the lines around it, 2 on a timeout with the last lines; `waitfor task <id>` waits for a background Bash task and prints its exit code, last lines and failure lines; use it instead of sleep and tail, `until ! pgrep -f` or polling loops.",
2106
2203
  gl: "`gl ci [ref]` waits for the GitLab pipeline covering a commit and prints each failed job's tests (exit 0/1/2), `gl runs`, `gl logs <pipeline> [-j job] [--full]`; use it instead of `glab ci` or `glab api .../pipelines/<id>/jobs` polled in a sleep loop.",
2204
+ peek: "`peek <file:from-to>...` prints several spans with line numbers in one call, `peek --def <symbol>` a whole declaration, `--refs <symbol>` its usages by file, `--outline <file>` the declarations and members, `--around <regex> -C 3` the lines around matches; use it instead of `sed -n N,Mp`, `grep -n` chains and whole-file reads.",
2205
+ "todo-scan": "`todo-scan [path...]` lists the TODO, FIXME and owner/repo#N comments as `<file>:<line>: <comment>`, `--stale` only those whose issue is closed (exit 1), through ft; use it to find the workarounds a shipped fix lets you drop.",
2206
+ doctor: "`doctor`: checks this host's agent-tools install, one line per check with its fix: each bin's version against the registry, tea's login, the Claude Code hooks, the Bun floor (`--json`); run it when a bin lacks a command or a flag its docs name.",
2207
+ "agent-tools": "`agent-tools list`: every agent-tools bin in one line, what it does and what it is reached for instead of, read from each bin's own --help (`--json` adds the synopsis and examples); run it to find the bin that fits a task.",
2208
+ hotspots: "`hotspots [dir] [--since <date>] [-n 20]`: ranks the source files by complexity times git churn (commits touching each in the window, 6 months by default), tests and generated files left out, `--json`; use it to pick which complex files are worth splitting first.",
2107
2209
  out: "`out -- <cmd>`: folds long output, keeping the errors and saving the full log; run noisy builds, installs, tests and linters through it."
2108
2210
  };
2109
2211
  function binsSection(installed) {
@@ -2180,7 +2282,9 @@ var checkUsage = `guard check [options] [<command> | -]
2180
2282
 
2181
2283
  Checks a shell command against the rules: remote machines, git (stash, whole-tree add and commit, push, tags), commit messages, SQL writes, env files, package managers and in-place edits (sed -i and replace scripts, pointed at sub).
2182
2284
  Prints each broken rule with the route to take instead and exits 1, prints nothing and exits 0 when the command is clean.
2183
- The command is read from stdin with - or when none is given; a rule may rewrite it instead (the pinned package manager, a noisy run through out), printed as \`runs as: <command>\`.`;
2285
+ The command is read from stdin with - or when none is given; a rule may rewrite it instead (the pinned package manager, a noisy run through out), printed as \`runs as: <command>\`.
2286
+
2287
+ Exits 0 when the command is clean, 1 when a rule is broken, 2 on a usage error.`;
2184
2288
  var checkOptions = {
2185
2289
  json: jsonOption("{violations, command, notes, packages} for a hook"),
2186
2290
  cwd: { type: "string", description: "the directory the command runs in (default: here)" },
@@ -2189,7 +2293,9 @@ var checkOptions = {
2189
2293
  };
2190
2294
  var pathUsage = `guard path [--json] <path>...
2191
2295
 
2192
- Checks the paths a file tool would read, edit or write: env files are refused.`;
2296
+ Checks the paths a file tool would read, edit or write: env files are refused.
2297
+
2298
+ Exits 0 when every path is clean, 1 when one is refused, 2 on a usage error.`;
2193
2299
  var pathOptions = {
2194
2300
  json: jsonOption("{violations} for a hook")
2195
2301
  };
@@ -2197,7 +2303,9 @@ var hookUsage = `guard hook <${EVENTS.join("|")}>
2197
2303
 
2198
2304
  Answers a Claude Code hook: reads the event JSON on stdin and prints the reply JSON, nothing when the call goes ahead untouched.
2199
2305
  pre-tool-use refuses a Bash call breaking a rule, an install pkg refuses, and a Read, Edit or Write of an env file; user-prompt-submit remembers whether the prompt asked for a push; session-start lists the installed agent-tools bins.
2200
- A failure exits 1, which Claude Code reports without blocking the call.`;
2306
+ A failure exits 1, which Claude Code reports without blocking the call.
2307
+
2308
+ Exits 0 when it answered, 1 on a failure or an unknown event (never 2, which would block the call).`;
2201
2309
  function readText(path) {
2202
2310
  try {
2203
2311
  return readFileSync(path, "utf8");
@@ -2283,6 +2391,8 @@ process6.exitCode = await runCli({
2283
2391
  name: "guard",
2284
2392
  version,
2285
2393
  about: "the safety rules a Claude Code Bash call is held to, each refusal naming the route",
2394
+ replaces: "a hook script per rule, each wording its own refusal",
2395
+ examples: [['guard check "git push --force"', "the rules a command breaks, with their routes"], ["guard path .env", "whether a file tool may open a path"]],
2286
2396
  commands: {
2287
2397
  check: { run: check, summary: "check a shell command, printing each broken rule with its route" },
2288
2398
  path: { run: async (args) => Promise.resolve(path(args)), summary: "check a path a file tool would open" },
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@kaynooo/guard-tools",
3
3
  "type": "module",
4
- "version": "0.3.0",
4
+ "version": "0.4.1",
5
5
  "description": "guard: the safety rules a Claude Code Bash call is held to (git, remote machines, SQL writes, env files, package managers, commit messages), each refusal naming the route.",
6
- "author": "Kaynooo <kaynooo@kevyn.fr>",
6
+ "author": "Kaynooo <contact@kaynooo.fr>",
7
7
  "license": "MIT",
8
- "homepage": "https://git.kevyn.fr/kaynooo/agent-tools/src/branch/main/packages/guard#readme",
8
+ "homepage": "https://git.kaynooo.fr/kaynooo/agent-tools/src/branch/main/packages/guard#readme",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+https://git.kevyn.fr/kaynooo/agent-tools.git",
11
+ "url": "git+https://git.kaynooo.fr/kaynooo/agent-tools.git",
12
12
  "directory": "packages/guard"
13
13
  },
14
- "bugs": "https://git.kevyn.fr/kaynooo/agent-tools/issues",
14
+ "bugs": "https://git.kaynooo.fr/kaynooo/agent-tools/issues",
15
15
  "keywords": [
16
16
  "claude-code",
17
17
  "cli",
@@ -29,7 +29,7 @@
29
29
  "bun": ">=1.4.3"
30
30
  },
31
31
  "publishConfig": {
32
- "registry": "https://git.kevyn.fr/api/packages/kaynooo/npm/"
32
+ "registry": "https://git.kaynooo.fr/api/packages/kaynooo/npm/"
33
33
  },
34
34
  "scripts": {
35
35
  "build": "$npm_execpath build src/cli.ts --target bun --outdir dist",