@kaynooo/cli-out 0.7.1 → 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.
Files changed (3) hide show
  1. package/README.md +10 -4
  2. package/dist/cli.js +212 -67
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -7,6 +7,7 @@ bun install -g @kaynooo/cli-out
7
7
  out -- bun install
8
8
  bun run build 2>&1 | out
9
9
  out --stats --since 7d
10
+ out --grep 'error TS'
10
11
  ```
11
12
 
12
13
  ## Behaviour
@@ -20,14 +21,19 @@ out --stats --since 7d
20
21
  - The head and tail are kept, and lines with `error`, `fail`, `warn`, `panic`, `exception`, `fatal` or a stack frame stay even in the folded middle, the first 40 of them. Lines wider than 300 characters are cut in the folded output (the full log keeps them), and a few-lines output that is still heavy keeps a shorter head and tail.
21
22
  - The exit code is always the wrapped command's. Piped into with no command, `out` folds what it reads and exits 0, the writer's code left to `set -o pipefail`; only stdout is piped, so `2>&1` brings stderr along. A terminal or `/dev/null` on stdin with no command is a usage error.
22
23
  - The output is streamed to a full log as it arrives once it outgrows the 4 MiB held in memory (the first and last 2 MiB), otherwise the log is written when folding. Logs go to `$XDG_RUNTIME_DIR/kaynooo-cli/out` (`~/.cache/kaynooo-cli/out` without it), a folder only the user can enter, files at mode 0600, and logs older than a day are removed on the next run.
23
- - SIGINT, SIGTERM and SIGHUP go to the command's whole process group, and `out` exits with its code. A watch command (`--watch`, `tsc -w`, `just dev`) is never wrapped by the hook, as its output would sit buffered.
24
+ - SIGINT, SIGTERM and SIGHUP go to the command's whole process group, and `out` exits with its code. A watch command (`--watch`, `tsc -w`, `just dev`) is never wrapped by `guard`, as its output would sit buffered.
25
+
26
+ ## Saved logs
27
+
28
+ - `out --last` prints the path of the most recent full log, `out --list` lists them (path, size, age, newest first) and `out --grep <regex>` prints the matching lines of the last log, or of the one `--log <name|path>` names, each as `N: line` with N the line number the folded output's `(same as line N)` refers to.
29
+ - Exit 0 when found, 1 when `--grep` matches nothing or `--list` finds no log, 2 when there is no saved log to read or the regex is invalid. A run saves a log only when its output was folded, and logs older than a day are removed.
24
30
 
25
31
  ## Stats
26
32
 
27
33
  - Each run appends a line to `$XDG_STATE_HOME/kaynooo-cli/out/stats.jsonl` (`~/.local/state/...` without it, folder 0700, file 0600): the time, the folder, the words naming the command, the bytes it printed, the bytes the session would have read without `out` and the bytes `out` showed. Only lowercase words without digits after the program are kept (`bun run build`, `docker compose exec`), so no argument, assignment or token reaches the file; a `sh -c` chain is named by its last command that is not a `cd`.
28
- - `--was '<stage>'` (repeatable, in pipeline order) names a `tail`, `head` or `grep` stage `out` stands in for, as the hook passes them when it drops one: the session would have read what that stage printed, so the saving counts against it. Output that folding would not make shorter is printed untouched, so `out` never shows more than the command printed. A stage it cannot read (`tail -f`, a long grep option it does not know) counts the whole output.
34
+ - `--was '<stage>'` (repeatable, in pipeline order) names a `tail`, `head` or `grep` stage `out` stands in for, as `guard` passes them when it drops one: the session would have read what that stage printed, so the saving counts against it. Output that folding would not make shorter is printed untouched, so `out` never shows more than the command printed. A stage it cannot read (`tail -f`, a long grep option it does not know) counts the whole output.
29
35
  - `out --stats [--since 7d] [--json]` prints the runs, how many were folded, the bytes printed and shown, and about how many tokens were saved (bytes / 4, an estimate), then the ten commands that saved the most, with the share of runs folded and, per command, the median share of the output a folded run cut.
30
36
 
31
- ## Wrapping from a hook
37
+ ## Wrapping from guard
32
38
 
33
- The pure functions a Claude Code PreToolUse hook needs live in `src/wrap.ts`: `shouldWrap(command)` is true when every part of a command line is a listed noisy command (installs, builds, test runners, linters, `docker build`/`docker compose build|pull`, `just <recipe>`), with no pipe, redirect, background, substitution, `--json` or `--help`. A runner is known by its last path segment (`vendor/bin/phpunit`), through `php` (`php bin/phpunit`) and inside `docker compose exec|run` or `docker exec` (`docker compose exec -T php vendor/bin/paratest`). A line ending in pipes into `tail` or `head` keeping at least 150 lines (`-n 200`, `-200`, `--lines=200`), out's fold size, is wrapped with those stages and the `2>&1` feeding them dropped, since the folded output is no longer and keeps the failures they would have picked out; a `grep` or a shorter cut shows less than `out` would, so that line is left as it is. `wrap(command)` returns `out -- <command>`, through `sh -c '...'` for `&&` chains and leading `VAR=value` assignments, each dropped stage passed ahead as `--was '<stage>'`. The guard hook imports them from this package.
39
+ The pure functions `guard` needs to wrap a command live in `src/wrap.ts`: `shouldWrap(command)` is true when every part of a command line is a listed noisy command (installs, builds, test runners, linters, `docker build`/`docker compose build|pull`, `just <recipe>`), with no pipe, redirect, background, substitution, `--json` or `--help`. A runner is known by its last path segment (`vendor/bin/phpunit`), through `php` (`php bin/phpunit`) and inside `docker compose exec|run` or `docker exec` (`docker compose exec -T php vendor/bin/paratest`). A line ending in pipes into `tail` or `head` keeping at least 150 lines (`-n 200`, `-200`, `--lines=200`), out's fold size, is wrapped with those stages and the `2>&1` feeding them dropped, since the folded output is no longer and keeps the failures they would have picked out; a `grep` or a shorter cut shows less than `out` would, so that line is left as it is. `wrap(command)` returns `out -- <command>`, through `sh -c '...'` for `&&` chains and leading `VAR=value` assignments, each dropped stage passed ahead as `--was '<stage>'`. The `guard` command imports them to rewrite a command into `out -- ...`; its Claude Code hook never wraps, since a hook cannot swap a command without approving it, and refuses with the command to run instead.
package/dist/cli.js CHANGED
@@ -2,17 +2,16 @@
2
2
  // @bun
3
3
 
4
4
  // src/cli.ts
5
- import process13 from "process";
6
-
5
+ import process14 from "process";
7
6
  // ../kit/src/args.ts
8
- import process4 from "process";
7
+ import process5 from "process";
9
8
  import { parseArgs } from "util";
10
9
 
11
10
  // ../kit/src/format.ts
12
- import process2 from "process";
11
+ import process3 from "process";
13
12
 
14
13
  // ../kit/src/style.ts
15
- import process from "process";
14
+ import process2 from "process";
16
15
 
17
16
  // ../../node_modules/.bun/@kaynooo+utils@2.31.2+d0769e28dc4dad16/node_modules/@kaynooo/utils/dist/chunk-mtetydvc.js
18
17
  function x(s) {
@@ -276,10 +275,10 @@ function le(i = {}) {
276
275
  var t = le();
277
276
 
278
277
  // ../kit/src/style.ts
279
- function takesControlCodes(stream, env = process.env) {
278
+ function takesControlCodes(stream, env = process2.env) {
280
279
  return stream.isTTY === true && env.NO_COLOR === undefined && env.NODE_DISABLE_COLORS === undefined && env.TERM !== "dumb";
281
280
  }
282
- function paint(stream, env = process.env) {
281
+ function paint(stream, env = process2.env) {
283
282
  return le({ stream: { isTTY: takesControlCodes(stream, env) }, env });
284
283
  }
285
284
  var STATUSES = {
@@ -292,10 +291,31 @@ var STATUSES = {
292
291
  var STATUS_OF = new Map(Object.entries(STATUSES).flatMap(([status, { words }]) => words.map((word) => [word, status])));
293
292
 
294
293
  // ../kit/src/format.ts
294
+ var UNITS = [["d", 86400], ["h", 3600], ["m", 60], ["s", 1]];
295
+ function formatSeconds(total) {
296
+ let rest = Math.max(0, Math.round(total));
297
+ const amounts = UNITS.map(([unit, size]) => {
298
+ const amount = Math.floor(rest / size);
299
+ rest -= amount * size;
300
+ return `${amount}${unit}`;
301
+ });
302
+ const first = amounts.findIndex((amount) => !amount.startsWith("0"));
303
+ if (first === -1)
304
+ return "0s";
305
+ const next = amounts[first + 1];
306
+ return next === undefined || next.startsWith("0") ? amounts[first] ?? "0s" : `${amounts[first]} ${next}`;
307
+ }
295
308
  function upTo(text, separator) {
296
309
  const end = text.indexOf(separator);
297
310
  return end === -1 ? text : text.slice(0, end);
298
311
  }
312
+ function formatAge(date, now = Date.now()) {
313
+ const at = typeof date === "number" ? date : Date.parse(date);
314
+ if (Number.isNaN(at))
315
+ return "-";
316
+ const seconds = (now - at) / 1000;
317
+ return seconds < 60 ? "just now" : `${upTo(formatSeconds(seconds), " ")} ago`;
318
+ }
299
319
  function truncate(text, width) {
300
320
  return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}\u2026`;
301
321
  }
@@ -312,7 +332,7 @@ function table(rows, { cellWidth, header = false, align = [], fit } = {}) {
312
332
  widths[index] = Math.max(widths[index] ?? 0, textWidth(cell));
313
333
  }
314
334
  const shown = fit === undefined ? cells : narrow(cells, widths, fit);
315
- const colors = paint(process2.stdout);
335
+ const colors = paint(process3.stdout);
316
336
  const lines = shown.map((row) => row.map((cell, index) => {
317
337
  const width = widths[index] ?? 0;
318
338
  const gap = " ".repeat(Math.max(0, width - textWidth(cell)));
@@ -344,24 +364,30 @@ function printJson(value) {
344
364
  }
345
365
 
346
366
  // ../kit/src/help.ts
347
- import process3 from "process";
367
+ import process4 from "process";
348
368
  var OPTIONAL = /\[[^\]]+\]/g;
349
369
  var USAGE_PREFIX = /^usage:\s*/i;
350
370
  var FULL_STOP = /\.$/;
351
- function usageLine(usage, colors = paint(process3.stdout)) {
371
+ function usageLine(usage, colors = paint(process4.stdout)) {
352
372
  return `${colors.bold("Usage:")} ${usage.replace(USAGE_PREFIX, "").replaceAll(OPTIONAL, (part) => colors.dim(part))}`;
353
373
  }
354
- function helpText(usage, options, colors = paint(process3.stdout), { examples = [], replaces } = {}) {
355
- return [usageLine(usage, colors), ...replacesRows(replaces, colors), "", colors.bold("Options:"), ...optionRows(options, colors), ...exampleRows(examples, colors)].join(`
374
+ function helpText(usage, options, colors = paint(process4.stdout), { examples = [], replaces, exits = [] } = {}) {
375
+ return [usageLine(usage, colors), ...replacesRows(replaces, colors), "", colors.bold("Options:"), ...optionRows(options, colors), ...exitRows(exits, colors), ...exampleRows(examples, colors)].join(`
356
376
  `);
357
377
  }
358
- function replacesRows(replaces, colors = paint(process3.stdout)) {
378
+ function exitRows(exits, colors = paint(process4.stdout)) {
379
+ if (exits.length === 0)
380
+ return [];
381
+ const width = Math.max(...exits.map(([code]) => String(code).length));
382
+ return ["", colors.bold("Exits:"), ...exits.map(([code, meaning]) => ` ${colors.cyan(String(code).padEnd(width))} ${meaning}`)];
383
+ }
384
+ function replacesRows(replaces, colors = paint(process4.stdout)) {
359
385
  return replaces === undefined ? [] : ["", `${colors.bold("Instead of:")} ${replaces}`];
360
386
  }
361
- function wantsHelpJson(env = process3.env) {
387
+ function wantsHelpJson(env = process4.env) {
362
388
  return env.KIT_HELP === "json";
363
389
  }
364
- function description(usage, { examples = [], replaces } = {}) {
390
+ function description(usage, { examples = [], replaces, exits } = {}, options) {
365
391
  const lines = usage.replace(USAGE_PREFIX, "").split(`
366
392
  `);
367
393
  const first = lines[0];
@@ -372,15 +398,17 @@ function description(usage, { examples = [], replaces } = {}) {
372
398
  synopsis: colon === -1 ? first : first.slice(0, colon),
373
399
  summary: colon === -1 ? fallback : first.slice(colon + 2),
374
400
  ...replaces === undefined ? {} : { replaces },
375
- examples: examples.map((example) => typeof example === "string" ? { line: example } : { line: example[0], note: example[1] })
401
+ examples: examples.map((example) => typeof example === "string" ? { line: example } : { line: example[0], note: example[1] }),
402
+ ...exits === undefined ? {} : { exits: exits.map(([code, meaning]) => ({ code, meaning })) },
403
+ ...options === undefined ? {} : { options: Object.entries(options).map(([name, { short, description }]) => ({ name, ...short === undefined ? {} : { short }, ...description === undefined ? {} : { description } })) }
376
404
  };
377
405
  }
378
- function exampleRows(examples, colors = paint(process3.stdout)) {
406
+ function exampleRows(examples, colors = paint(process4.stdout)) {
379
407
  if (examples.length === 0)
380
408
  return [];
381
409
  return ["", colors.bold("Examples:"), ...examples.map((example) => typeof example === "string" ? ` ${example}` : ` ${example[0]} ${colors.dim(`# ${example[1]}`)}`)];
382
410
  }
383
- function optionRows(options, colors = paint(process3.stdout)) {
411
+ function optionRows(options, colors = paint(process4.stdout)) {
384
412
  const rows = Object.entries(options).map(([name, option]) => {
385
413
  const short = option.short === undefined ? " " : `${colors.cyan(`-${option.short}`)}, `;
386
414
  const named = option.choices === undefined ? "<value>" : `<${option.choices.join("|")}>`;
@@ -490,7 +518,7 @@ var OUTPUT_OPTIONS = {
490
518
  color: { type: "string", description: "auto (on a terminal alone), always or never" },
491
519
  plain: { type: "boolean", description: "plain text, no colour or spinner: --color never" }
492
520
  };
493
- function applyOutput(color, plain, env = process4.env) {
521
+ function applyOutput(color, plain, env = process5.env) {
494
522
  const when = plain === true ? "never" : color ?? "auto";
495
523
  if (when === "auto")
496
524
  return;
@@ -513,7 +541,7 @@ function parse(args, usage, options, settings = {}) {
513
541
  const { own, rest } = settings.trailing === true ? splitTrailing(args, all, settings.positionals?.length ?? 0) : splitRest(args, settings.rest === true);
514
542
  let parsed;
515
543
  try {
516
- parsed = parseArgs({ args: joinDashValues(withBare(own, all), all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
544
+ parsed = parseArgs({ args: joinDashValues(withBare(withAliases(own, all), all), all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
517
545
  } catch (error) {
518
546
  const message = parseError(error);
519
547
  throw new UsageError(message, nearestOption(message, all, settings.allowNegative === true));
@@ -521,7 +549,7 @@ function parse(args, usage, options, settings = {}) {
521
549
  const values = parsed.values;
522
550
  applyOutput("color" in output ? values.color : undefined, "plain" in output ? values.plain : undefined);
523
551
  if (values.help === true) {
524
- console.log(wantsHelpJson() ? JSON.stringify(description(usage, settings)) : helpText(usage, listing, undefined, settings));
552
+ console.log(wantsHelpJson() ? JSON.stringify(description(usage, settings, listing)) : helpText(usage, listing, undefined, settings));
525
553
  throw new HelpShown;
526
554
  }
527
555
  checkChoices(parsed.values, all);
@@ -563,6 +591,18 @@ function listed(options, allowNegative) {
563
591
  return [...option.description === undefined ? [] : [[name, option]], [`no-${name}`, { type: "boolean", description: option.negative }]];
564
592
  }));
565
593
  }
594
+ function withAliases(args, options) {
595
+ const named = new Map(Object.entries(options).flatMap(([name, { aliases = [] }]) => aliases.map((alias) => [`--${alias}`, `--${name}`])));
596
+ if (named.size === 0)
597
+ return args;
598
+ const separator = args.indexOf("--");
599
+ const own = separator === -1 ? args : args.slice(0, separator);
600
+ return [...own.map((arg) => {
601
+ const at = arg.indexOf("=");
602
+ const current = named.get(at === -1 ? arg : arg.slice(0, at));
603
+ return current === undefined ? arg : `${current}${at === -1 ? "" : arg.slice(at)}`;
604
+ }), ...separator === -1 ? [] : args.slice(separator)];
605
+ }
566
606
  function withBare(args, options) {
567
607
  const bare = new Map(Object.entries(options).flatMap(([name, { short, optional }]) => optional === undefined ? [] : [[`--${name}`, optional], ...short === undefined ? [] : [[`-${short}`, optional]]]));
568
608
  if (bare.size === 0)
@@ -649,7 +689,7 @@ function nearestOption(message, options, allowNegative) {
649
689
  }
650
690
  // ../kit/src/process.ts
651
691
  import { constants } from "os";
652
- import process5 from "process";
692
+ import process6 from "process";
653
693
  var KILL_GRACE_MS = 2000;
654
694
  var SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
655
695
  function exitCodeOf(code, signal) {
@@ -671,7 +711,7 @@ function holdSignals(cleanup) {
671
711
  },
672
712
  release: () => {
673
713
  for (const signal of SIGNALS)
674
- process5.off(signal, onSignal);
714
+ process6.off(signal, onSignal);
675
715
  }
676
716
  };
677
717
  function onSignal(signal) {
@@ -684,10 +724,10 @@ function holdSignals(cleanup) {
684
724
  return;
685
725
  held.release();
686
726
  cleanup(signal);
687
- process5.kill(process5.pid, signal);
727
+ process6.kill(process6.pid, signal);
688
728
  }
689
729
  for (const signal of SIGNALS)
690
- process5.on(signal, onSignal);
730
+ process6.on(signal, onSignal);
691
731
  return held;
692
732
  }
693
733
  async function runCommand(argv, { cwd, env, stdin = "ignore", timeoutMs, signal, maxBytes = Infinity, onChunk, onStart } = {}) {
@@ -701,7 +741,7 @@ async function runCommand(argv, { cwd, env, stdin = "ignore", timeoutMs, signal,
701
741
  return;
702
742
  try {
703
743
  if (grouped)
704
- process5.kill(-proc.pid, killing);
744
+ process6.kill(-proc.pid, killing);
705
745
  else
706
746
  proc.kill(killing);
707
747
  } catch {}
@@ -775,31 +815,33 @@ var ESCAPE_CODE = new RegExp(`${ESC}(?:\\[[\\x30-\\x3F]*[\\x20-\\x2F]*[\\x40-\\x
775
815
  var RESET_STYLE = `${ESC}[0m`;
776
816
  var CLOSE_LINK = `${ESC}]8;;${ESC}\\`;
777
817
  // ../kit/src/cli.ts
778
- import process8 from "process";
818
+ import { readFileSync, realpathSync } from "fs";
819
+ import { dirname as dirname2, join as join3 } from "path";
820
+ import process9 from "process";
779
821
 
780
822
  // ../kit/src/usage.ts
781
823
  import { appendFileSync, mkdirSync } from "fs";
782
824
  import { homedir as homedir2 } from "os";
783
825
  import { dirname, isAbsolute, join as join2 } from "path";
784
- import process7 from "process";
826
+ import process8 from "process";
785
827
 
786
828
  // ../kit/src/logs.ts
787
829
  import { lstatSync, openSync } from "fs";
788
830
  import { mkdir, mkdtemp, readdir, rm, stat } from "fs/promises";
789
831
  import { homedir, tmpdir } from "os";
790
832
  import { join } from "path";
791
- import process6 from "process";
833
+ import process7 from "process";
792
834
  var DAY_MS = 24 * 60 * 60 * 1000;
793
835
  var CLI_FOLDER = "kaynooo-cli";
794
836
  function isPrivateDirectory(path) {
795
837
  try {
796
838
  const info = lstatSync(path, { throwIfNoEntry: false });
797
- return info !== undefined && info.isDirectory() && info.uid === (process6.getuid?.() ?? info.uid) && (info.mode & 63) === 0;
839
+ return info !== undefined && info.isDirectory() && info.uid === (process7.getuid?.() ?? info.uid) && (info.mode & 63) === 0;
798
840
  } catch {
799
841
  return false;
800
842
  }
801
843
  }
802
- async function privateLogDirectory(name, env = process6.env) {
844
+ async function privateLogDirectory(name, env = process7.env) {
803
845
  const runtime = env.XDG_RUNTIME_DIR;
804
846
  const base = runtime === undefined || runtime === "" ? join(homedir(), ".cache", CLI_FOLDER) : join(runtime, CLI_FOLDER);
805
847
  const path = join(base, name);
@@ -842,14 +884,14 @@ var usageError;
842
884
  function noteUsageError(message) {
843
885
  usageError = message;
844
886
  }
845
- function stateHome(env = process7.env) {
887
+ function stateHome(env = process8.env) {
846
888
  const dir = env.XDG_STATE_HOME;
847
889
  return dir !== undefined && isAbsolute(dir) ? dir : join2(homedir2(), ".local", "state");
848
890
  }
849
- function usagePath(env = process7.env) {
891
+ function usagePath(env = process8.env) {
850
892
  return join2(stateHome(env), CLI_FOLDER, "usage.jsonl");
851
893
  }
852
- function usageRecorded(env = process7.env) {
894
+ function usageRecorded(env = process8.env) {
853
895
  const off = env.KAYNOOO_CLI_NO_USAGE;
854
896
  return env.NODE_ENV !== "test" && (off === undefined || off === "" || off === "0");
855
897
  }
@@ -863,7 +905,7 @@ function usageEntry(bin, command, argv, code, now = new Date) {
863
905
  usageError = undefined;
864
906
  return { at: now.toISOString(), bin, ...command === undefined ? {} : { command }, flags: flagNames(argv), code, ...unknown === undefined ? {} : { unknown } };
865
907
  }
866
- function recordUsage(entry, env = process7.env) {
908
+ function recordUsage(entry, env = process8.env) {
867
909
  if (!usageRecorded(env))
868
910
  return;
869
911
  try {
@@ -881,8 +923,8 @@ var LEVEL_OPTIONS = {
881
923
  };
882
924
  var TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: "boolean", short: "v", description: "print the version" } };
883
925
  function printError(prefix, message, hint, cause) {
884
- const colors = paint(process8.stderr);
885
- const styled = takesControlCodes(process8.stderr);
926
+ const colors = paint(process9.stderr);
927
+ const styled = takesControlCodes(process9.stderr);
886
928
  const head = styled ? `${colors.red.bold("error")} ${colors.dim(`${prefix}:`)}` : `error: ${prefix}:`;
887
929
  const under = (label, text, tint) => text === undefined ? "" : `
888
930
  ${styled ? tint(label) : `${label}:`} ${text}`;
@@ -964,11 +1006,22 @@ async function printCandidates(found) {
964
1006
  console.log(line);
965
1007
  return 0;
966
1008
  }
967
- async function runMain({ name, version, run, redact, usage }, argv = process8.argv.slice(2)) {
1009
+ function packageVersion(entry = Bun.main) {
1010
+ for (let dir = dirname2(realpathSync(entry));; dir = dirname2(dir)) {
1011
+ try {
1012
+ const { version } = JSON.parse(readFileSync(join3(dir, "package.json"), "utf8"));
1013
+ if (typeof version === "string")
1014
+ return version;
1015
+ } catch {}
1016
+ if (dirname2(dir) === dir)
1017
+ return "0.0.0";
1018
+ }
1019
+ }
1020
+ async function runMain({ name, version, run, redact, usage }, argv = process9.argv.slice(2)) {
968
1021
  if (argv[0] === COMPLETE)
969
1022
  return printCandidates(leafCandidates(run, argv.slice(1)));
970
1023
  if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-v")) {
971
- console.log(version);
1024
+ console.log(version ?? packageVersion());
972
1025
  return 0;
973
1026
  }
974
1027
  let code;
@@ -977,8 +1030,11 @@ async function runMain({ name, version, run, redact, usage }, argv = process8.ar
977
1030
  } catch (error) {
978
1031
  code = reportThrown(name, error, redact, wantsJson(argv));
979
1032
  }
980
- if (usage !== false)
981
- recordUsage(usageEntry(name, undefined, argv, code));
1033
+ if (usage !== false) {
1034
+ try {
1035
+ recordUsage(usageEntry(name, undefined, argv, code));
1036
+ } catch {}
1037
+ }
982
1038
  return code;
983
1039
  }
984
1040
  // ../kit/src/secrets.ts
@@ -987,9 +1043,6 @@ var SECRET_WORDS = new Set(["key", "pass", "pwd", "auth", "salt", "cookie", "ses
987
1043
  var {Glob } = globalThis.Bun;
988
1044
  var SESSION_FILES = new Glob("*.json");
989
1045
  var ACCOUNT_DIRS = new Glob("*/sessions");
990
- // package.json
991
- var version = "0.7.1";
992
-
993
1046
  // src/shape.ts
994
1047
  var HEX_LITERAL = /\b0x[0-9a-f]+\b/gi;
995
1048
  var PATH = /(?:\.{1,2}\/|\/)?[\w.@~-]+(?:\/[\w.@~-]+)+/g;
@@ -1170,11 +1223,11 @@ function fold(text, { threshold, logPath }) {
1170
1223
 
1171
1224
  // src/run.ts
1172
1225
  import { closeSync, rmSync, writeSync } from "fs";
1173
- import process12 from "process";
1226
+ import process13 from "process";
1174
1227
 
1175
1228
  // src/logs.ts
1176
- import { join as join3 } from "path";
1177
- import process10 from "process";
1229
+ import { join as join4 } from "path";
1230
+ import process11 from "process";
1178
1231
  var MAX_AGE_MS = 24 * 60 * 60 * 1000;
1179
1232
  async function logDir() {
1180
1233
  return privateLogDirectory("out");
@@ -1183,7 +1236,7 @@ async function cleanLogs(dir, now = Date.now()) {
1183
1236
  await sweepLogs(dir, MAX_AGE_MS, now);
1184
1237
  }
1185
1238
  function newLog(dir) {
1186
- const path = join3(dir, `${new Date().toISOString().replaceAll(/[:.]/g, "-")}-${process10.pid}.log`);
1239
+ const path = join4(dir, `${new Date().toISOString().replaceAll(/[:.]/g, "-")}-${process11.pid}.log`);
1187
1240
  return { path, fd: createLog(path) };
1188
1241
  }
1189
1242
 
@@ -1202,13 +1255,16 @@ var ANSI_ESCAPES = { n: `
1202
1255
  var ANSI_DIGITS = { x: 2, u: 4, U: 8 };
1203
1256
  var OCTAL = /^[0-7]{1,3}/;
1204
1257
  var KEYWORDS = new Set(["then", "do", "else", "if", "elif", "while", "until", "!", "{", "}"]);
1205
- var SHELLS = new Set(["sh", "bash", "zsh", "dash", "ash", "ksh"]);
1206
- var COMMAND_STRING_PROGRAMS = new Set([...SHELLS, "su", "runuser"]);
1258
+ var SHELLS = new Set(["sh", "bash", "zsh", "dash", "ash", "ksh", "fish", "csh", "tcsh", "nu", "xonsh", "elvish", "pwsh", "powershell"]);
1259
+ var SCANNED_PROGRAMS = new Set(["su", "runuser", "flock"]);
1260
+ var COMMAND_STRING_PROGRAMS = new Set([...SHELLS, ...SCANNED_PROGRAMS]);
1261
+ var POWERSHELL = new Set(["pwsh", "powershell"]);
1207
1262
  var SHELL_VALUE_OPTIONS = new Set(["-o", "-O", "--rcfile", "--init-file"]);
1263
+ var FISH_VALUE_OPTIONS = new Set(["-C", "--init-command"]);
1208
1264
  var PRINTERS = new Set(["echo", "printf"]);
1209
1265
  var FIND_EXEC = new Set(["-exec", "-execdir", "-ok", "-okdir"]);
1210
- function wrapper(short = "", long = [], operands = 0) {
1211
- return { short, long, operands };
1266
+ function wrapper(short = "", long = [], operands = 0, runsString = false) {
1267
+ return { short, long, operands, runsString };
1212
1268
  }
1213
1269
  var WRAPPERS = new Map([
1214
1270
  ["command", wrapper()],
@@ -1226,6 +1282,11 @@ var WRAPPERS = new Map([
1226
1282
  ["ionice", wrapper("cnpPu", ["--class", "--classdata", "--pid", "--pgid", "--uid"])],
1227
1283
  ["stdbuf", wrapper("ioe", ["--input", "--output", "--error"])],
1228
1284
  ["chroot", wrapper("", ["--userspec", "--groups"], 1)],
1285
+ ["watch", wrapper("n", ["--interval"], 0, true)],
1286
+ ["flock", wrapper("wE", ["--timeout", "--conflict-exit-code"], 1)],
1287
+ ["parallel", wrapper("jnNSaIPlLdTsC", ["--jobs", "--max-args", "--max-replace-args", "--sshlogin", "--arg-file", "--delimiter", "--colsep"], 0, true)],
1288
+ ["unbuffer", wrapper()],
1289
+ ["systemd-run", wrapper("puEMH", ["--property", "--unit", "--description", "--slice", "--setenv", "--machine", "--host", "--uid", "--gid", "--working-directory", "--nice", "--service-type"])],
1229
1290
  ["xargs", wrapper("IndsEaPL", ["--max-args", "--max-procs", "--max-chars", "--delimiter", "--arg-file", "--eof", "--max-lines", "--process-slot-var"])]
1230
1291
  ]);
1231
1292
  function closerOf(quote) {
@@ -1439,8 +1500,8 @@ function filtered(text, stages) {
1439
1500
 
1440
1501
  // src/stats.ts
1441
1502
  import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync2 } from "fs";
1442
- import { basename, dirname as dirname2, join as join4 } from "path";
1443
- import process11 from "process";
1503
+ import { basename, dirname as dirname3, join as join5 } from "path";
1504
+ import process12 from "process";
1444
1505
  var BYTES_PER_TOKEN = 4;
1445
1506
  var KEY_WORD = /^[a-z][a-z:_-]*$/;
1446
1507
  var KEY_WORDS = 3;
@@ -1449,8 +1510,8 @@ var ASSIGNMENT = /^\w+=/;
1449
1510
  var CHAIN = /&&|\|\||;/;
1450
1511
  var SHELLS2 = new Set(["sh", "bash", "zsh"]);
1451
1512
  var TOP = 10;
1452
- function statsPath(env = process11.env) {
1453
- return join4(stateHome(env), CLI_FOLDER, "out", "stats.jsonl");
1513
+ function statsPath(env = process12.env) {
1514
+ return join5(stateHome(env), CLI_FOLDER, "out", "stats.jsonl");
1454
1515
  }
1455
1516
  function commandKey(command) {
1456
1517
  const [program = "", ...rest] = command.slice(Math.max(0, command.findIndex((word) => !ASSIGNMENT.test(word))));
@@ -1468,7 +1529,7 @@ function commandKey(command) {
1468
1529
  }
1469
1530
  function record(entry, path = statsPath()) {
1470
1531
  try {
1471
- mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
1532
+ mkdirSync2(dirname3(path), { recursive: true, mode: 448 });
1472
1533
  appendFileSync2(path, `${JSON.stringify(entry)}
1473
1534
  `, { mode: 384 });
1474
1535
  } catch {}
@@ -1539,6 +1600,7 @@ class Recorder {
1539
1600
  tailBytes = 0;
1540
1601
  dropped = 0;
1541
1602
  log;
1603
+ logFailed = false;
1542
1604
  constructor(dir) {
1543
1605
  this.dir = dir;
1544
1606
  }
@@ -1548,7 +1610,7 @@ class Recorder {
1548
1610
  this.headBytes += chunk.data.length;
1549
1611
  return;
1550
1612
  }
1551
- writeSync(this.startLog().fd, chunk.data);
1613
+ this.writeToLog(chunk.data);
1552
1614
  this.tail.push(chunk);
1553
1615
  this.tailBytes += chunk.data.length;
1554
1616
  while (this.tail.length > 1 && this.tailBytes - (this.tail[0]?.data.length ?? 0) >= TAIL_BYTES) {
@@ -1557,6 +1619,16 @@ class Recorder {
1557
1619
  this.dropped += gone?.data.length ?? 0;
1558
1620
  }
1559
1621
  }
1622
+ writeToLog(data) {
1623
+ if (this.logFailed)
1624
+ return;
1625
+ try {
1626
+ writeSync(this.startLog().fd, data);
1627
+ } catch (error) {
1628
+ this.logFailed = true;
1629
+ console.error(`out: cannot write the log, the output past ${HEAD_BYTES} bytes is kept in memory only: ${error instanceof Error ? error.message : String(error)}`);
1630
+ }
1631
+ }
1560
1632
  startLog() {
1561
1633
  if (this.log !== undefined)
1562
1634
  return this.log;
@@ -1589,17 +1661,17 @@ class Recorder {
1589
1661
  if (this.dropped === 0)
1590
1662
  return decode(this.chunks);
1591
1663
  return `${decode(this.head)}
1592
- [out: ${this.dropped} bytes left out of memory, all of it is in the log]
1664
+ [out: ${this.dropped} bytes left out of memory${this.logFailed ? "" : ", all of it is in the log"}]
1593
1665
  ${decode(this.tail)}`;
1594
1666
  }
1595
1667
  }
1596
1668
  function replay(chunks) {
1597
1669
  for (const { stream, data } of chunks)
1598
- process12[stream].write(data);
1670
+ process13[stream].write(data);
1599
1671
  }
1600
1672
  function count(recorder, shown, recorded) {
1601
1673
  const baseline = recorded.was.length === 0 || recorder.overflowing ? undefined : filtered(recorder.text, recorded.was);
1602
- record({ at: new Date().toISOString(), dir: process12.cwd(), command: recorded.command, bytes: recorder.bytes, baseline: baseline === undefined ? recorder.bytes : Buffer.byteLength(baseline), shown, folded: shown !== recorder.bytes });
1674
+ record({ at: new Date().toISOString(), dir: process13.cwd(), command: recorded.command, bytes: recorder.bytes, baseline: baseline === undefined ? recorder.bytes : Buffer.byteLength(baseline), shown, folded: shown !== recorder.bytes });
1603
1675
  }
1604
1676
  async function run(command, threshold, was = []) {
1605
1677
  const dir = await logDir();
@@ -1624,7 +1696,7 @@ async function run(command, threshold, was = []) {
1624
1696
  } catch (error) {
1625
1697
  if (!started) {
1626
1698
  console.error(`out: cannot run ${command[0]}: ${error instanceof Error ? error.message : String(error)}`);
1627
- return 127;
1699
+ return error.code === "EACCES" ? 126 : 127;
1628
1700
  }
1629
1701
  console.error(`out: ${error instanceof Error ? error.message : String(error)}`);
1630
1702
  code = 1;
@@ -1663,20 +1735,80 @@ function present(recorder, threshold) {
1663
1735
  replay(recorder.chunks);
1664
1736
  return recorder.bytes;
1665
1737
  }
1666
- process12.stdout.write(printed);
1738
+ process13.stdout.write(printed);
1667
1739
  return Buffer.byteLength(printed);
1668
1740
  }
1669
1741
 
1742
+ // src/saved.ts
1743
+ import { readdir as readdir2, stat as stat2 } from "fs/promises";
1744
+ import { basename as basename2, join as join6 } from "path";
1745
+ async function savedLogs() {
1746
+ const dir = await logDir();
1747
+ const logs = [];
1748
+ for (const name of await readdir2(dir)) {
1749
+ const path = join6(dir, name);
1750
+ try {
1751
+ const info = await stat2(path);
1752
+ if (info.isFile() && name.endsWith(".log"))
1753
+ logs.push({ path, size: info.size, mtimeMs: info.mtimeMs });
1754
+ } catch {
1755
+ continue;
1756
+ }
1757
+ }
1758
+ return logs.toSorted((a, b) => b.mtimeMs - a.mtimeMs || b.path.localeCompare(a.path));
1759
+ }
1760
+ async function pickLog(name) {
1761
+ const logs = await savedLogs();
1762
+ const found = name === undefined ? logs[0] : logs.find((log) => log.path === name || basename2(log.path) === name);
1763
+ if (found === undefined)
1764
+ fail(name === undefined ? "no saved log (a run saves one only when its output is folded, and logs older than a day are removed)" : `${name} is not a saved log, \`out --list\` names them`);
1765
+ return found;
1766
+ }
1767
+ async function printLast() {
1768
+ console.log((await pickLog(undefined)).path);
1769
+ return 0;
1770
+ }
1771
+ async function printList() {
1772
+ const logs = await savedLogs();
1773
+ if (logs.length === 0)
1774
+ fail("no saved log", 1);
1775
+ console.log(table(logs.map((log) => [log.path, `${Math.ceil(log.size / 1024)} KiB`, formatAge(log.mtimeMs)])));
1776
+ return 0;
1777
+ }
1778
+ async function grepLog(source, name) {
1779
+ let pattern;
1780
+ try {
1781
+ pattern = new RegExp(source);
1782
+ } catch (error) {
1783
+ return badUsage(`--grep is no valid regular expression: ${error instanceof Error ? error.message : String(error)}`);
1784
+ }
1785
+ const lines = (await Bun.file((await pickLog(name)).path).text()).split(`
1786
+ `);
1787
+ const hits = lines.flatMap((line, i) => pattern.test(line) ? [`${i + 1}: ${line}`] : []);
1788
+ if (hits.length === 0)
1789
+ fail(`no line of the log matches ${source}`, 1);
1790
+ console.log(hits.join(`
1791
+ `));
1792
+ return 0;
1793
+ }
1794
+
1670
1795
  // src/cli.ts
1671
1796
  var USAGE = `out [-n <lines>] -- <command> [args...]: runs the command and shortens its output when it is long, exiting with its code
1672
1797
  <command> | out [-n <lines>]: shortens the output piped in
1673
1798
  out --stats [--since 7d] [--json]: the runs recorded and about how many tokens they saved
1799
+ out --last: the path of the most recent full log
1800
+ out --list: the saved logs, the newest first
1801
+ out --grep <regex> [--log <name>]: the lines of a saved log (the last by default) matching the regex, with their line numbers
1674
1802
 
1675
- Each run is recorded in ~/.local/state/kaynooo-cli/out/stats.jsonl with the words naming the command, never its arguments; tokens are counted as bytes / 4.
1676
- Exits 0 when the command does, with its own code otherwise, 127 when it cannot be started, 2 on a usage error. Piped into, it exits 0.`;
1803
+ Each run is recorded in ~/.local/state/kaynooo-cli/out/stats.jsonl with the words naming the command, never its arguments; tokens are counted as bytes / 4.`;
1804
+ var EXITS = [[0, "the command did, or the output piped in was shortened, or the log was found"], ["its own", "the code of a command that failed"], [1, "--grep matched nothing, or --list found no log"], [126, "the command cannot be run (not executable)"], [127, "the command cannot be started"], [2, "a usage error, or no such saved log"]];
1677
1805
  var OPTIONS = {
1678
1806
  lines: { type: "string", short: "n", description: "longest output left untouched (default 150)" },
1679
1807
  was: { type: "string", multiple: true, description: "a tail, head or grep stage out stands in for, so the stats count against what it would have printed (repeatable, in pipeline order)" },
1808
+ last: { type: "boolean", description: "print the path of the most recent full log" },
1809
+ list: { type: "boolean", description: "list the saved logs, the newest first" },
1810
+ grep: { type: "string", description: "print the lines of a saved log matching this regular expression, with their line numbers" },
1811
+ log: { type: "string", description: "with --grep, the saved log to read (its name or path) instead of the last" },
1680
1812
  stats: { type: "boolean", description: "print the runs recorded and the tokens saved" },
1681
1813
  since: { type: "string", description: "with --stats, only the runs this recent (7d, 12h)" },
1682
1814
  json: jsonOption("with --stats, {runs, folded, bytes, shown, tokensSaved, commands}")
@@ -1692,8 +1824,21 @@ async function stats(since, json) {
1692
1824
  }
1693
1825
  async function main(argv) {
1694
1826
  const bare = argv[0] !== undefined && !argv[0].startsWith("-");
1695
- const parsed = parse(bare ? [] : argv, USAGE, OPTIONS, { rest: true, replaces: "piping a noisy command through tail or head", examples: ["out -- bun install", ["out -n 300 -- bun run build", "up to 300 lines left untouched"], ["bun run build 2>&1 | out", "what is piped in"], ["out --stats --since 7d", "the tokens saved this week"]] });
1827
+ const parsed = parse(bare ? [] : argv, USAGE, OPTIONS, { rest: true, exits: EXITS, replaces: "piping a noisy command through tail or head", examples: ["out -- bun install", ["out -n 300 -- bun run build", "up to 300 lines left untouched"], ["bun run build 2>&1 | out", "what is piped in"], ["out --stats --since 7d", "the tokens saved this week"]] });
1696
1828
  const { values } = parsed;
1829
+ const reads = [values.last === true, values.list === true, values.grep !== undefined, values.stats === true].filter(Boolean).length;
1830
+ if (reads > 1)
1831
+ badUsage("give one of --last, --list, --grep and --stats");
1832
+ if (reads === 1 && ((parsed.rest ?? []).length > 0 || parsed.positionals.length > 0))
1833
+ badUsage("--last, --list, --grep and --stats run no command");
1834
+ if (values.log !== undefined && values.grep === undefined)
1835
+ badUsage("--log goes with --grep");
1836
+ if (values.last === true)
1837
+ return printLast();
1838
+ if (values.list === true)
1839
+ return printList();
1840
+ if (values.grep !== undefined)
1841
+ return grepLog(values.grep, values.log);
1697
1842
  if (values.stats === true) {
1698
1843
  if ((parsed.rest ?? []).length > 0 || parsed.positionals.length > 0)
1699
1844
  badUsage("--stats runs no command");
@@ -1712,4 +1857,4 @@ async function main(argv) {
1712
1857
  ${USAGE}`);
1713
1858
  return run(command, lines, was);
1714
1859
  }
1715
- process13.exitCode = await runMain({ name: "out", version, run: main });
1860
+ process14.exitCode = await runMain({ name: "out", run: main });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kaynooo/cli-out",
3
3
  "type": "module",
4
- "version": "0.7.1",
4
+ "version": "0.8.0",
5
5
  "description": "out: shortens noisy command output for Claude Code, folding long logs and keeping errors, with the full log saved to a file.",
6
6
  "author": "Kaynooo <contact@kaynooo.fr>",
7
7
  "license": "MIT",
@@ -28,6 +28,6 @@
28
28
  "out": "$npm_execpath src/cli.ts"
29
29
  },
30
30
  "devDependencies": {
31
- "@kaynooo/cli-kit": "0.20.0"
31
+ "@kaynooo/cli-kit": "0.21.0"
32
32
  }
33
33
  }