@crafter/cli-tree 0.1.3 → 0.2.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/dist/{chunk-dnq2rnr7.js → chunk-e8jgr5p6.js} +235 -3
- package/dist/{chunk-dnq2rnr7.js.map → chunk-e8jgr5p6.js.map} +7 -4
- package/dist/cli.js +209 -20
- package/dist/cli.js.map +3 -3
- package/dist/miner/activity.d.ts +25 -0
- package/dist/miner/cross-cli.d.ts +55 -0
- package/dist/miner/index.d.ts +6 -2
- package/dist/miner/index.js +14 -2
- package/dist/miner/index.js.map +1 -1
- package/dist/miner/sparkline.d.ts +20 -0
- package/package.json +1 -1
- package/src/cli.ts +250 -21
- package/src/miner/activity.ts +90 -0
- package/src/miner/cross-cli.ts +216 -0
- package/src/miner/index.ts +28 -1
- package/src/miner/sparkline.ts +43 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface CrossCliStep {
|
|
2
|
+
cli: string;
|
|
3
|
+
subcommand: string;
|
|
4
|
+
}
|
|
5
|
+
export interface CrossCliWorkflow {
|
|
6
|
+
path: CrossCliStep[];
|
|
7
|
+
support: number;
|
|
8
|
+
signature: string;
|
|
9
|
+
uniqueCLIs: number;
|
|
10
|
+
}
|
|
11
|
+
export interface CrossCliOptions {
|
|
12
|
+
historyPath?: string;
|
|
13
|
+
sessionGapMinutes?: number;
|
|
14
|
+
minSupport?: number;
|
|
15
|
+
minPathLength?: number;
|
|
16
|
+
maxPathLength?: number;
|
|
17
|
+
topK?: number;
|
|
18
|
+
/** Only include workflows that cross at least this many distinct CLIs */
|
|
19
|
+
minDistinctCLIs?: number;
|
|
20
|
+
/** If non-empty, only consider entries whose cli[0] is in this set */
|
|
21
|
+
allowedCLIs?: string[];
|
|
22
|
+
/** Ignore CLIs that add noise (ls, cd, etc.) */
|
|
23
|
+
ignoreCLIs?: string[];
|
|
24
|
+
}
|
|
25
|
+
export interface CrossCliResult {
|
|
26
|
+
workflows: CrossCliWorkflow[];
|
|
27
|
+
sessionsAnalyzed: number;
|
|
28
|
+
distinctCLIs: string[];
|
|
29
|
+
totalTransitions: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Mine workflows that cross multiple CLIs. Unlike `mineCli`, this does NOT filter
|
|
33
|
+
* to a single binary up front. It looks at every command in a session and detects
|
|
34
|
+
* recurring sequences that weave between tools — e.g. `git push → gh pr create`,
|
|
35
|
+
* `docker build → docker push → kubectl apply`, `bun test → git commit → git push`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function mineCrossCli(options?: CrossCliOptions): Promise<CrossCliResult>;
|
|
38
|
+
/**
|
|
39
|
+
* Convert a CrossCliWorkflow into a Workflow suitable for the flow renderer.
|
|
40
|
+
* Each node is labeled with "<cli> <sub>" so the DAG makes the cross-CLI nature obvious.
|
|
41
|
+
*/
|
|
42
|
+
export declare function crossCliToFlowWorkflow(workflow: CrossCliWorkflow): {
|
|
43
|
+
name: string;
|
|
44
|
+
description: string;
|
|
45
|
+
cli: string;
|
|
46
|
+
nodes: {
|
|
47
|
+
id: string;
|
|
48
|
+
command: string[];
|
|
49
|
+
label: string;
|
|
50
|
+
}[];
|
|
51
|
+
edges: {
|
|
52
|
+
from: string;
|
|
53
|
+
to: string;
|
|
54
|
+
}[];
|
|
55
|
+
};
|
package/dist/miner/index.d.ts
CHANGED
|
@@ -5,9 +5,12 @@ import { extractPaths, clusterIntoWorkflows } from "./workflows";
|
|
|
5
5
|
import { computeStats } from "./stats";
|
|
6
6
|
import { suggestSkills, type SkillSuggestion } from "./suggest";
|
|
7
7
|
import { minedToFlowWorkflow } from "./to-flow";
|
|
8
|
+
import { computeActivity, formatTimeRange, type ActivityProfile } from "./activity";
|
|
9
|
+
import { sparkline, labeledSparkline } from "./sparkline";
|
|
10
|
+
import { mineCrossCli, crossCliToFlowWorkflow, type CrossCliOptions, type CrossCliResult, type CrossCliWorkflow, type CrossCliStep } from "./cross-cli";
|
|
8
11
|
import type { HistoryEntry, Session, Transition, MinedWorkflow, CliUsageStats, MineOptions } from "./types";
|
|
9
|
-
export type { HistoryEntry, Session, Transition, MinedWorkflow, CliUsageStats, MineOptions, SkillSuggestion };
|
|
10
|
-
export { parseHistory, readHistoryFile, defaultHistoryPath, tokenize, detectHistoryFormat, segmentSessions, filterByCli, buildTransitions, normalizeTransitions, extractSubcommand, extractSubcommandPath, extractPaths, clusterIntoWorkflows, computeStats, suggestSkills, minedToFlowWorkflow, };
|
|
12
|
+
export type { HistoryEntry, Session, Transition, MinedWorkflow, CliUsageStats, MineOptions, SkillSuggestion, ActivityProfile, CrossCliOptions, CrossCliResult, CrossCliWorkflow, CrossCliStep, };
|
|
13
|
+
export { parseHistory, readHistoryFile, defaultHistoryPath, tokenize, detectHistoryFormat, segmentSessions, filterByCli, buildTransitions, normalizeTransitions, extractSubcommand, extractSubcommandPath, extractPaths, clusterIntoWorkflows, computeStats, suggestSkills, minedToFlowWorkflow, computeActivity, formatTimeRange, sparkline, labeledSparkline, mineCrossCli, crossCliToFlowWorkflow, };
|
|
11
14
|
export interface MineResult {
|
|
12
15
|
cli: string;
|
|
13
16
|
stats: CliUsageStats;
|
|
@@ -15,5 +18,6 @@ export interface MineResult {
|
|
|
15
18
|
workflows: MinedWorkflow[];
|
|
16
19
|
suggestions: SkillSuggestion[];
|
|
17
20
|
sessionsAnalyzed: number;
|
|
21
|
+
activity: ActivityProfile;
|
|
18
22
|
}
|
|
19
23
|
export declare function mineCli(cli: string, options?: MineOptions): Promise<MineResult>;
|
package/dist/miner/index.js
CHANGED
|
@@ -1,40 +1,52 @@
|
|
|
1
1
|
import {
|
|
2
2
|
buildTransitions,
|
|
3
3
|
clusterIntoWorkflows,
|
|
4
|
+
computeActivity,
|
|
4
5
|
computeStats,
|
|
6
|
+
crossCliToFlowWorkflow,
|
|
5
7
|
defaultHistoryPath,
|
|
6
8
|
detectHistoryFormat,
|
|
7
9
|
extractPaths,
|
|
8
10
|
extractSubcommand,
|
|
9
11
|
extractSubcommandPath,
|
|
10
12
|
filterByCli,
|
|
13
|
+
formatTimeRange,
|
|
14
|
+
labeledSparkline,
|
|
11
15
|
mineCli,
|
|
16
|
+
mineCrossCli,
|
|
12
17
|
minedToFlowWorkflow,
|
|
13
18
|
normalizeTransitions,
|
|
14
19
|
parseHistory,
|
|
15
20
|
readHistoryFile,
|
|
16
21
|
segmentSessions,
|
|
22
|
+
sparkline,
|
|
17
23
|
suggestSkills,
|
|
18
24
|
tokenize
|
|
19
|
-
} from "../chunk-
|
|
25
|
+
} from "../chunk-e8jgr5p6.js";
|
|
20
26
|
export {
|
|
21
27
|
tokenize,
|
|
22
28
|
suggestSkills,
|
|
29
|
+
sparkline,
|
|
23
30
|
segmentSessions,
|
|
24
31
|
readHistoryFile,
|
|
25
32
|
parseHistory,
|
|
26
33
|
normalizeTransitions,
|
|
27
34
|
minedToFlowWorkflow,
|
|
35
|
+
mineCrossCli,
|
|
28
36
|
mineCli,
|
|
37
|
+
labeledSparkline,
|
|
38
|
+
formatTimeRange,
|
|
29
39
|
filterByCli,
|
|
30
40
|
extractSubcommandPath,
|
|
31
41
|
extractSubcommand,
|
|
32
42
|
extractPaths,
|
|
33
43
|
detectHistoryFormat,
|
|
34
44
|
defaultHistoryPath,
|
|
45
|
+
crossCliToFlowWorkflow,
|
|
35
46
|
computeStats,
|
|
47
|
+
computeActivity,
|
|
36
48
|
clusterIntoWorkflows,
|
|
37
49
|
buildTransitions
|
|
38
50
|
};
|
|
39
51
|
|
|
40
|
-
//# debugId=
|
|
52
|
+
//# debugId=3F080486405E782764756E2164756E21
|
package/dist/miner/index.js.map
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Unicode sparkline for a numeric array.
|
|
3
|
+
*
|
|
4
|
+
* We could depend on @crafter/charts for this, but keeping zero deps means
|
|
5
|
+
* clitree stays as a single focused package. The logic is trivial: bucket each
|
|
6
|
+
* value into one of 8 block characters (▁▂▃▄▅▆▇█), with a distinct marker
|
|
7
|
+
* for zero values so absence is visible at a glance.
|
|
8
|
+
*/
|
|
9
|
+
export interface SparklineOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Character used for zero values. Defaults to "·" (middle dot) so
|
|
12
|
+
* zero-days are visually distinct from missing data instead of blank.
|
|
13
|
+
*/
|
|
14
|
+
zero?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function sparkline(data: number[], opts?: SparklineOptions): string;
|
|
17
|
+
/**
|
|
18
|
+
* Render a labeled row like "Mon ▁▃▅█▇▅▃▂" — used for day-of-week activity.
|
|
19
|
+
*/
|
|
20
|
+
export declare function labeledSparkline(label: string, data: number[], labelWidth?: number): string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crafter/cli-tree",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Explore and map any CLI tool deeply. Parse --help trees, mine shell history for repeated workflows, surface hidden flags via LLM archaeology, and suggest new agent skills from your usage patterns.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
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 {
|
|
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", "safe-help"]);
|
|
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 ?? ""))) {
|
|
@@ -23,6 +31,7 @@ if (isHelpFlag) {
|
|
|
23
31
|
else if (subcommand === "mine") printMineHelp();
|
|
24
32
|
else if (subcommand === "archaeology") printArchaeologyHelp();
|
|
25
33
|
else if (subcommand === "safe-help") printSafeHelpHelp();
|
|
34
|
+
else if (subcommand === "cross") printCrossHelp();
|
|
26
35
|
else printMainHelp();
|
|
27
36
|
process.exit(0);
|
|
28
37
|
}
|
|
@@ -35,6 +44,8 @@ if (subcommand === "flow") {
|
|
|
35
44
|
await runArchaeologyCmd(args.slice(1));
|
|
36
45
|
} else if (subcommand === "safe-help") {
|
|
37
46
|
await runSafeHelp(args.slice(1));
|
|
47
|
+
} else if (subcommand === "cross") {
|
|
48
|
+
await runCross(args.slice(1));
|
|
38
49
|
} else {
|
|
39
50
|
await runTree(args);
|
|
40
51
|
}
|
|
@@ -47,19 +58,22 @@ function printMainHelp() {
|
|
|
47
58
|
clitree <binary> [options] Render command tree from --help
|
|
48
59
|
clitree flow <file> [options] Render a workflow YAML as a DAG
|
|
49
60
|
clitree mine <binary> [options] Mine shell history for workflows
|
|
61
|
+
clitree cross [options] Detect cross-CLI workflows (git + gh, docker + kubectl)
|
|
50
62
|
clitree archaeology <binary> [options] Full analysis: tree + mining + (LLM)
|
|
51
63
|
clitree safe-help <binary> [sub...] Fetch clean help for any CLI (no pager, no overstriking)
|
|
52
64
|
|
|
53
65
|
Examples:
|
|
54
66
|
clitree docker # basic tree
|
|
55
67
|
clitree mine git # "what workflows do I repeat with git?"
|
|
68
|
+
clitree cross # cross-CLI flows across your history
|
|
56
69
|
clitree archaeology bun # tree + mining + LLM archaeology
|
|
57
70
|
clitree safe-help git commit # avoid the git man-page pager trap
|
|
58
71
|
|
|
59
72
|
Subcommands:
|
|
60
73
|
tree Parse --help output (default; passing a binary name invokes this)
|
|
61
74
|
flow Render a workflow YAML file as an ASCII DAG
|
|
62
|
-
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)
|
|
63
77
|
archaeology Run full analysis (tree + mine + LLM proposals when available)
|
|
64
78
|
safe-help Return inline help for any CLI, handling pager/overstrike edge cases
|
|
65
79
|
|
|
@@ -67,6 +81,43 @@ function printMainHelp() {
|
|
|
67
81
|
`);
|
|
68
82
|
}
|
|
69
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
|
+
|
|
70
121
|
function printSafeHelpHelp() {
|
|
71
122
|
console.log(`
|
|
72
123
|
clitree safe-help — Fetch clean help output for any CLI
|
|
@@ -111,22 +162,26 @@ function printMineHelp() {
|
|
|
111
162
|
|
|
112
163
|
Examples:
|
|
113
164
|
clitree mine git
|
|
165
|
+
clitree mine git --top-k 3 # render top 3 workflows as DAGs
|
|
114
166
|
clitree mine docker --min-support 5 --with-flow
|
|
115
167
|
clitree mine bun --format json > bun-flows.json
|
|
116
168
|
clitree mine git --no-color | tee report.txt
|
|
169
|
+
clitree mine git --no-activity # skip temporal sparklines
|
|
117
170
|
|
|
118
171
|
Options:
|
|
119
172
|
--min-support <n> Minimum occurrences to count as a workflow (default: 3)
|
|
120
173
|
--history-path <p> Custom shell history path (default: ~/.zsh_history)
|
|
121
174
|
--format <fmt> Output format: ansi (default), json
|
|
122
|
-
--max-paths <n> Max workflows to show (default: 10)
|
|
123
|
-
--
|
|
124
|
-
--
|
|
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
|
|
125
180
|
--no-color Disable ANSI colors (also auto-disabled when stdout is not a TTY)
|
|
126
181
|
-h, --help Show this help
|
|
127
182
|
|
|
128
|
-
By default, the top workflow is rendered as a DAG
|
|
129
|
-
|
|
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.
|
|
130
185
|
`);
|
|
131
186
|
}
|
|
132
187
|
|
|
@@ -476,6 +531,8 @@ async function runMine(args: string[]) {
|
|
|
476
531
|
let format: "ansi" | "json" = "ansi";
|
|
477
532
|
let maxPaths = 10;
|
|
478
533
|
let withFlow: "auto" | "on" | "off" = "auto";
|
|
534
|
+
let topK = 1;
|
|
535
|
+
let showActivity = true;
|
|
479
536
|
|
|
480
537
|
for (let i = 0; i < args.length; i++) {
|
|
481
538
|
const arg = args[i]!;
|
|
@@ -487,10 +544,15 @@ async function runMine(args: string[]) {
|
|
|
487
544
|
format = args[++i] as "ansi" | "json";
|
|
488
545
|
} else if (arg === "--max-paths" && args[i + 1]) {
|
|
489
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";
|
|
490
550
|
} else if (arg === "--with-flow" || arg === "--flow") {
|
|
491
551
|
withFlow = "on";
|
|
492
552
|
} else if (arg === "--no-flow") {
|
|
493
553
|
withFlow = "off";
|
|
554
|
+
} else if (arg === "--no-activity") {
|
|
555
|
+
showActivity = false;
|
|
494
556
|
} else if (arg !== "--no-color" && !arg.startsWith("-")) {
|
|
495
557
|
binary = arg;
|
|
496
558
|
}
|
|
@@ -528,6 +590,10 @@ async function runMine(args: string[]) {
|
|
|
528
590
|
console.log();
|
|
529
591
|
}
|
|
530
592
|
|
|
593
|
+
if (showActivity && result.activity.total > 0) {
|
|
594
|
+
renderActivitySection(result.activity, C);
|
|
595
|
+
}
|
|
596
|
+
|
|
531
597
|
if (result.workflows.length > 0) {
|
|
532
598
|
console.log(`${C.bold}Discovered workflows:${C.reset}`);
|
|
533
599
|
for (const wf of result.workflows.slice(0, maxPaths)) {
|
|
@@ -553,19 +619,23 @@ async function runMine(args: string[]) {
|
|
|
553
619
|
console.log();
|
|
554
620
|
}
|
|
555
621
|
|
|
556
|
-
// Pick the top
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
// In "on" mode, render whatever is the top regardless of length.
|
|
560
|
-
// 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).
|
|
561
625
|
if (withFlow !== "off") {
|
|
562
626
|
const minSteps = withFlow === "on" ? 2 : 3;
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
console.log(
|
|
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
|
+
}
|
|
569
639
|
console.log();
|
|
570
640
|
}
|
|
571
641
|
}
|
|
@@ -575,15 +645,174 @@ async function runMine(args: string[]) {
|
|
|
575
645
|
}
|
|
576
646
|
}
|
|
577
647
|
|
|
648
|
+
function renderActivitySection(
|
|
649
|
+
activity: {
|
|
650
|
+
hourOfDay: number[];
|
|
651
|
+
dayOfWeek: number[];
|
|
652
|
+
last30Days: number[];
|
|
653
|
+
last52Weeks: number[];
|
|
654
|
+
firstSeen: number;
|
|
655
|
+
lastSeen: number;
|
|
656
|
+
total: number;
|
|
657
|
+
daysWithActivity: number;
|
|
658
|
+
},
|
|
659
|
+
C: ReturnType<typeof makeColors>,
|
|
660
|
+
) {
|
|
661
|
+
// Only show if we have timestamp data
|
|
662
|
+
if (activity.firstSeen === 0) return;
|
|
663
|
+
|
|
664
|
+
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
665
|
+
console.log(`${C.bold}Activity:${C.reset} ${C.dim}(blank = no data)${C.reset}`);
|
|
666
|
+
|
|
667
|
+
const range = formatTimeRange(activity.firstSeen, activity.lastSeen);
|
|
668
|
+
console.log(` ${C.dim}Tracked over:${C.reset} ${C.cyan}${range}${C.reset} ${C.dim}(${activity.daysWithActivity} active days)${C.reset}`);
|
|
669
|
+
|
|
670
|
+
// Hour-of-day sparkline (24 chars wide — one per hour)
|
|
671
|
+
const hourSpark = sparkline(activity.hourOfDay);
|
|
672
|
+
console.log(` ${C.dim}Hour of day:${C.reset} ${C.cyan}${hourSpark}${C.reset} ${C.dim}0h······················23h${C.reset}`);
|
|
673
|
+
|
|
674
|
+
// Day-of-week mini bars
|
|
675
|
+
const dowMax = Math.max(...activity.dayOfWeek);
|
|
676
|
+
if (dowMax > 0) {
|
|
677
|
+
console.log(` ${C.dim}Day of week:${C.reset}`);
|
|
678
|
+
for (let i = 0; i < 7; i++) {
|
|
679
|
+
const count = activity.dayOfWeek[i]!;
|
|
680
|
+
const barLen = dowMax > 0 ? Math.round((count / dowMax) * 20) : 0;
|
|
681
|
+
const bar = "█".repeat(Math.max(count > 0 ? 1 : 0, barLen));
|
|
682
|
+
console.log(` ${C.gray}${dayNames[i]}${C.reset} ${C.cyan}${bar}${C.reset} ${C.dim}${count}${C.reset}`);
|
|
683
|
+
}
|
|
684
|
+
console.log();
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Last 30 days — one character per day, · marks days with zero activity
|
|
688
|
+
if (activity.last30Days.some(v => v > 0)) {
|
|
689
|
+
const activeDays = activity.last30Days.filter(v => v > 0).length;
|
|
690
|
+
const totalLast30 = activity.last30Days.reduce((a, b) => a + b, 0);
|
|
691
|
+
const monthSpark = sparkline(activity.last30Days);
|
|
692
|
+
console.log(` ${C.dim}Last 30 days:${C.reset} ${C.cyan}${monthSpark}${C.reset}`);
|
|
693
|
+
console.log(` ${C.dim} └── 1 char per day · ${activeDays}/30 active · ${totalLast30} total${C.reset}`);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Last 52 weeks — gives context for heavy-history users (1+ year of tracking)
|
|
697
|
+
if (activity.last52Weeks.some(v => v > 0)) {
|
|
698
|
+
const activeWeeks = activity.last52Weeks.filter(v => v > 0).length;
|
|
699
|
+
const weekSpark = sparkline(activity.last52Weeks);
|
|
700
|
+
console.log(` ${C.dim}Last 52 weeks:${C.reset} ${C.cyan}${weekSpark}${C.reset}`);
|
|
701
|
+
console.log(` ${C.dim} └── 1 char per week · ${activeWeeks}/52 active weeks${C.reset}`);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
console.log();
|
|
705
|
+
}
|
|
706
|
+
|
|
578
707
|
function pickWorkflowForFlow<T extends { path: string[][]; support: number }>(
|
|
579
708
|
workflows: T[],
|
|
580
709
|
minSteps = 3,
|
|
581
710
|
): T | null {
|
|
711
|
+
const picked = pickWorkflowsForFlow(workflows, minSteps, 1);
|
|
712
|
+
return picked[0] ?? null;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function pickWorkflowsForFlow<T extends { path: string[][]; support: number }>(
|
|
716
|
+
workflows: T[],
|
|
717
|
+
minSteps: number,
|
|
718
|
+
topK: number,
|
|
719
|
+
): T[] {
|
|
720
|
+
const result: T[] = [];
|
|
721
|
+
const signatures = new Set<string>();
|
|
582
722
|
for (const wf of workflows) {
|
|
583
723
|
const len = wf.path[0]?.length ?? 0;
|
|
584
|
-
if (len
|
|
724
|
+
if (len < minSteps) continue;
|
|
725
|
+
const sig = wf.path[0]!.join(" → ");
|
|
726
|
+
if (signatures.has(sig)) continue;
|
|
727
|
+
signatures.add(sig);
|
|
728
|
+
result.push(wf);
|
|
729
|
+
if (result.length >= topK) break;
|
|
730
|
+
}
|
|
731
|
+
return result;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function runCross(args: string[]) {
|
|
735
|
+
let format: "ansi" | "json" = "ansi";
|
|
736
|
+
let topK = 10;
|
|
737
|
+
let minSupport = 3;
|
|
738
|
+
let minPathLength = 2;
|
|
739
|
+
let maxPathLength = 5;
|
|
740
|
+
let minDistinctCLIs = 2;
|
|
741
|
+
let onlyList: string[] = [];
|
|
742
|
+
let withFlow = true;
|
|
743
|
+
|
|
744
|
+
for (let i = 0; i < args.length; i++) {
|
|
745
|
+
const arg = args[i]!;
|
|
746
|
+
if (arg === "--top-k" && args[i + 1]) {
|
|
747
|
+
topK = Number.parseInt(args[++i]!, 10);
|
|
748
|
+
} else if (arg === "--min-support" && args[i + 1]) {
|
|
749
|
+
minSupport = Number.parseInt(args[++i]!, 10);
|
|
750
|
+
} else if (arg === "--min-path-length" && args[i + 1]) {
|
|
751
|
+
minPathLength = Number.parseInt(args[++i]!, 10);
|
|
752
|
+
} else if (arg === "--max-path-length" && args[i + 1]) {
|
|
753
|
+
maxPathLength = Number.parseInt(args[++i]!, 10);
|
|
754
|
+
} else if (arg === "--min-distinct-clis" && args[i + 1]) {
|
|
755
|
+
minDistinctCLIs = Number.parseInt(args[++i]!, 10);
|
|
756
|
+
} else if (arg === "--only" && args[i + 1]) {
|
|
757
|
+
onlyList = args[++i]!.split(",").map(s => s.trim()).filter(Boolean);
|
|
758
|
+
} else if (arg === "--format" && args[i + 1]) {
|
|
759
|
+
format = args[++i] as "ansi" | "json";
|
|
760
|
+
} else if (arg === "--no-flow") {
|
|
761
|
+
withFlow = false;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
try {
|
|
766
|
+
const result = await mineCrossCli({
|
|
767
|
+
topK,
|
|
768
|
+
minSupport,
|
|
769
|
+
minPathLength,
|
|
770
|
+
maxPathLength,
|
|
771
|
+
minDistinctCLIs,
|
|
772
|
+
allowedCLIs: onlyList.length > 0 ? onlyList : undefined,
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
if (format === "json") {
|
|
776
|
+
console.log(JSON.stringify(result, null, 2));
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const C = makeColors(shouldUseColor(args));
|
|
781
|
+
|
|
782
|
+
console.log(`\n${C.bold}${C.magenta}cross-CLI workflow analysis${C.reset}\n`);
|
|
783
|
+
console.log(`${C.bold}Scope:${C.reset}`);
|
|
784
|
+
console.log(` ${C.dim}Sessions analyzed:${C.reset} ${C.cyan}${result.sessionsAnalyzed}${C.reset}`);
|
|
785
|
+
console.log(` ${C.dim}Distinct CLIs:${C.reset} ${C.cyan}${result.distinctCLIs.length}${C.reset}`);
|
|
786
|
+
console.log(` ${C.dim}Transitions:${C.reset} ${C.cyan}${result.totalTransitions}${C.reset}\n`);
|
|
787
|
+
|
|
788
|
+
if (result.workflows.length === 0) {
|
|
789
|
+
console.log(`${C.dim}No cross-CLI workflows found. Try lowering --min-support or --min-distinct-clis.${C.reset}\n`);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
console.log(`${C.bold}Cross-CLI workflows (top ${result.workflows.length}):${C.reset}`);
|
|
794
|
+
for (let i = 0; i < result.workflows.length; i++) {
|
|
795
|
+
const wf = result.workflows[i]!;
|
|
796
|
+
const chain = wf.path
|
|
797
|
+
.map(s => `${C.green}${s.cli}${C.reset} ${C.cyan}${s.subcommand}${C.reset}`)
|
|
798
|
+
.join(` ${C.gray}→${C.reset} `);
|
|
799
|
+
console.log(` ${C.dim}${(i + 1).toString().padStart(2)}.${C.reset} ${chain}`);
|
|
800
|
+
console.log(` ${C.dim}seen ${wf.support}×, ${wf.uniqueCLIs} distinct CLIs${C.reset}`);
|
|
801
|
+
}
|
|
802
|
+
console.log();
|
|
803
|
+
|
|
804
|
+
if (withFlow && result.workflows.length > 0) {
|
|
805
|
+
const top = result.workflows[0]!;
|
|
806
|
+
const flowWorkflow = crossCliToFlowWorkflow(top);
|
|
807
|
+
const rendered = shouldUseColor(args) ? flowToAnsi(flowWorkflow) : flowToString(flowWorkflow);
|
|
808
|
+
console.log(`${C.bold}Top cross-CLI workflow (visualized):${C.reset}`);
|
|
809
|
+
console.log(rendered);
|
|
810
|
+
console.log();
|
|
811
|
+
}
|
|
812
|
+
} catch (err: any) {
|
|
813
|
+
console.error(`Error: ${err.message}`);
|
|
814
|
+
process.exit(1);
|
|
585
815
|
}
|
|
586
|
-
return null;
|
|
587
816
|
}
|
|
588
817
|
|
|
589
818
|
async function runArchaeologyCmd(args: string[]) {
|
|
@@ -0,0 +1,90 @@
|
|
|
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 30 days (most recent last) */
|
|
11
|
+
last30Days: number[];
|
|
12
|
+
/** Count per ISO week over the last 52 weeks (most recent last) */
|
|
13
|
+
last52Weeks: number[];
|
|
14
|
+
/** The earliest timestamp observed (unix seconds) */
|
|
15
|
+
firstSeen: number;
|
|
16
|
+
/** The latest timestamp observed (unix seconds) */
|
|
17
|
+
lastSeen: number;
|
|
18
|
+
/** How many distinct days had at least one invocation */
|
|
19
|
+
daysWithActivity: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compute temporal activity histograms from a list of history entries.
|
|
24
|
+
* Assumes entries have unix-second timestamps; entries with timestamp 0 are ignored.
|
|
25
|
+
*/
|
|
26
|
+
export function computeActivity(entries: HistoryEntry[], now: Date = new Date()): ActivityProfile {
|
|
27
|
+
const hourOfDay = new Array(24).fill(0);
|
|
28
|
+
const dayOfWeek = new Array(7).fill(0);
|
|
29
|
+
const last30Days = new Array(30).fill(0);
|
|
30
|
+
const last52Weeks = new Array(52).fill(0);
|
|
31
|
+
|
|
32
|
+
let first = Infinity;
|
|
33
|
+
let last = 0;
|
|
34
|
+
let total = 0;
|
|
35
|
+
|
|
36
|
+
const nowMs = now.getTime();
|
|
37
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
38
|
+
const weekMs = 7 * dayMs;
|
|
39
|
+
|
|
40
|
+
const distinctDays = new Set<number>();
|
|
41
|
+
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (!entry.timestamp || entry.timestamp <= 0) continue;
|
|
44
|
+
|
|
45
|
+
total += 1;
|
|
46
|
+
if (entry.timestamp < first) first = entry.timestamp;
|
|
47
|
+
if (entry.timestamp > last) last = entry.timestamp;
|
|
48
|
+
|
|
49
|
+
const date = new Date(entry.timestamp * 1000);
|
|
50
|
+
hourOfDay[date.getHours()] += 1;
|
|
51
|
+
dayOfWeek[date.getDay()] += 1;
|
|
52
|
+
|
|
53
|
+
const dayKey = Math.floor(date.getTime() / dayMs);
|
|
54
|
+
distinctDays.add(dayKey);
|
|
55
|
+
|
|
56
|
+
const ageDays = Math.floor((nowMs - date.getTime()) / dayMs);
|
|
57
|
+
if (ageDays >= 0 && ageDays < 30) {
|
|
58
|
+
// Index 29 = today, 0 = 29 days ago
|
|
59
|
+
last30Days[29 - ageDays] += 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const ageWeeks = Math.floor((nowMs - date.getTime()) / weekMs);
|
|
63
|
+
if (ageWeeks >= 0 && ageWeeks < 52) {
|
|
64
|
+
// Index 51 = this week, 0 = 51 weeks ago
|
|
65
|
+
last52Weeks[51 - ageWeeks] += 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
total,
|
|
71
|
+
hourOfDay,
|
|
72
|
+
dayOfWeek,
|
|
73
|
+
last30Days,
|
|
74
|
+
last52Weeks,
|
|
75
|
+
firstSeen: first === Infinity ? 0 : first,
|
|
76
|
+
lastSeen: last,
|
|
77
|
+
daysWithActivity: distinctDays.size,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function formatTimeRange(firstSeen: number, lastSeen: number): string {
|
|
82
|
+
if (!firstSeen || !lastSeen) return "no data";
|
|
83
|
+
const first = new Date(firstSeen * 1000);
|
|
84
|
+
const last = new Date(lastSeen * 1000);
|
|
85
|
+
const days = Math.round((last.getTime() - first.getTime()) / (24 * 60 * 60 * 1000));
|
|
86
|
+
if (days < 1) return "less than a day";
|
|
87
|
+
if (days < 30) return `${days} days`;
|
|
88
|
+
if (days < 365) return `${Math.round(days / 30)} months`;
|
|
89
|
+
return `${(days / 365).toFixed(1)} years`;
|
|
90
|
+
}
|