@kaynooo/cli-out 0.6.0 → 0.7.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 +3 -3
  2. package/dist/cli.js +162 -28
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -11,13 +11,13 @@ out --stats --since 7d
11
11
 
12
12
  ## Behaviour
13
13
 
14
- - Output of at most 150 lines (`-n <lines>` changes it) passes through byte for byte, stdout and stderr to their own streams.
14
+ - Output of at most 150 lines (`-n <lines>` changes it) and about 8 KB (55 bytes per allowed line) passes through byte for byte, stdout and stderr to their own streams.
15
15
  - Longer output is captured (both streams, in arrival order) and printed folded, after a header with the line count, how many lines were left out and the path of the full log.
16
16
  - Lines alike in shape (ANSI codes stripped; numbers, hashes, durations, percentages, paths and file names normalized) fold into one line with a count and the last line: `[1/300] compiling a.ts (x300, last: [300/300] compiling z.ts)`.
17
17
  - A block of 2 to 20 lines repeated back to back is shown once, followed by `(block repeated N times)`.
18
18
  - A line or block seen earlier is replaced by `(same as line N)`, N being a line of the full log, and the header lists the most repeated messages with their counts.
19
19
  - Carriage-return progress (spinners, bars) keeps only the final state of each line.
20
- - 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.
20
+ - 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
21
  - 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
22
  - 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
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.
@@ -26,7 +26,7 @@ out --stats --since 7d
26
26
 
27
27
  - 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
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.
29
- - `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.
29
+ - `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
30
 
31
31
  ## Wrapping from a hook
32
32
 
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ import process2 from "process";
15
15
  // ../kit/src/style.ts
16
16
  import process from "process";
17
17
 
18
- // ../../node_modules/.bun/@kaynooo+utils@2.31.0+d0769e28dc4dad16/node_modules/@kaynooo/utils/dist/chunk-mtetydvc.js
18
+ // ../../node_modules/.bun/@kaynooo+utils@2.31.2+d0769e28dc4dad16/node_modules/@kaynooo/utils/dist/chunk-mtetydvc.js
19
19
  function x(s) {
20
20
  let r = s.startsWith("#") ? s.slice(1).toLowerCase() : s.toLowerCase();
21
21
  if (!/^[a-f0-9]+$/.test(r))
@@ -235,7 +235,7 @@ class e {
235
235
  }
236
236
  }
237
237
 
238
- // ../../node_modules/.bun/@kaynooo+utils@2.31.0+d0769e28dc4dad16/node_modules/@kaynooo/utils/dist/chunk-y1msbssb.js
238
+ // ../../node_modules/.bun/@kaynooo+utils@2.31.2+d0769e28dc4dad16/node_modules/@kaynooo/utils/dist/chunk-y1msbssb.js
239
239
  var r = 39;
240
240
  var o = 49;
241
241
  var f2 = "\x1B[";
@@ -402,8 +402,12 @@ class HelpShown extends Exit {
402
402
 
403
403
  class Completing {
404
404
  options;
405
- constructor(options) {
405
+ positionals;
406
+ candidates;
407
+ constructor(options, positionals = [], candidates = {}) {
406
408
  this.options = options;
409
+ this.positionals = positionals;
410
+ this.candidates = candidates;
407
411
  }
408
412
  }
409
413
  var completion = { active: false };
@@ -446,8 +450,10 @@ function exampleRows(examples, colors = paint(process3.stdout)) {
446
450
  function optionRows(options, colors = paint(process3.stdout)) {
447
451
  const rows = Object.entries(options).map(([name, option]) => {
448
452
  const short = option.short === undefined ? " " : `${colors.cyan(`-${option.short}`)}, `;
449
- const value = option.type === "string" ? ` ${colors.dim("<value>")}` : "";
450
- return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === "string" ? " <value>".length : 0), description: option.description ?? "" };
453
+ const named = option.choices === undefined ? "<value>" : `<${option.choices.join("|")}>`;
454
+ const placeholder = option.optional === undefined ? named : `[${named}]`;
455
+ const value = option.type === "string" ? ` ${colors.dim(placeholder)}` : "";
456
+ return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === "string" ? placeholder.length + 1 : 0), description: option.description ?? "" };
451
457
  });
452
458
  const width = Math.max(0, ...rows.map((row) => row.width));
453
459
  return rows.map((row) => row.description === "" ? ` ${row.flag}` : ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
@@ -473,14 +479,13 @@ function applyOutput(color, plain, env = process3.env) {
473
479
  function parse(args, usage, options, settings = {}) {
474
480
  const output = Object.fromEntries(Object.entries(OUTPUT_OPTIONS).filter(([name]) => !(name in options)));
475
481
  const all = { ...options, ...output, help: { type: "boolean", short: "h", description: "show this help" } };
482
+ const listing = listed(all, settings.allowNegative === true);
476
483
  if (completion.active)
477
- throw new Completing(all);
478
- const separator = settings.rest === true ? args.indexOf("--") : -1;
479
- const own = separator === -1 ? args : args.slice(0, separator);
480
- const rest = separator === -1 ? undefined : args.slice(separator + 1);
484
+ throw new Completing(listing, settings.positionals, settings.candidates);
485
+ const { own, rest } = settings.trailing === true ? splitTrailing(args, all, settings.positionals?.length ?? 0) : splitRest(args, settings.rest === true);
481
486
  let parsed;
482
487
  try {
483
- parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
488
+ parsed = parseArgs({ args: joinDashValues(withBare(own, all), all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
484
489
  } catch (error) {
485
490
  const message = parseError(error);
486
491
  throw new UsageError(message, nearestOption(message, all, settings.allowNegative === true));
@@ -488,12 +493,84 @@ function parse(args, usage, options, settings = {}) {
488
493
  const values = parsed.values;
489
494
  applyOutput("color" in output ? values.color : undefined, "plain" in output ? values.plain : undefined);
490
495
  if (values.help === true) {
491
- console.log(wantsHelpJson() ? JSON.stringify(description(usage, settings)) : helpText(usage, all, undefined, settings));
496
+ console.log(wantsHelpJson() ? JSON.stringify(description(usage, settings)) : helpText(usage, listing, undefined, settings));
492
497
  throw new HelpShown;
493
498
  }
499
+ checkChoices(parsed.values, all);
500
+ checkConflicts(parsed.values, all);
494
501
  if (settings.positionals !== undefined)
495
502
  checkPositionals(parsed.positionals, settings.positionals);
496
- return { ...parsed, positionals: parsed.positionals, rest };
503
+ return { ...parsed, values: parsed.values, positionals: parsed.positionals, rest };
504
+ }
505
+ function splitRest(args, passes) {
506
+ const separator = passes ? args.indexOf("--") : -1;
507
+ return { own: separator === -1 ? args : args.slice(0, separator), rest: separator === -1 ? undefined : args.slice(separator + 1) };
508
+ }
509
+ function splitTrailing(args, options, count) {
510
+ const takesValue = (word) => !word.includes("=") && Object.entries(options).some(([name, { short, type }]) => type === "string" && (word === `--${name}` || word === `-${short}`));
511
+ let seen = 0;
512
+ if (count === 0)
513
+ return { own: args, rest: undefined };
514
+ for (let at = 0;at < args.length; at++) {
515
+ const word = args[at];
516
+ if (word === "--")
517
+ break;
518
+ if (word.startsWith("-")) {
519
+ if (takesValue(word))
520
+ at++;
521
+ continue;
522
+ }
523
+ seen++;
524
+ if (seen === count)
525
+ return { own: args.slice(0, at + 1), rest: args.slice(at + 1) };
526
+ }
527
+ return { own: args, rest: undefined };
528
+ }
529
+ function listed(options, allowNegative) {
530
+ if (!allowNegative)
531
+ return options;
532
+ return Object.fromEntries(Object.entries(options).flatMap(([name, option]) => {
533
+ if (option.type !== "boolean" || option.negative === undefined)
534
+ return [[name, option]];
535
+ return [...option.description === undefined ? [] : [[name, option]], [`no-${name}`, { type: "boolean", description: option.negative }]];
536
+ }));
537
+ }
538
+ function withBare(args, options) {
539
+ const bare = new Map(Object.entries(options).flatMap(([name, { short, optional }]) => optional === undefined ? [] : [[`--${name}`, optional], ...short === undefined ? [] : [[`-${short}`, optional]]]));
540
+ if (bare.size === 0)
541
+ return args;
542
+ const separator = args.indexOf("--");
543
+ const own = separator === -1 ? args : args.slice(0, separator);
544
+ return [...own.flatMap((arg, index) => {
545
+ const optional = bare.get(arg);
546
+ const next = own[index + 1];
547
+ const value = next !== undefined && (optional?.accepts === undefined ? !next.startsWith("-") : optional.accepts.test(next));
548
+ return optional === undefined || value ? [arg] : [arg, optional.bare];
549
+ }), ...separator === -1 ? [] : args.slice(separator)];
550
+ }
551
+ function checkConflicts(values, options) {
552
+ const given = (name) => values[name] !== undefined && values[name] !== false;
553
+ for (const [name, { conflicts = [] }] of Object.entries(options)) {
554
+ const other = given(name) ? conflicts.find(given) : undefined;
555
+ if (other !== undefined)
556
+ badUsage(`--${name} cannot be used with --${other}`);
557
+ }
558
+ }
559
+ function checkChoices(values, options) {
560
+ for (const [name, option] of Object.entries(options)) {
561
+ if (option.choices === undefined)
562
+ continue;
563
+ const given = values[name];
564
+ for (const value of Array.isArray(given) ? given : [given]) {
565
+ if (typeof value === "string")
566
+ oneOf(value, name, option.choices);
567
+ }
568
+ }
569
+ }
570
+ function oneOf(value, name, choices) {
571
+ if (!choices.includes(value))
572
+ badUsage(`${name.startsWith("<") ? name : `--${name}`} takes one of ${choices.join(", ")}, not ${value}`);
573
+ return value;
497
574
  }
498
575
  function checkPositionals(given, names) {
499
576
  const variadic = names.at(-1)?.replace("]", "").endsWith("...") === true;
@@ -727,22 +804,55 @@ function reportThrown(prefix, error, redact = (text) => text, json = false) {
727
804
  }
728
805
  var COMPLETE = "__complete";
729
806
  var FILES = "__files__";
730
- async function optionsOf(run) {
807
+ async function completingOf(run) {
731
808
  completion.active = true;
732
809
  try {
733
810
  await run([]);
734
811
  } catch (error) {
735
812
  if (error instanceof Completing)
736
- return error.options;
813
+ return error;
737
814
  } finally {
738
815
  completion.active = false;
739
816
  }
740
- return {};
817
+ return new Completing({});
818
+ }
819
+ function candidateLine(candidate) {
820
+ return typeof candidate === "string" ? candidate : candidate.join("\t");
821
+ }
822
+ function positionalBeingTyped(before, { options, positionals }) {
823
+ const takesValue = (word) => !word.includes("=") && Object.entries(options).some(([name, { short, type }]) => type === "string" && (word === `--${name}` || word === `-${short}`));
824
+ let index = 0;
825
+ let at = 0;
826
+ while (at < before.length) {
827
+ const word = before[at];
828
+ if (word === "--") {
829
+ index += before.length - at - 1;
830
+ break;
831
+ }
832
+ if (!word.startsWith("-"))
833
+ index++;
834
+ else if (takesValue(word))
835
+ at++;
836
+ at++;
837
+ }
838
+ const last = positionals.at(-1);
839
+ if (index < positionals.length)
840
+ return positionals[index];
841
+ return last?.replace("]", "").endsWith("...") === true ? last : undefined;
741
842
  }
742
843
  async function leafCandidates(run, words) {
743
- if (!(words.at(-1) ?? "").startsWith("-"))
744
- return [FILES];
745
- return Object.entries(await optionsOf(run)).map(([name, option]) => option.description === undefined ? `--${name}` : `--${name} ${option.description}`);
844
+ const completing = await completingOf(run);
845
+ if ((words.at(-1) ?? "").startsWith("-"))
846
+ return Object.entries(completing.options).map(([name, option]) => option.description === undefined ? `--${name}` : `--${name} ${option.description}`);
847
+ const flag = words.at(-2);
848
+ const option = flag?.startsWith("-") === true ? Object.entries(completing.options).find(([name, { short }]) => flag === `--${name}` || flag === `-${short}`)?.[1] : undefined;
849
+ if (option?.type === "string") {
850
+ const values = option.choices === undefined ? await option.candidates?.() : option.choices;
851
+ return values === undefined ? [FILES] : values.map(candidateLine);
852
+ }
853
+ const name = positionalBeingTyped(words.slice(0, -1), completing);
854
+ const values = name === undefined ? undefined : await completing.candidates[name]?.();
855
+ return values === undefined ? [FILES] : values.map(candidateLine);
746
856
  }
747
857
  async function printCandidates(found) {
748
858
  for (const line of await found)
@@ -819,7 +929,7 @@ var {Glob } = globalThis.Bun;
819
929
  var SESSION_FILES = new Glob("*.json");
820
930
  var ACCOUNT_DIRS = new Glob("*/sessions");
821
931
  // package.json
822
- var version = "0.6.0";
932
+ var version = "0.7.0";
823
933
 
824
934
  // src/shape.ts
825
935
  var HEX_LITERAL = /\b0x[0-9a-f]+\b/gi;
@@ -844,6 +954,9 @@ var HEAD = 40;
844
954
  var TAIL = 60;
845
955
  var MAX_BLOCK = 20;
846
956
  var MIN_REFERENCED = 20;
957
+ var BYTES_PER_LINE = 55;
958
+ var MAX_WIDTH = 300;
959
+ var MAX_KEPT_IMPORTANT = 40;
847
960
  var TOP_REPEATED = 3;
848
961
  var LINE_BREAK = /\r\n|\r|\n/;
849
962
  var LINE_END = /(?:\r\n|\r|\n)$/;
@@ -854,7 +967,10 @@ function lineCount(text) {
854
967
  return LINE_END.test(text) ? count - 1 : count;
855
968
  }
856
969
  function shouldFold(text, threshold) {
857
- return lineCount(text) > threshold;
970
+ return lineCount(text) > threshold || Buffer.byteLength(text) > threshold * BYTES_PER_LINE;
971
+ }
972
+ function cutWide(line) {
973
+ return line.length > MAX_WIDTH ? `${line.slice(0, MAX_WIDTH)}... [+${line.length - MAX_WIDTH} chars]` : line;
858
974
  }
859
975
  function finalState(line) {
860
976
  const segments = line.split("\r").filter((segment) => segment !== "");
@@ -948,11 +1064,16 @@ function toEntries(units) {
948
1064
  function fold(text, { threshold, logPath }) {
949
1065
  const lines = splitLines(text);
950
1066
  const { entries, references, repeated } = toEntries(toUnits(lines, lines.map((line) => shape(line))));
951
- const head = Math.min(HEAD, Math.floor(threshold / 3));
952
- const tail = Math.min(TAIL, Math.floor(threshold / 2));
1067
+ for (const entry of entries)
1068
+ entry.text = cutWide(entry.text);
1069
+ const heavy = entries.reduce((sum, { text }) => sum + text.length + 1, 0) > threshold * BYTES_PER_LINE;
1070
+ const shrink = heavy && entries.length <= threshold ? 2 : 1;
1071
+ const head = Math.floor(Math.min(HEAD, Math.floor(threshold / 3)) / shrink);
1072
+ const tail = Math.floor(Math.min(TAIL, Math.floor(threshold / 2)) / shrink);
953
1073
  const out = [];
954
1074
  let dropped = 0;
955
- if (entries.length <= threshold) {
1075
+ let kept = 0;
1076
+ if (entries.length <= threshold && !heavy) {
956
1077
  out.push(...entries.map(({ text }) => text));
957
1078
  } else {
958
1079
  const middleEnd = entries.length - tail;
@@ -969,7 +1090,8 @@ function fold(text, { threshold, logPath }) {
969
1090
  out.push(entry.text);
970
1091
  continue;
971
1092
  }
972
- if (entry.important) {
1093
+ if (entry.important && kept < MAX_KEPT_IMPORTANT) {
1094
+ kept++;
973
1095
  closeGap();
974
1096
  out.push(entry.text);
975
1097
  continue;
@@ -1307,29 +1429,41 @@ async function readEntries(path = statsPath(), since = 0) {
1307
1429
  }
1308
1430
  });
1309
1431
  }
1432
+ function median(values) {
1433
+ const sorted = values.toSorted((a, b) => a - b);
1434
+ const middle = Math.floor(sorted.length / 2);
1435
+ if (sorted.length === 0)
1436
+ return;
1437
+ return sorted.length % 2 === 1 ? sorted[middle] : ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2;
1438
+ }
1310
1439
  function tokens(bytes) {
1311
1440
  return Math.round(bytes / BYTES_PER_TOKEN);
1312
1441
  }
1313
1442
  function formatNumber(value) {
1314
1443
  return value.toLocaleString("en-US");
1315
1444
  }
1445
+ function cut2(runs) {
1446
+ const value = median(runs.filter((run) => run.folded && run.baseline > 0).map((run) => (1 - run.shown / run.baseline) * 100));
1447
+ return value === undefined ? undefined : Math.round(value);
1448
+ }
1316
1449
  function summarize(entries) {
1317
1450
  const commands = Map.groupBy(entries, (entry) => entry.command).entries().map(([command, runs]) => ({
1318
1451
  command,
1319
1452
  runs: runs.length,
1320
1453
  folded: runs.filter((run) => run.folded).length,
1321
- tokensSaved: tokens(runs.reduce((sum, run) => sum + run.baseline - run.shown, 0))
1454
+ tokensSaved: tokens(runs.reduce((sum, run) => sum + run.baseline - run.shown, 0)),
1455
+ medianCut: cut2(runs)
1322
1456
  }));
1323
1457
  const sum = (field) => entries.reduce((total, entry) => total + entry[field], 0);
1324
- return { runs: entries.length, folded: entries.filter((entry) => entry.folded).length, bytes: sum("bytes"), shown: sum("shown"), tokensSaved: tokens(sum("baseline") - sum("shown")), commands: [...commands].sort((a, b) => b.tokensSaved - a.tokensSaved) };
1458
+ return { runs: entries.length, folded: entries.filter((entry) => entry.folded).length, bytes: sum("bytes"), shown: sum("shown"), tokensSaved: tokens(sum("baseline") - sum("shown")), foldedShare: entries.length === 0 ? 0 : Math.round(entries.filter((entry) => entry.folded).length / entries.length * 100), commands: [...commands].sort((a, b) => b.tokensSaved - a.tokensSaved) };
1325
1459
  }
1326
1460
  function renderSummary(summary, period) {
1327
1461
  if (summary.runs === 0)
1328
1462
  return `no run of out recorded ${period}`;
1329
1463
  const top = summary.commands.slice(0, TOP).filter((each) => each.tokensSaved !== 0);
1330
1464
  return [
1331
- `${formatNumber(summary.runs)} runs ${period}, ${formatNumber(summary.folded)} folded: ${formatNumber(summary.bytes)} bytes printed, ${formatNumber(summary.shown)} shown, about ${formatNumber(summary.tokensSaved)} tokens saved (bytes / ${BYTES_PER_TOKEN})`,
1332
- ...top.length === 0 ? [] : ["", table([["command", "runs", "folded", "tokens saved"], ...top.map((each) => [each.command, formatNumber(each.runs), formatNumber(each.folded), formatNumber(each.tokensSaved)])])]
1465
+ `${formatNumber(summary.runs)} runs ${period}, ${formatNumber(summary.folded)} folded (${summary.foldedShare}%): ${formatNumber(summary.bytes)} bytes printed, ${formatNumber(summary.shown)} shown, about ${formatNumber(summary.tokensSaved)} tokens saved (bytes / ${BYTES_PER_TOKEN})`,
1466
+ ...top.length === 0 ? [] : ["", table([["command", "runs", "folded", "median cut", "tokens saved"], ...top.map((each) => [each.command, formatNumber(each.runs), formatNumber(each.folded), each.medianCut === undefined ? "-" : `${each.medianCut}%`, formatNumber(each.tokensSaved)])])]
1333
1467
  ].join(`
1334
1468
  `);
1335
1469
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kaynooo/cli-out",
3
3
  "type": "module",
4
- "version": "0.6.0",
4
+ "version": "0.7.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.15.0"
31
+ "@kaynooo/cli-kit": "0.18.0"
32
32
  }
33
33
  }