@crafter/cli-tree 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -4,13 +4,21 @@ import { printTree, treeToString, treeToHtml } from "./index";
4
4
  import type { TreeOptions } from "./types";
5
5
  import { parseWorkflow, validateWorkflow, flowToAnsi, flowToString, flowToHtml } from "./flow";
6
6
  import type { FlowRenderOptions } from "./flow/types";
7
- import { mineCli, minedToFlowWorkflow } from "./miner";
7
+ import {
8
+ mineCli,
9
+ minedToFlowWorkflow,
10
+ mineCrossCli,
11
+ crossCliToFlowWorkflow,
12
+ sparkline,
13
+ labeledSparkline,
14
+ formatTimeRange,
15
+ } from "./miner";
8
16
  import { runArchaeology, NullDelegate } from "./archaeology";
9
17
 
10
18
  const args = process.argv.slice(2);
11
19
  const subcommand = args[0];
12
20
 
13
- const HELP_SUBCOMMANDS = new Set(["flow", "mine", "archaeology"]);
21
+ const HELP_SUBCOMMANDS = new Set(["flow", "mine", "archaeology", "safe-help", "cross"]);
14
22
  const isHelpFlag = args.includes("--help") || args.includes("-h");
15
23
 
16
24
  if (!subcommand || (isHelpFlag && !HELP_SUBCOMMANDS.has(subcommand ?? ""))) {
@@ -22,6 +30,8 @@ if (isHelpFlag) {
22
30
  if (subcommand === "flow") printFlowHelp();
23
31
  else if (subcommand === "mine") printMineHelp();
24
32
  else if (subcommand === "archaeology") printArchaeologyHelp();
33
+ else if (subcommand === "safe-help") printSafeHelpHelp();
34
+ else if (subcommand === "cross") printCrossHelp();
25
35
  else printMainHelp();
26
36
  process.exit(0);
27
37
  }
@@ -32,6 +42,10 @@ if (subcommand === "flow") {
32
42
  await runMine(args.slice(1));
33
43
  } else if (subcommand === "archaeology") {
34
44
  await runArchaeologyCmd(args.slice(1));
45
+ } else if (subcommand === "safe-help") {
46
+ await runSafeHelp(args.slice(1));
47
+ } else if (subcommand === "cross") {
48
+ await runCross(args.slice(1));
35
49
  } else {
36
50
  await runTree(args);
37
51
  }
@@ -44,23 +58,101 @@ function printMainHelp() {
44
58
  clitree <binary> [options] Render command tree from --help
45
59
  clitree flow <file> [options] Render a workflow YAML as a DAG
46
60
  clitree mine <binary> [options] Mine shell history for workflows
61
+ clitree cross [options] Detect cross-CLI workflows (git + gh, docker + kubectl)
47
62
  clitree archaeology <binary> [options] Full analysis: tree + mining + (LLM)
63
+ clitree safe-help <binary> [sub...] Fetch clean help for any CLI (no pager, no overstriking)
48
64
 
49
65
  Examples:
50
66
  clitree docker # basic tree
51
67
  clitree mine git # "what workflows do I repeat with git?"
68
+ clitree cross # cross-CLI flows across your history
52
69
  clitree archaeology bun # tree + mining + LLM archaeology
70
+ clitree safe-help git commit # avoid the git man-page pager trap
53
71
 
54
72
  Subcommands:
55
73
  tree Parse --help output (default; passing a binary name invokes this)
56
74
  flow Render a workflow YAML file as an ASCII DAG
57
- mine Discover workflows from your shell history
75
+ mine Discover workflows from your shell history (single CLI)
76
+ cross Discover workflows that span multiple CLIs (e.g. git → gh)
58
77
  archaeology Run full analysis (tree + mine + LLM proposals when available)
78
+ safe-help Return inline help for any CLI, handling pager/overstrike edge cases
59
79
 
60
80
  Help for a subcommand: clitree <subcommand> --help
61
81
  `);
62
82
  }
63
83
 
84
+ function printCrossHelp() {
85
+ console.log(`
86
+ clitree cross — Mine cross-CLI workflows from your shell history
87
+
88
+ Usage:
89
+ clitree cross [options]
90
+
91
+ This is mineCli's bigger sibling. Instead of filtering history to a single
92
+ binary, it looks at every command in a session and finds sequences that weave
93
+ between tools — e.g.:
94
+
95
+ git push → gh pr create
96
+ docker build → docker push → kubectl apply
97
+ bun test → git commit → git push
98
+
99
+ These patterns are invisible to 'clitree mine <cli>' because they cross boundaries.
100
+
101
+ Examples:
102
+ clitree cross # top 10 cross-CLI workflows
103
+ clitree cross --top-k 5 # just the top 5
104
+ clitree cross --only git,gh,docker # restrict to a set of CLIs
105
+ clitree cross --format json # raw data
106
+
107
+ Options:
108
+ --top-k <n> Max workflows to return (default: 10)
109
+ --min-support <n> Minimum occurrences (default: 3)
110
+ --min-path-length <n> Minimum chain length (default: 2)
111
+ --max-path-length <n> Maximum chain length (default: 5)
112
+ --min-distinct-clis <n> Require at least this many distinct CLIs (default: 2)
113
+ --only <csv> Whitelist: only include these CLIs (comma-separated)
114
+ --format <fmt> ansi (default) or json
115
+ --no-color Disable ANSI colors
116
+ --no-flow Skip DAG rendering of the top workflow
117
+ -h, --help Show this help
118
+ `);
119
+ }
120
+
121
+ function printSafeHelpHelp() {
122
+ console.log(`
123
+ clitree safe-help — Fetch clean help output for any CLI
124
+
125
+ Usage:
126
+ clitree safe-help <binary> [subcommand...] [options]
127
+
128
+ Why this exists:
129
+ Running '<cli> --help' directly is a minefield on agents and scripts:
130
+ - 'git commit --help' opens a pager in a TTY and hangs
131
+ - Piping 'git <sub> --help' returns nroff with overstrike (ffiixxuupp)
132
+ - Some CLIs use 'help <sub>' instead of '<sub> --help'
133
+ - macOS man pages emit \\b sequences that need col -bx
134
+
135
+ safe-help tries the right variants in order, cleans overstrikes, and
136
+ returns plain text every time.
137
+
138
+ Strategy (in order):
139
+ 1. <cli> <sub> -h — short inline help
140
+ 2. <cli> <sub> --help — long help (captured, overstrike-stripped)
141
+ 3. <cli> help <sub> — alternate syntax
142
+ 4. MANPAGER=cat man <cli>-<sub> | col -bx — final man fallback
143
+
144
+ Examples:
145
+ clitree safe-help git commit
146
+ clitree safe-help docker run
147
+ clitree safe-help kubectl get pods
148
+ clitree safe-help bun install
149
+
150
+ Options:
151
+ --no-color Disable ANSI codes (auto-detects non-TTY)
152
+ -h, --help Show this help
153
+ `);
154
+ }
155
+
64
156
  function printMineHelp() {
65
157
  console.log(`
66
158
  clitree mine — Mine your shell history for CLI workflows
@@ -70,22 +162,26 @@ function printMineHelp() {
70
162
 
71
163
  Examples:
72
164
  clitree mine git
165
+ clitree mine git --top-k 3 # render top 3 workflows as DAGs
73
166
  clitree mine docker --min-support 5 --with-flow
74
167
  clitree mine bun --format json > bun-flows.json
75
168
  clitree mine git --no-color | tee report.txt
169
+ clitree mine git --no-activity # skip temporal sparklines
76
170
 
77
171
  Options:
78
172
  --min-support <n> Minimum occurrences to count as a workflow (default: 3)
79
173
  --history-path <p> Custom shell history path (default: ~/.zsh_history)
80
174
  --format <fmt> Output format: ansi (default), json
81
- --max-paths <n> Max workflows to show (default: 10)
82
- --with-flow Always render the top workflow as an ASCII DAG
83
- --no-flow Never render the DAG (overrides auto-detect)
175
+ --max-paths <n> Max workflows to show in text list (default: 10)
176
+ --top-k <n> Render top N workflows as DAGs (default: 1)
177
+ --with-flow Include 2-step workflows in DAG rendering
178
+ --no-flow Skip DAG rendering entirely
179
+ --no-activity Skip hour/day/30-day activity sparklines
84
180
  --no-color Disable ANSI colors (also auto-disabled when stdout is not a TTY)
85
181
  -h, --help Show this help
86
182
 
87
- By default, the top workflow is rendered as a DAG only when it has 3+ steps.
88
- Use --with-flow to force it on even for 2-step chains, or --no-flow to skip.
183
+ By default, the top 3+ step workflow is rendered as a DAG.
184
+ --top-k bumps that to N distinct workflows stacked on top of each other.
89
185
  `);
90
186
  }
91
187
 
@@ -272,6 +368,138 @@ async function readStdin(): Promise<string> {
272
368
  return Buffer.concat(chunks).toString("utf-8");
273
369
  }
274
370
 
371
+ function stripOverstrike(text: string): string {
372
+ // Strip nroff bold via char\bchar, underline via _\bchar, and stray control chars.
373
+ // This is what `col -bx` does.
374
+ return text
375
+ .replace(/(.)\b\1/g, "$1")
376
+ .replace(/_\b(.)/g, "$1")
377
+ .replace(/\b/g, "")
378
+ .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
379
+ }
380
+
381
+ function looksLikeHelp(text: string): boolean {
382
+ if (!text.trim()) return false;
383
+ if (text.length < 40) return false;
384
+ const firstChunk = text.slice(0, 200).toLowerCase();
385
+ // Error messages from unknown subcommands or unsupported flags
386
+ if (/(unknown|invalid|no such|not a \w+ command|flag needs an argument|unrecognized)/.test(firstChunk)) {
387
+ return false;
388
+ }
389
+ // "Run '... --help' for more information" is a hint, not the help itself
390
+ if (/run '.+' for more information/.test(firstChunk)) return false;
391
+ return true;
392
+ }
393
+
394
+ async function tryHelpVariant(binary: string, argv: string[], timeoutMs = 5000): Promise<string | null> {
395
+ try {
396
+ const proc = Bun.spawn([binary, ...argv], {
397
+ stdout: "pipe",
398
+ stderr: "pipe",
399
+ env: { ...process.env, PAGER: "cat", MANPAGER: "cat", GIT_PAGER: "cat" },
400
+ });
401
+
402
+ const timer = setTimeout(() => proc.kill(), timeoutMs);
403
+
404
+ const [stdout, stderr] = await Promise.all([
405
+ new Response(proc.stdout).text(),
406
+ new Response(proc.stderr).text(),
407
+ ]);
408
+ clearTimeout(timer);
409
+ await proc.exited;
410
+
411
+ const combined = stdout || stderr;
412
+ if (!looksLikeHelp(combined)) return null;
413
+ return stripOverstrike(combined);
414
+ } catch {
415
+ return null;
416
+ }
417
+ }
418
+
419
+ async function tryManPage(binary: string, subPath: string[], timeoutMs = 5000): Promise<string | null> {
420
+ // Try 'man binary-sub' (git-commit), then 'man binary sub' as a fallback.
421
+ const candidates = [
422
+ subPath.length > 0 ? [`${binary}-${subPath.join("-")}`] : [binary],
423
+ [binary, ...subPath],
424
+ ];
425
+
426
+ for (const args of candidates) {
427
+ try {
428
+ const proc = Bun.spawn(["man", ...args], {
429
+ stdout: "pipe",
430
+ stderr: "pipe",
431
+ env: { ...process.env, MANPAGER: "cat", PAGER: "cat" },
432
+ });
433
+
434
+ const timer = setTimeout(() => proc.kill(), timeoutMs);
435
+ const [stdout] = await Promise.all([
436
+ new Response(proc.stdout).text(),
437
+ new Response(proc.stderr).text(),
438
+ ]);
439
+ clearTimeout(timer);
440
+ await proc.exited;
441
+
442
+ if (looksLikeHelp(stdout)) return stripOverstrike(stdout);
443
+ } catch {}
444
+ }
445
+ return null;
446
+ }
447
+
448
+ async function runSafeHelp(args: string[]) {
449
+ let binary: string | null = null;
450
+ const subPath: string[] = [];
451
+
452
+ for (const arg of args) {
453
+ if (arg === "--no-color" || arg === "--help" || arg === "-h") continue;
454
+ if (!binary) {
455
+ binary = arg;
456
+ } else {
457
+ subPath.push(arg);
458
+ }
459
+ }
460
+
461
+ if (!binary) {
462
+ console.error("Error: provide a binary name");
463
+ console.error("Run 'clitree safe-help --help' for usage");
464
+ process.exit(1);
465
+ }
466
+
467
+ // Strategy in order of preference:
468
+ // 1. `<cli> <sub> -h` — short inline help, no pager
469
+ // 2. `<cli> <sub> --help` — long form, overstrike-stripped
470
+ // 3. `<cli> help <sub>` — alternate syntax (git, go, bun)
471
+ // 4. `man <cli>-<sub>` or `man <cli> <sub>` — final fallback
472
+ const variants: Array<{ label: string; args: string[] }> = [
473
+ { label: "short help (-h)", args: [...subPath, "-h"] },
474
+ { label: "long help (--help)", args: [...subPath, "--help"] },
475
+ ];
476
+
477
+ if (subPath.length > 0) {
478
+ variants.push({ label: "help subcommand", args: ["help", ...subPath] });
479
+ }
480
+
481
+ for (const variant of variants) {
482
+ const output = await tryHelpVariant(binary, variant.args);
483
+ if (output) {
484
+ process.stdout.write(output);
485
+ if (!output.endsWith("\n")) process.stdout.write("\n");
486
+ return;
487
+ }
488
+ }
489
+
490
+ // Last resort: man page
491
+ const manOutput = await tryManPage(binary, subPath);
492
+ if (manOutput) {
493
+ process.stdout.write(manOutput);
494
+ if (!manOutput.endsWith("\n")) process.stdout.write("\n");
495
+ return;
496
+ }
497
+
498
+ console.error(`Error: could not fetch help for ${binary} ${subPath.join(" ")}`);
499
+ console.error("Tried: -h, --help, help subcommand, and man page.");
500
+ process.exit(1);
501
+ }
502
+
275
503
  function shouldUseColor(args: string[]): boolean {
276
504
  if (args.includes("--no-color")) return false;
277
505
  if (process.env.NO_COLOR) return false;
@@ -303,6 +531,8 @@ async function runMine(args: string[]) {
303
531
  let format: "ansi" | "json" = "ansi";
304
532
  let maxPaths = 10;
305
533
  let withFlow: "auto" | "on" | "off" = "auto";
534
+ let topK = 1;
535
+ let showActivity = true;
306
536
 
307
537
  for (let i = 0; i < args.length; i++) {
308
538
  const arg = args[i]!;
@@ -314,10 +544,15 @@ async function runMine(args: string[]) {
314
544
  format = args[++i] as "ansi" | "json";
315
545
  } else if (arg === "--max-paths" && args[i + 1]) {
316
546
  maxPaths = Number.parseInt(args[++i]!, 10);
547
+ } else if (arg === "--top-k" && args[i + 1]) {
548
+ topK = Number.parseInt(args[++i]!, 10);
549
+ if (withFlow === "auto") withFlow = "on";
317
550
  } else if (arg === "--with-flow" || arg === "--flow") {
318
551
  withFlow = "on";
319
552
  } else if (arg === "--no-flow") {
320
553
  withFlow = "off";
554
+ } else if (arg === "--no-activity") {
555
+ showActivity = false;
321
556
  } else if (arg !== "--no-color" && !arg.startsWith("-")) {
322
557
  binary = arg;
323
558
  }
@@ -355,6 +590,10 @@ async function runMine(args: string[]) {
355
590
  console.log();
356
591
  }
357
592
 
593
+ if (showActivity && result.activity.total > 0) {
594
+ renderActivitySection(result.activity, C);
595
+ }
596
+
358
597
  if (result.workflows.length > 0) {
359
598
  console.log(`${C.bold}Discovered workflows:${C.reset}`);
360
599
  for (const wf of result.workflows.slice(0, maxPaths)) {
@@ -380,19 +619,23 @@ async function runMine(args: string[]) {
380
619
  console.log();
381
620
  }
382
621
 
383
- // Pick the top workflow worth visualizing as a DAG.
384
- // In "auto" mode, prefer the highest-support workflow with 3+ steps
385
- // 2-step chains are more readable as text than as boxed diagrams.
386
- // In "on" mode, render whatever is the top regardless of length.
387
- // In "off" mode, skip.
622
+ // Pick the top-K workflows worth visualizing as DAGs.
623
+ // Auto mode renders only the first 3+ step workflow.
624
+ // `--top-k N` renders up to N distinct workflows (skipping shorter 2-step ones by default).
388
625
  if (withFlow !== "off") {
389
626
  const minSteps = withFlow === "on" ? 2 : 3;
390
- const topForFlow = pickWorkflowForFlow(result.workflows, minSteps);
391
- if (topForFlow) {
392
- const flowWorkflow = minedToFlowWorkflow(topForFlow);
393
- const rendered = shouldUseColor(args) ? flowToAnsi(flowWorkflow) : flowToString(flowWorkflow);
394
- console.log(`${C.bold}Top workflow (visualized):${C.reset}`);
395
- console.log(rendered);
627
+ const topK_effective = Math.max(1, topK);
628
+ const candidates = pickWorkflowsForFlow(result.workflows, minSteps, topK_effective);
629
+
630
+ if (candidates.length > 0) {
631
+ const header = candidates.length === 1 ? "Top workflow (visualized):" : `Top ${candidates.length} workflows (visualized):`;
632
+ console.log(`${C.bold}${header}${C.reset}`);
633
+ for (let i = 0; i < candidates.length; i++) {
634
+ if (i > 0) console.log(`${C.gray}${"─".repeat(60)}${C.reset}`);
635
+ const flowWorkflow = minedToFlowWorkflow(candidates[i]!);
636
+ const rendered = shouldUseColor(args) ? flowToAnsi(flowWorkflow) : flowToString(flowWorkflow);
637
+ console.log(rendered);
638
+ }
396
639
  console.log();
397
640
  }
398
641
  }
@@ -402,15 +645,152 @@ async function runMine(args: string[]) {
402
645
  }
403
646
  }
404
647
 
648
+ function renderActivitySection(
649
+ activity: { hourOfDay: number[]; dayOfWeek: number[]; last30Days: number[]; firstSeen: number; lastSeen: number; total: number },
650
+ C: ReturnType<typeof makeColors>,
651
+ ) {
652
+ // Only show if we have timestamp data
653
+ if (activity.firstSeen === 0) return;
654
+
655
+ const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
656
+ console.log(`${C.bold}Activity:${C.reset}`);
657
+
658
+ const range = formatTimeRange(activity.firstSeen, activity.lastSeen);
659
+ console.log(` ${C.dim}Tracked over:${C.reset} ${C.cyan}${range}${C.reset}`);
660
+
661
+ // Hour-of-day sparkline (24 chars wide)
662
+ const hourSpark = sparkline(activity.hourOfDay);
663
+ console.log(` ${C.dim}Hour of day: ${C.reset}${C.cyan}${hourSpark}${C.reset} ${C.dim}(0h → 23h)${C.reset}`);
664
+
665
+ // Day-of-week mini bars
666
+ const dowMax = Math.max(...activity.dayOfWeek);
667
+ if (dowMax > 0) {
668
+ console.log(` ${C.dim}Day of week:${C.reset}`);
669
+ for (let i = 0; i < 7; i++) {
670
+ const count = activity.dayOfWeek[i]!;
671
+ const barLen = dowMax > 0 ? Math.round((count / dowMax) * 20) : 0;
672
+ const bar = "█".repeat(Math.max(count > 0 ? 1 : 0, barLen));
673
+ console.log(` ${C.gray}${dayNames[i]}${C.reset} ${C.cyan}${bar}${C.reset} ${C.dim}${count}${C.reset}`);
674
+ }
675
+ }
676
+
677
+ // Last 30 days sparkline
678
+ if (activity.last30Days.some(v => v > 0)) {
679
+ const monthSpark = sparkline(activity.last30Days);
680
+ console.log(` ${C.dim}Last 30 days:${C.reset} ${C.cyan}${monthSpark}${C.reset} ${C.dim}(30d ago → today)${C.reset}`);
681
+ }
682
+ console.log();
683
+ }
684
+
405
685
  function pickWorkflowForFlow<T extends { path: string[][]; support: number }>(
406
686
  workflows: T[],
407
687
  minSteps = 3,
408
688
  ): T | null {
689
+ const picked = pickWorkflowsForFlow(workflows, minSteps, 1);
690
+ return picked[0] ?? null;
691
+ }
692
+
693
+ function pickWorkflowsForFlow<T extends { path: string[][]; support: number }>(
694
+ workflows: T[],
695
+ minSteps: number,
696
+ topK: number,
697
+ ): T[] {
698
+ const result: T[] = [];
699
+ const signatures = new Set<string>();
409
700
  for (const wf of workflows) {
410
701
  const len = wf.path[0]?.length ?? 0;
411
- if (len >= minSteps) return wf;
702
+ if (len < minSteps) continue;
703
+ const sig = wf.path[0]!.join(" → ");
704
+ if (signatures.has(sig)) continue;
705
+ signatures.add(sig);
706
+ result.push(wf);
707
+ if (result.length >= topK) break;
708
+ }
709
+ return result;
710
+ }
711
+
712
+ async function runCross(args: string[]) {
713
+ let format: "ansi" | "json" = "ansi";
714
+ let topK = 10;
715
+ let minSupport = 3;
716
+ let minPathLength = 2;
717
+ let maxPathLength = 5;
718
+ let minDistinctCLIs = 2;
719
+ let onlyList: string[] = [];
720
+ let withFlow = true;
721
+
722
+ for (let i = 0; i < args.length; i++) {
723
+ const arg = args[i]!;
724
+ if (arg === "--top-k" && args[i + 1]) {
725
+ topK = Number.parseInt(args[++i]!, 10);
726
+ } else if (arg === "--min-support" && args[i + 1]) {
727
+ minSupport = Number.parseInt(args[++i]!, 10);
728
+ } else if (arg === "--min-path-length" && args[i + 1]) {
729
+ minPathLength = Number.parseInt(args[++i]!, 10);
730
+ } else if (arg === "--max-path-length" && args[i + 1]) {
731
+ maxPathLength = Number.parseInt(args[++i]!, 10);
732
+ } else if (arg === "--min-distinct-clis" && args[i + 1]) {
733
+ minDistinctCLIs = Number.parseInt(args[++i]!, 10);
734
+ } else if (arg === "--only" && args[i + 1]) {
735
+ onlyList = args[++i]!.split(",").map(s => s.trim()).filter(Boolean);
736
+ } else if (arg === "--format" && args[i + 1]) {
737
+ format = args[++i] as "ansi" | "json";
738
+ } else if (arg === "--no-flow") {
739
+ withFlow = false;
740
+ }
741
+ }
742
+
743
+ try {
744
+ const result = await mineCrossCli({
745
+ topK,
746
+ minSupport,
747
+ minPathLength,
748
+ maxPathLength,
749
+ minDistinctCLIs,
750
+ allowedCLIs: onlyList.length > 0 ? onlyList : undefined,
751
+ });
752
+
753
+ if (format === "json") {
754
+ console.log(JSON.stringify(result, null, 2));
755
+ return;
756
+ }
757
+
758
+ const C = makeColors(shouldUseColor(args));
759
+
760
+ console.log(`\n${C.bold}${C.magenta}cross-CLI workflow analysis${C.reset}\n`);
761
+ console.log(`${C.bold}Scope:${C.reset}`);
762
+ console.log(` ${C.dim}Sessions analyzed:${C.reset} ${C.cyan}${result.sessionsAnalyzed}${C.reset}`);
763
+ console.log(` ${C.dim}Distinct CLIs:${C.reset} ${C.cyan}${result.distinctCLIs.length}${C.reset}`);
764
+ console.log(` ${C.dim}Transitions:${C.reset} ${C.cyan}${result.totalTransitions}${C.reset}\n`);
765
+
766
+ if (result.workflows.length === 0) {
767
+ console.log(`${C.dim}No cross-CLI workflows found. Try lowering --min-support or --min-distinct-clis.${C.reset}\n`);
768
+ return;
769
+ }
770
+
771
+ console.log(`${C.bold}Cross-CLI workflows (top ${result.workflows.length}):${C.reset}`);
772
+ for (let i = 0; i < result.workflows.length; i++) {
773
+ const wf = result.workflows[i]!;
774
+ const chain = wf.path
775
+ .map(s => `${C.green}${s.cli}${C.reset} ${C.cyan}${s.subcommand}${C.reset}`)
776
+ .join(` ${C.gray}→${C.reset} `);
777
+ console.log(` ${C.dim}${(i + 1).toString().padStart(2)}.${C.reset} ${chain}`);
778
+ console.log(` ${C.dim}seen ${wf.support}×, ${wf.uniqueCLIs} distinct CLIs${C.reset}`);
779
+ }
780
+ console.log();
781
+
782
+ if (withFlow && result.workflows.length > 0) {
783
+ const top = result.workflows[0]!;
784
+ const flowWorkflow = crossCliToFlowWorkflow(top);
785
+ const rendered = shouldUseColor(args) ? flowToAnsi(flowWorkflow) : flowToString(flowWorkflow);
786
+ console.log(`${C.bold}Top cross-CLI workflow (visualized):${C.reset}`);
787
+ console.log(rendered);
788
+ console.log();
789
+ }
790
+ } catch (err: any) {
791
+ console.error(`Error: ${err.message}`);
792
+ process.exit(1);
412
793
  }
413
- return null;
414
794
  }
415
795
 
416
796
  async function runArchaeologyCmd(args: string[]) {
@@ -0,0 +1,71 @@
1
+ import type { HistoryEntry } from "./types";
2
+
3
+ export interface ActivityProfile {
4
+ /** Total invocations considered */
5
+ total: number;
6
+ /** Count per hour of day (0-23) */
7
+ hourOfDay: number[];
8
+ /** Count per day of week (0=Sun, 6=Sat) */
9
+ dayOfWeek: number[];
10
+ /** Count per day over the last N days (most recent last) */
11
+ last30Days: number[];
12
+ /** The earliest timestamp observed (unix seconds) */
13
+ firstSeen: number;
14
+ /** The latest timestamp observed (unix seconds) */
15
+ lastSeen: number;
16
+ }
17
+
18
+ /**
19
+ * Compute temporal activity histograms from a list of history entries.
20
+ * Assumes entries have unix-second timestamps; entries with timestamp 0 are ignored.
21
+ */
22
+ export function computeActivity(entries: HistoryEntry[], now: Date = new Date()): ActivityProfile {
23
+ const hourOfDay = new Array(24).fill(0);
24
+ const dayOfWeek = new Array(7).fill(0);
25
+ const last30Days = new Array(30).fill(0);
26
+
27
+ let first = Infinity;
28
+ let last = 0;
29
+ let total = 0;
30
+
31
+ const nowMs = now.getTime();
32
+ const dayMs = 24 * 60 * 60 * 1000;
33
+
34
+ for (const entry of entries) {
35
+ if (!entry.timestamp || entry.timestamp <= 0) continue;
36
+
37
+ total += 1;
38
+ if (entry.timestamp < first) first = entry.timestamp;
39
+ if (entry.timestamp > last) last = entry.timestamp;
40
+
41
+ const date = new Date(entry.timestamp * 1000);
42
+ hourOfDay[date.getHours()] += 1;
43
+ dayOfWeek[date.getDay()] += 1;
44
+
45
+ const ageDays = Math.floor((nowMs - date.getTime()) / dayMs);
46
+ if (ageDays >= 0 && ageDays < 30) {
47
+ // Index 29 = today, 0 = 29 days ago
48
+ last30Days[29 - ageDays] += 1;
49
+ }
50
+ }
51
+
52
+ return {
53
+ total,
54
+ hourOfDay,
55
+ dayOfWeek,
56
+ last30Days,
57
+ firstSeen: first === Infinity ? 0 : first,
58
+ lastSeen: last,
59
+ };
60
+ }
61
+
62
+ export function formatTimeRange(firstSeen: number, lastSeen: number): string {
63
+ if (!firstSeen || !lastSeen) return "no data";
64
+ const first = new Date(firstSeen * 1000);
65
+ const last = new Date(lastSeen * 1000);
66
+ const days = Math.round((last.getTime() - first.getTime()) / (24 * 60 * 60 * 1000));
67
+ if (days < 1) return "less than a day";
68
+ if (days < 30) return `${days} days`;
69
+ if (days < 365) return `${Math.round(days / 30)} months`;
70
+ return `${(days / 365).toFixed(1)} years`;
71
+ }