@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.
@@ -0,0 +1,216 @@
1
+ import type { HistoryEntry, Session } from "./types";
2
+ import { segmentSessions } from "./sessions";
3
+ import { parseHistory, readHistoryFile, defaultHistoryPath } from "./history";
4
+
5
+ export interface CrossCliStep {
6
+ cli: string;
7
+ subcommand: string;
8
+ }
9
+
10
+ export interface CrossCliWorkflow {
11
+ path: CrossCliStep[];
12
+ support: number;
13
+ signature: string;
14
+ uniqueCLIs: number;
15
+ }
16
+
17
+ export interface CrossCliOptions {
18
+ historyPath?: string;
19
+ sessionGapMinutes?: number;
20
+ minSupport?: number;
21
+ minPathLength?: number;
22
+ maxPathLength?: number;
23
+ topK?: number;
24
+ /** Only include workflows that cross at least this many distinct CLIs */
25
+ minDistinctCLIs?: number;
26
+ /** If non-empty, only consider entries whose cli[0] is in this set */
27
+ allowedCLIs?: string[];
28
+ /** Ignore CLIs that add noise (ls, cd, etc.) */
29
+ ignoreCLIs?: string[];
30
+ }
31
+
32
+ export interface CrossCliResult {
33
+ workflows: CrossCliWorkflow[];
34
+ sessionsAnalyzed: number;
35
+ distinctCLIs: string[];
36
+ totalTransitions: number;
37
+ }
38
+
39
+ const DEFAULT_IGNORE = new Set([
40
+ "ls",
41
+ "cd",
42
+ "pwd",
43
+ "echo",
44
+ "cat",
45
+ "clear",
46
+ "exit",
47
+ "env",
48
+ "export",
49
+ "which",
50
+ "source",
51
+ ".",
52
+ "sudo",
53
+ "man",
54
+ "open",
55
+ ]);
56
+
57
+ function entryToStep(entry: HistoryEntry, ignore: Set<string>): CrossCliStep | null {
58
+ const [raw, ...rest] = entry.argv;
59
+ if (!raw) return null;
60
+
61
+ // Skip absolute paths, relative paths, and variables being used as the "cli"
62
+ if (raw.startsWith("/") || raw.startsWith("./") || raw.startsWith("../") || raw.startsWith("$")) return null;
63
+
64
+ // CLI names must look like program names: alphanumeric start, no slashes
65
+ if (!/^[a-zA-Z0-9_-]+$/.test(raw)) return null;
66
+
67
+ if (ignore.has(raw)) return null;
68
+
69
+ const sub =
70
+ rest.find(arg => !arg.startsWith("-") && !arg.startsWith("$") && /^[a-z]/i.test(arg)) ?? "(root)";
71
+ return { cli: raw, subcommand: sub };
72
+ }
73
+
74
+ function dedupeConsecutive(steps: CrossCliStep[]): CrossCliStep[] {
75
+ const out: CrossCliStep[] = [];
76
+ for (const s of steps) {
77
+ const last = out[out.length - 1];
78
+ if (!last || last.cli !== s.cli || last.subcommand !== s.subcommand) {
79
+ out.push(s);
80
+ }
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function stepKey(step: CrossCliStep): string {
86
+ return `${step.cli} ${step.subcommand}`;
87
+ }
88
+
89
+ function pathSignature(path: CrossCliStep[]): string {
90
+ return path.map(stepKey).join(" → ");
91
+ }
92
+
93
+ function countDistinctCLIs(path: CrossCliStep[]): number {
94
+ return new Set(path.map(s => s.cli)).size;
95
+ }
96
+
97
+ /**
98
+ * Mine workflows that cross multiple CLIs. Unlike `mineCli`, this does NOT filter
99
+ * to a single binary up front. It looks at every command in a session and detects
100
+ * recurring sequences that weave between tools — e.g. `git push → gh pr create`,
101
+ * `docker build → docker push → kubectl apply`, `bun test → git commit → git push`.
102
+ */
103
+ export async function mineCrossCli(options: CrossCliOptions = {}): Promise<CrossCliResult> {
104
+ const path = options.historyPath ?? defaultHistoryPath();
105
+ if (!path) {
106
+ throw new Error("Could not determine shell history path. Pass historyPath explicitly.");
107
+ }
108
+
109
+ const text = await readHistoryFile(path);
110
+ const entries = parseHistory(text);
111
+ const sessions = segmentSessions(entries, options.sessionGapMinutes ?? 10);
112
+
113
+ const ignore = new Set(options.ignoreCLIs ?? Array.from(DEFAULT_IGNORE));
114
+ const allowed = options.allowedCLIs && options.allowedCLIs.length > 0 ? new Set(options.allowedCLIs) : null;
115
+
116
+ const minSupport = options.minSupport ?? 3;
117
+ const minLen = options.minPathLength ?? 2;
118
+ const maxLen = options.maxPathLength ?? 5;
119
+ const minDistinctCLIs = options.minDistinctCLIs ?? 2;
120
+
121
+ const pathCounts = new Map<string, { path: CrossCliStep[]; count: number }>();
122
+ const distinctCLIs = new Set<string>();
123
+ let totalTransitions = 0;
124
+
125
+ for (const session of sessions) {
126
+ const steps: CrossCliStep[] = [];
127
+ for (const entry of session.entries) {
128
+ const step = entryToStep(entry, ignore);
129
+ if (!step) continue;
130
+ if (allowed && !allowed.has(step.cli)) continue;
131
+ distinctCLIs.add(step.cli);
132
+ steps.push(step);
133
+ }
134
+
135
+ const deduped = dedupeConsecutive(steps);
136
+ if (deduped.length >= 2) totalTransitions += deduped.length - 1;
137
+
138
+ for (let start = 0; start < deduped.length; start++) {
139
+ const maxEnd = Math.min(start + maxLen, deduped.length);
140
+ for (let end = start + minLen; end <= maxEnd; end++) {
141
+ const slice = deduped.slice(start, end);
142
+ if (countDistinctCLIs(slice) < minDistinctCLIs) continue;
143
+ const sig = pathSignature(slice);
144
+ const existing = pathCounts.get(sig);
145
+ if (existing) {
146
+ existing.count += 1;
147
+ } else {
148
+ pathCounts.set(sig, { path: slice, count: 1 });
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ const clusters = Array.from(pathCounts.entries())
155
+ .map(([signature, { path, count }]) => ({
156
+ signature,
157
+ path,
158
+ support: count,
159
+ uniqueCLIs: countDistinctCLIs(path),
160
+ }))
161
+ .filter(c => c.support >= minSupport)
162
+ .sort((a, b) => {
163
+ // Prefer more-distinct, then longer, then higher-support
164
+ if (b.uniqueCLIs !== a.uniqueCLIs) return b.uniqueCLIs - a.uniqueCLIs;
165
+ if (b.path.length !== a.path.length) return b.path.length - a.path.length;
166
+ return b.support - a.support;
167
+ });
168
+
169
+ // Remove subpaths fully contained in longer kept paths
170
+ const keptPaths: CrossCliWorkflow[] = [];
171
+ for (const cluster of clusters) {
172
+ const coveredBy = keptPaths.find(
173
+ k => k.signature !== cluster.signature && k.signature.includes(cluster.signature) && k.support >= cluster.support * 0.7,
174
+ );
175
+ if (!coveredBy) {
176
+ keptPaths.push({
177
+ path: cluster.path,
178
+ support: cluster.support,
179
+ signature: cluster.signature,
180
+ uniqueCLIs: cluster.uniqueCLIs,
181
+ });
182
+ }
183
+ }
184
+
185
+ const topK = options.topK ?? 10;
186
+
187
+ return {
188
+ workflows: keptPaths.slice(0, topK),
189
+ sessionsAnalyzed: sessions.length,
190
+ distinctCLIs: Array.from(distinctCLIs).sort(),
191
+ totalTransitions,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Convert a CrossCliWorkflow into a Workflow suitable for the flow renderer.
197
+ * Each node is labeled with "<cli> <sub>" so the DAG makes the cross-CLI nature obvious.
198
+ */
199
+ export function crossCliToFlowWorkflow(workflow: CrossCliWorkflow) {
200
+ const nodes = workflow.path.map((step, idx) => ({
201
+ id: `n${idx}`,
202
+ command: [step.cli, step.subcommand],
203
+ label: `${step.cli} ${step.subcommand}`,
204
+ }));
205
+ const edges = [];
206
+ for (let i = 0; i < nodes.length - 1; i++) {
207
+ edges.push({ from: nodes[i]!.id, to: nodes[i + 1]!.id });
208
+ }
209
+ return {
210
+ name: workflow.signature,
211
+ description: `Cross-CLI workflow seen ${workflow.support}× (${workflow.uniqueCLIs} tools)`,
212
+ cli: workflow.path[0]?.cli ?? "multi",
213
+ nodes,
214
+ edges,
215
+ };
216
+ }
@@ -5,9 +5,25 @@ 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
12
 
10
- export type { HistoryEntry, Session, Transition, MinedWorkflow, CliUsageStats, MineOptions, SkillSuggestion };
13
+ export type {
14
+ HistoryEntry,
15
+ Session,
16
+ Transition,
17
+ MinedWorkflow,
18
+ CliUsageStats,
19
+ MineOptions,
20
+ SkillSuggestion,
21
+ ActivityProfile,
22
+ CrossCliOptions,
23
+ CrossCliResult,
24
+ CrossCliWorkflow,
25
+ CrossCliStep,
26
+ };
11
27
  export {
12
28
  parseHistory,
13
29
  readHistoryFile,
@@ -25,6 +41,12 @@ export {
25
41
  computeStats,
26
42
  suggestSkills,
27
43
  minedToFlowWorkflow,
44
+ computeActivity,
45
+ formatTimeRange,
46
+ sparkline,
47
+ labeledSparkline,
48
+ mineCrossCli,
49
+ crossCliToFlowWorkflow,
28
50
  };
29
51
 
30
52
  export interface MineResult {
@@ -34,6 +56,7 @@ export interface MineResult {
34
56
  workflows: MinedWorkflow[];
35
57
  suggestions: SkillSuggestion[];
36
58
  sessionsAnalyzed: number;
59
+ activity: ActivityProfile;
37
60
  }
38
61
 
39
62
  export async function mineCli(cli: string, options: MineOptions = {}): Promise<MineResult> {
@@ -67,6 +90,9 @@ export async function mineCli(cli: string, options: MineOptions = {}): Promise<M
67
90
 
68
91
  const suggestions = suggestSkills(workflows, stats);
69
92
 
93
+ const cliEntries = entries.filter(e => e.argv[0] === cli);
94
+ const activity = computeActivity(cliEntries);
95
+
70
96
  return {
71
97
  cli,
72
98
  stats,
@@ -74,5 +100,6 @@ export async function mineCli(cli: string, options: MineOptions = {}): Promise<M
74
100
  workflows,
75
101
  suggestions,
76
102
  sessionsAnalyzed: cliSessions.length,
103
+ activity,
77
104
  };
78
105
  }
@@ -0,0 +1,31 @@
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 (▁▂▃▄▅▆▇█).
7
+ */
8
+
9
+ const BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const;
10
+
11
+ export function sparkline(data: number[], opts: { empty?: string } = {}): string {
12
+ if (data.length === 0) return "";
13
+ const empty = opts.empty ?? " ";
14
+ const max = Math.max(...data);
15
+ if (max === 0) return empty.repeat(data.length);
16
+
17
+ return data
18
+ .map(value => {
19
+ if (value === 0) return empty;
20
+ const idx = Math.min(BLOCKS.length - 1, Math.max(0, Math.round((value / max) * (BLOCKS.length - 1))));
21
+ return BLOCKS[idx];
22
+ })
23
+ .join("");
24
+ }
25
+
26
+ /**
27
+ * Render a labeled row like "Mon ▁▃▅█▇▅▃▂" — used for day-of-week activity.
28
+ */
29
+ export function labeledSparkline(label: string, data: number[], labelWidth = 3): string {
30
+ return `${label.padEnd(labelWidth)} ${sparkline(data)}`;
31
+ }