@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,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
|
+
}
|
package/src/miner/index.ts
CHANGED
|
@@ -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 {
|
|
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,43 @@
|
|
|
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
|
+
|
|
10
|
+
const BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const;
|
|
11
|
+
|
|
12
|
+
export interface SparklineOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Character used for zero values. Defaults to "·" (middle dot) so
|
|
15
|
+
* zero-days are visually distinct from missing data instead of blank.
|
|
16
|
+
*/
|
|
17
|
+
zero?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function sparkline(data: number[], opts: SparklineOptions = {}): string {
|
|
21
|
+
if (data.length === 0) return "";
|
|
22
|
+
const zero = opts.zero ?? "·";
|
|
23
|
+
const max = Math.max(...data);
|
|
24
|
+
if (max === 0) return zero.repeat(data.length);
|
|
25
|
+
|
|
26
|
+
return data
|
|
27
|
+
.map(value => {
|
|
28
|
+
if (value === 0) return zero;
|
|
29
|
+
const idx = Math.min(
|
|
30
|
+
BLOCKS.length - 1,
|
|
31
|
+
Math.max(0, Math.round((value / max) * (BLOCKS.length - 1))),
|
|
32
|
+
);
|
|
33
|
+
return BLOCKS[idx];
|
|
34
|
+
})
|
|
35
|
+
.join("");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Render a labeled row like "Mon ▁▃▅█▇▅▃▂" — used for day-of-week activity.
|
|
40
|
+
*/
|
|
41
|
+
export function labeledSparkline(label: string, data: number[], labelWidth = 3): string {
|
|
42
|
+
return `${label.padEnd(labelWidth)} ${sparkline(data)}`;
|
|
43
|
+
}
|