@stupify/cli 0.0.8 โ 0.0.10
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/analysis.js +3 -2
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/git.d.ts +1 -0
- package/dist/git.js +44 -1
- package/dist/model.d.ts +3 -2
- package/dist/model.js +42 -43
- package/dist/render.js +38 -20
- package/dist/sem-provider.js +17 -12
- package/dist/stupify.d.ts +35 -1
- package/dist/stupify.js +28 -19
- package/dist/types.d.ts +3 -0
- package/dist/ui.d.ts +34 -0
- package/dist/ui.js +143 -0
- package/package.json +3 -1
- package/src/analysis.ts +3 -2
- package/src/constants.ts +1 -1
- package/src/git.ts +44 -1
- package/src/model.ts +49 -55
- package/src/render.ts +34 -20
- package/src/sem-provider.ts +31 -16
- package/src/stupify.ts +31 -18
- package/src/types.ts +3 -0
- package/src/ui.ts +187 -0
package/src/render.ts
CHANGED
|
@@ -1,43 +1,42 @@
|
|
|
1
1
|
import { VERSION } from "./constants.ts";
|
|
2
2
|
import type { SearchCommand, SearchRunJson } from "./types.ts";
|
|
3
|
+
import { format } from "./ui.ts";
|
|
3
4
|
|
|
4
5
|
export function renderSearchRun(run: SearchRunJson, command: SearchCommand): string {
|
|
5
6
|
if (command.json) return JSON.stringify(run, null, 2);
|
|
6
7
|
|
|
7
8
|
if (run.stats.skipped && run.stats.skipReason === "input_too_large") {
|
|
8
|
-
return
|
|
9
|
-
|
|
10
|
-
Size:
|
|
9
|
+
return `${format.heading("Search input is too large for precise local search.")}
|
|
10
|
+
${format.heading("Size:")}
|
|
11
11
|
~${run.stats.inputTokens ?? "unknown"} tokens
|
|
12
|
-
Limit:
|
|
12
|
+
${format.heading("Limit:")}
|
|
13
13
|
${run.stats.inputTokenCap ?? "unknown"} tokens
|
|
14
14
|
Stupify skipped the search rather than review truncated context.
|
|
15
15
|
Nothing was blocked.
|
|
16
|
-
Try:
|
|
16
|
+
${format.heading("Try:")}
|
|
17
17
|
rerun with ${sourceHint(command)} --max-search-input-tokens ${Math.max((run.stats.inputTokens ?? 12_000) + 1, (run.stats.inputTokenCap ?? 12_000) * 2)}`;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
if (run.stats.skipped && run.stats.skipReason === "no_candidates") {
|
|
21
|
-
return
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
No search targets found.`;
|
|
21
|
+
return `${format.heading("Search complete.")}
|
|
22
|
+
${format.label("Patterns:")} ${run.patterns.join(", ")}
|
|
23
|
+
${format.success("No search targets found.")}`;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
if (run.matches.length === 0) {
|
|
28
|
-
return
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
No judgment-offload signals found.`;
|
|
27
|
+
return `${format.heading("Search complete.")}
|
|
28
|
+
${format.label("Patterns:")} ${run.patterns.join(", ")}
|
|
29
|
+
${format.success("No judgment-offload signals found.")}`;
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
return
|
|
35
|
-
|
|
36
|
-
${
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
return `${format.warn("AI SLOP DETECTED")}
|
|
33
|
+
${run.matches.map((match, index) => `${index + 1}.
|
|
34
|
+
${format.muted("who:")} ${committerLabel(run)}
|
|
35
|
+
${format.muted("what:")} ${match.patternId} - ${match.reason}
|
|
36
|
+
${format.muted("when:")} ${sourceLabel(command)}
|
|
37
|
+
${format.muted("where:")} ${match.proof}
|
|
38
|
+
${format.muted("why:")} ${match.checkWhy ?? "This pattern may indicate judgment-offload."}`).join("\n")}
|
|
39
|
+
${format.muted("Search mode is warn-only.")}`;
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
export function helpText(): string {
|
|
@@ -95,3 +94,18 @@ function sourceHint(command: SearchCommand): string {
|
|
|
95
94
|
if (command.kind === "commits") return `--commits ${command.count}`;
|
|
96
95
|
return "--stdin";
|
|
97
96
|
}
|
|
97
|
+
|
|
98
|
+
function sourceLabel(command: SearchCommand): string {
|
|
99
|
+
if (command.kind === "staged") return "staged changes";
|
|
100
|
+
if (command.kind === "since") return `since ${command.since}`;
|
|
101
|
+
if (command.kind === "commit") return `commit ${command.commit}`;
|
|
102
|
+
if (command.kind === "commits") return `last ${command.count} commits`;
|
|
103
|
+
return "stdin diff";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function committerLabel(run: SearchRunJson): string {
|
|
107
|
+
const committers = (run.stats.committers ?? []).filter(Boolean);
|
|
108
|
+
if (committers.length === 0) return "unknown committer";
|
|
109
|
+
if (committers.length <= 3) return committers.join(", ");
|
|
110
|
+
return `${committers.slice(0, 3).join(", ")} +${committers.length - 3} more`;
|
|
111
|
+
}
|
package/src/sem-provider.ts
CHANGED
|
@@ -7,11 +7,13 @@ import { promisify } from "node:util";
|
|
|
7
7
|
import { cachedJson, fingerprint } from "./cache.ts";
|
|
8
8
|
import { readDiffFromStdin } from "./diff.ts";
|
|
9
9
|
import {
|
|
10
|
+
gitUserLabel,
|
|
10
11
|
sourceRangeForCommit,
|
|
11
12
|
sourceRangeForRecentCommits,
|
|
12
13
|
sourceRangeSince,
|
|
13
14
|
stagedDiff,
|
|
14
15
|
} from "./git.ts";
|
|
16
|
+
import { diagnostic } from "./ui.ts";
|
|
15
17
|
import type {
|
|
16
18
|
SearchCommand,
|
|
17
19
|
SemChange,
|
|
@@ -26,11 +28,12 @@ const execFileAsync = promisify(execFile);
|
|
|
26
28
|
export async function semChangeSetForCommand(
|
|
27
29
|
command: SearchCommand,
|
|
28
30
|
): Promise<SemChangeSet> {
|
|
29
|
-
if (command.kind === "stdin") return semChangeSetFromPatch(await readDiffFromStdin(), command.debugSem);
|
|
31
|
+
if (command.kind === "stdin") return semChangeSetFromPatch(await readDiffFromStdin(), command.debugSem, "stdin", ["stdin"]);
|
|
30
32
|
if (command.kind === "staged") {
|
|
31
|
-
const diff = await stagedDiff();
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
const [diff, committer] = await Promise.all([stagedDiff(), gitUserLabel()]);
|
|
34
|
+
const committers = [committer];
|
|
35
|
+
if (!diff.text.trim()) return emptyChangeSet("staged", diff.stats, committers);
|
|
36
|
+
return semChangeSetFromPatch(diff.text, command.debugSem, "staged", committers);
|
|
34
37
|
}
|
|
35
38
|
if (command.kind === "commit") {
|
|
36
39
|
const range = await sourceRangeForCommit(command.commit);
|
|
@@ -51,12 +54,17 @@ export async function semChangeSetForCommand(
|
|
|
51
54
|
return withContextWorkspace(normalizeSemDiff(raw, range), command.debugSem);
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
function emptyChangeSet(
|
|
57
|
+
function emptyChangeSet(
|
|
58
|
+
label: string,
|
|
59
|
+
stats: SourceRange["stats"],
|
|
60
|
+
committers?: readonly string[],
|
|
61
|
+
): SemChangeSet {
|
|
55
62
|
return {
|
|
56
63
|
id: sourceId(label),
|
|
57
64
|
label,
|
|
58
65
|
base: label,
|
|
59
66
|
target: label,
|
|
67
|
+
committers,
|
|
60
68
|
contextCwd: process.cwd(),
|
|
61
69
|
cleanup: async () => undefined,
|
|
62
70
|
changes: [],
|
|
@@ -79,7 +87,12 @@ async function semRangeForCommand(command: SearchCommand): Promise<SourceRange>
|
|
|
79
87
|
throw new Error("sem cannot resolve stdin as a git range.");
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
async function semChangeSetFromPatch(
|
|
90
|
+
async function semChangeSetFromPatch(
|
|
91
|
+
patch: string,
|
|
92
|
+
debugSem: boolean,
|
|
93
|
+
label = "stdin",
|
|
94
|
+
committers?: readonly string[],
|
|
95
|
+
): Promise<SemChangeSet> {
|
|
83
96
|
if (!patch.trim()) throw new Error("No diff received on stdin.");
|
|
84
97
|
const raw = await cachedJson(
|
|
85
98
|
"sem-diff",
|
|
@@ -93,11 +106,12 @@ async function semChangeSetFromPatch(patch: string, debugSem: boolean, label = "
|
|
|
93
106
|
);
|
|
94
107
|
return {
|
|
95
108
|
...normalizeSemDiff(raw, {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
109
|
+
id: sourceId(label),
|
|
110
|
+
label,
|
|
111
|
+
base: label,
|
|
112
|
+
target: label,
|
|
113
|
+
committers,
|
|
114
|
+
stats: { filesChanged: 0, additions: 0, deletions: 0 },
|
|
101
115
|
}),
|
|
102
116
|
contextCwd: process.cwd(),
|
|
103
117
|
cleanup: async () => undefined,
|
|
@@ -140,14 +154,14 @@ async function withContextWorkspace(changeSet: SemChangeSet, debugSem: boolean):
|
|
|
140
154
|
}
|
|
141
155
|
|
|
142
156
|
async function runSem(args: readonly string[], debugSem: boolean, cwd = process.cwd()): Promise<unknown> {
|
|
143
|
-
if (debugSem)
|
|
157
|
+
if (debugSem) diagnostic(`sem ${args.join(" ")}`);
|
|
144
158
|
const { command, commandArgs } = resolveSemCommand(args);
|
|
145
159
|
try {
|
|
146
160
|
const { stdout, stderr } = await execFileAsync(command, commandArgs, {
|
|
147
161
|
cwd,
|
|
148
162
|
maxBuffer: 128 * 1024 * 1024,
|
|
149
163
|
});
|
|
150
|
-
if (debugSem && stderr.trim())
|
|
164
|
+
if (debugSem && stderr.trim()) diagnostic(stderr.trim());
|
|
151
165
|
return JSON.parse(stdout);
|
|
152
166
|
} catch (error) {
|
|
153
167
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -156,7 +170,7 @@ async function runSem(args: readonly string[], debugSem: boolean, cwd = process.
|
|
|
156
170
|
}
|
|
157
171
|
|
|
158
172
|
async function runSemWithInput(args: readonly string[], stdin: string, debugSem: boolean): Promise<unknown> {
|
|
159
|
-
if (debugSem)
|
|
173
|
+
if (debugSem) diagnostic(`sem ${args.join(" ")}`);
|
|
160
174
|
const { command, commandArgs } = resolveSemCommand(args);
|
|
161
175
|
return new Promise((resolve, reject) => {
|
|
162
176
|
const child = spawn(command, commandArgs, { stdio: ["pipe", "pipe", "pipe"] });
|
|
@@ -167,7 +181,7 @@ async function runSemWithInput(args: readonly string[], stdin: string, debugSem:
|
|
|
167
181
|
child.on("error", (error) => reject(error));
|
|
168
182
|
child.on("close", (code) => {
|
|
169
183
|
const stderrText = Buffer.concat(stderr).toString("utf8");
|
|
170
|
-
if (debugSem && stderrText.trim())
|
|
184
|
+
if (debugSem && stderrText.trim()) diagnostic(stderrText.trim());
|
|
171
185
|
if (code !== 0) {
|
|
172
186
|
reject(new Error(`sem failed with exit code ${code}${stderrText ? `: ${stderrText.trim()}` : ""}`));
|
|
173
187
|
return;
|
|
@@ -183,7 +197,7 @@ async function runSemWithInput(args: readonly string[], stdin: string, debugSem:
|
|
|
183
197
|
}
|
|
184
198
|
|
|
185
199
|
async function git(args: readonly string[], debugSem: boolean): Promise<void> {
|
|
186
|
-
if (debugSem)
|
|
200
|
+
if (debugSem) diagnostic(`git ${args.join(" ")}`);
|
|
187
201
|
await execFileAsync("git", [...args], { maxBuffer: 128 * 1024 * 1024 });
|
|
188
202
|
}
|
|
189
203
|
|
|
@@ -225,6 +239,7 @@ function normalizeSemDiff(value: unknown, range: SourceRange): SemChangeSet {
|
|
|
225
239
|
label: range.label,
|
|
226
240
|
base: range.base,
|
|
227
241
|
target: range.target,
|
|
242
|
+
committers: range.committers,
|
|
228
243
|
contextCwd: process.cwd(),
|
|
229
244
|
cleanup: async () => undefined,
|
|
230
245
|
changes,
|
package/src/stupify.ts
CHANGED
|
@@ -20,46 +20,49 @@ import {
|
|
|
20
20
|
} from "./search-profile.ts";
|
|
21
21
|
import { semChangeSetForCommand } from "./sem-provider.ts";
|
|
22
22
|
import { createTracer } from "./trace.ts";
|
|
23
|
+
import { createCliUi, type CliUi } from "./ui.ts";
|
|
23
24
|
import type { SearchCommand, SearchMatch, SearchProfile, SearchRunJson, SemContext, SemContextPack, StupifyCheck } from "./types.ts";
|
|
24
25
|
|
|
25
26
|
export async function main(argv = process.argv.slice(2)): Promise<number> {
|
|
26
27
|
const startedAt = Date.now();
|
|
28
|
+
let ui = createCliUi();
|
|
27
29
|
try {
|
|
28
30
|
const command = parseCommand(argv);
|
|
29
31
|
if (command.kind === "help") {
|
|
30
|
-
|
|
32
|
+
ui.writeStdout(helpText());
|
|
31
33
|
return 0;
|
|
32
34
|
}
|
|
33
35
|
if (command.kind === "hook") {
|
|
34
|
-
|
|
36
|
+
ui.writeStdout(await runHookCommand(command.action));
|
|
35
37
|
return 0;
|
|
36
38
|
}
|
|
37
39
|
if (command.kind === "doctor") {
|
|
38
40
|
const result = await runDoctor();
|
|
39
|
-
|
|
41
|
+
ui.writeStdout(result.text);
|
|
40
42
|
return result.exitCode;
|
|
41
43
|
}
|
|
42
44
|
if (command.kind === "bench-search") {
|
|
43
45
|
const { runSearchBench } = await import("./search-bench.ts");
|
|
44
|
-
|
|
46
|
+
ui.writeStdout(await runSearchBench(command.configPath));
|
|
45
47
|
return 0;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
ui = createCliUi({ quiet: command.json });
|
|
51
|
+
const run = await runSearchCommand(command, startedAt, ui);
|
|
52
|
+
ui.writeStdout(renderSearchRun(run, command));
|
|
50
53
|
return 0;
|
|
51
54
|
} catch (error) {
|
|
52
|
-
|
|
55
|
+
ui.error(error instanceof Error ? error.message : String(error), { force: true });
|
|
53
56
|
return 1;
|
|
54
57
|
}
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
export async function runSearchCommand(command: SearchCommand, startedAt: number): Promise<SearchRunJson> {
|
|
60
|
+
export async function runSearchCommand(command: SearchCommand, startedAt: number, ui = createCliUi({ quiet: command.json })): Promise<SearchRunJson> {
|
|
58
61
|
const t = createTracer({
|
|
59
62
|
writeLine: () => undefined,
|
|
60
63
|
onEvent: (event) => {
|
|
61
64
|
if (command.json) return;
|
|
62
|
-
|
|
65
|
+
ui.step(formatStep(event.name, event.ms, event.count, event.detail));
|
|
63
66
|
},
|
|
64
67
|
});
|
|
65
68
|
|
|
@@ -68,7 +71,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
68
71
|
const patternIds = checks.map((check) => check.id);
|
|
69
72
|
const maxCandidates = effectiveMaxCandidates(command.maxCandidates, profile);
|
|
70
73
|
const maxSearchInputTokens = effectiveMaxSearchInputTokens(command.maxSearchInputTokens, profile);
|
|
71
|
-
printRunPlan(command, patternIds);
|
|
74
|
+
printRunPlan(command, patternIds, ui);
|
|
72
75
|
const { value: changeSet } = await t.trace(
|
|
73
76
|
"entity.diff",
|
|
74
77
|
() => semChangeSetForCommand(command),
|
|
@@ -93,6 +96,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
93
96
|
stats: {
|
|
94
97
|
elapsedMs: Date.now() - startedAt,
|
|
95
98
|
modelCalls: 0,
|
|
99
|
+
committers: changeSet.committers,
|
|
96
100
|
skipped: true,
|
|
97
101
|
skipReason: "no_candidates",
|
|
98
102
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -134,6 +138,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
134
138
|
stats: {
|
|
135
139
|
elapsedMs: Date.now() - startedAt,
|
|
136
140
|
modelCalls: 0,
|
|
141
|
+
committers: changeSet.committers,
|
|
137
142
|
skipped: true,
|
|
138
143
|
skipReason: "no_candidates",
|
|
139
144
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -177,6 +182,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
177
182
|
modelCalls: 0,
|
|
178
183
|
inputTokens: batches.estimatedInputTokens,
|
|
179
184
|
inputTokenCap: maxSearchInputTokens,
|
|
185
|
+
committers: changeSet.committers,
|
|
180
186
|
skipped: true,
|
|
181
187
|
skipReason: "input_too_large",
|
|
182
188
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -197,14 +203,14 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
197
203
|
}
|
|
198
204
|
|
|
199
205
|
if (batches.wasSplit && !command.json) {
|
|
200
|
-
|
|
206
|
+
ui.warn(`Search input is large; queued ${batches.batches.length} smaller search batches.`);
|
|
201
207
|
if (batches.skippedTargets > 0) {
|
|
202
|
-
|
|
208
|
+
ui.warn(`Skipped ${batches.skippedTargets} oversized targets that could not fit alone.`);
|
|
203
209
|
}
|
|
204
210
|
}
|
|
205
211
|
|
|
206
|
-
const modelPath = await firstRunModelBootstrap(command.model);
|
|
207
|
-
const model = await loadLocalModel(modelPath, command.model, "scout");
|
|
212
|
+
const modelPath = await firstRunModelBootstrap(command.model, ui);
|
|
213
|
+
const model = await loadLocalModel(modelPath, command.model, "scout", ui);
|
|
208
214
|
const matches = [];
|
|
209
215
|
let modelCalls = 0;
|
|
210
216
|
let inputTokens = 0;
|
|
@@ -215,7 +221,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
215
221
|
if (batchInputTokens > maxSearchInputTokens) {
|
|
216
222
|
exactSkippedTargets += batch.contexts.length;
|
|
217
223
|
if (!command.json) {
|
|
218
|
-
|
|
224
|
+
ui.warn(`Skipped ${batch.contexts.length} targets after exact token count exceeded the limit.`);
|
|
219
225
|
}
|
|
220
226
|
continue;
|
|
221
227
|
}
|
|
@@ -240,6 +246,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
240
246
|
modelCalls,
|
|
241
247
|
inputTokens,
|
|
242
248
|
inputTokenCap: maxSearchInputTokens,
|
|
249
|
+
committers: changeSet.committers,
|
|
243
250
|
filesChanged: changeSet.summary.fileCount,
|
|
244
251
|
entitiesScanned: changeSet.summary.total,
|
|
245
252
|
candidates: contexts.length,
|
|
@@ -415,11 +422,17 @@ function buildSearchRequest(
|
|
|
415
422
|
function printRunPlan(
|
|
416
423
|
command: SearchCommand,
|
|
417
424
|
patternIds: readonly string[],
|
|
425
|
+
ui: CliUi,
|
|
418
426
|
): void {
|
|
419
427
|
if (command.json) return;
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
428
|
+
ui.intro("stupify");
|
|
429
|
+
ui.note(
|
|
430
|
+
[
|
|
431
|
+
`Search: ${sourceLabel(command)}`,
|
|
432
|
+
`Patterns: ${patternIds.join(", ")}`,
|
|
433
|
+
].join("\n"),
|
|
434
|
+
"Run",
|
|
435
|
+
);
|
|
423
436
|
}
|
|
424
437
|
|
|
425
438
|
function formatStep(name: string, ms: number, count?: number, detail?: string): string {
|
package/src/types.ts
CHANGED
|
@@ -92,6 +92,7 @@ export type SourceRange = Readonly<{
|
|
|
92
92
|
label: string;
|
|
93
93
|
base: string;
|
|
94
94
|
target: string;
|
|
95
|
+
committers?: readonly string[];
|
|
95
96
|
stats: NetDiffStats;
|
|
96
97
|
}>;
|
|
97
98
|
|
|
@@ -120,6 +121,7 @@ export type SemChangeSet = Readonly<{
|
|
|
120
121
|
label: string;
|
|
121
122
|
base: string;
|
|
122
123
|
target: string;
|
|
124
|
+
committers?: readonly string[];
|
|
123
125
|
contextCwd: string;
|
|
124
126
|
cleanup: () => Promise<void>;
|
|
125
127
|
changes: readonly SemChange[];
|
|
@@ -211,6 +213,7 @@ export type SearchRunJson = Readonly<{
|
|
|
211
213
|
inputTokenCap?: number;
|
|
212
214
|
skipped?: boolean;
|
|
213
215
|
skipReason?: "input_too_large" | "no_candidates";
|
|
216
|
+
committers?: readonly string[];
|
|
214
217
|
filesChanged?: number;
|
|
215
218
|
entitiesScanned?: number;
|
|
216
219
|
candidates?: number;
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {
|
|
2
|
+
confirm as clackConfirm,
|
|
3
|
+
intro as clackIntro,
|
|
4
|
+
isCancel,
|
|
5
|
+
log,
|
|
6
|
+
note,
|
|
7
|
+
outro as clackOutro,
|
|
8
|
+
progress,
|
|
9
|
+
spinner,
|
|
10
|
+
type SpinnerResult,
|
|
11
|
+
} from "@clack/prompts";
|
|
12
|
+
import { createReadStream, createWriteStream, type ReadStream, type WriteStream } from "node:fs";
|
|
13
|
+
import { platform } from "node:os";
|
|
14
|
+
import { stdin, stderr, stdout } from "node:process";
|
|
15
|
+
import type { Readable, Writable } from "node:stream";
|
|
16
|
+
import pc from "picocolors";
|
|
17
|
+
|
|
18
|
+
export type CliUi = ReturnType<typeof createCliUi>;
|
|
19
|
+
|
|
20
|
+
export type CliUiOptions = Readonly<{
|
|
21
|
+
quiet?: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
|
|
24
|
+
type LogOptions = Readonly<{
|
|
25
|
+
force?: boolean;
|
|
26
|
+
}>;
|
|
27
|
+
|
|
28
|
+
type PromptIo = Readonly<{
|
|
29
|
+
input: Readable;
|
|
30
|
+
output: Writable;
|
|
31
|
+
close: () => void;
|
|
32
|
+
}>;
|
|
33
|
+
|
|
34
|
+
export function createCliUi(options: CliUiOptions = {}) {
|
|
35
|
+
const quiet = options.quiet ?? false;
|
|
36
|
+
const output = stderr;
|
|
37
|
+
|
|
38
|
+
function shouldWrite(logOptions?: LogOptions): boolean {
|
|
39
|
+
return logOptions?.force === true || !quiet;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function withPromptIo<T>(
|
|
43
|
+
run: (io: PromptIo) => Promise<T>,
|
|
44
|
+
): Promise<T> {
|
|
45
|
+
const io = promptIo();
|
|
46
|
+
return run(io).finally(() => io.close());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
intro(title: string, logOptions?: LogOptions): void {
|
|
51
|
+
if (shouldWrite(logOptions)) clackIntro(title, { output });
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
outro(message: string, logOptions?: LogOptions): void {
|
|
55
|
+
if (shouldWrite(logOptions)) clackOutro(message, { output });
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
note(message: string, title?: string, logOptions?: LogOptions): void {
|
|
59
|
+
if (shouldWrite(logOptions)) note(message, title, { output });
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
info(message: string, logOptions?: LogOptions): void {
|
|
63
|
+
if (shouldWrite(logOptions)) log.info(message, { output });
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
step(message: string, logOptions?: LogOptions): void {
|
|
67
|
+
if (shouldWrite(logOptions)) log.step(message, { output });
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
success(message: string, logOptions?: LogOptions): void {
|
|
71
|
+
if (shouldWrite(logOptions)) log.success(message, { output });
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
warn(message: string, logOptions?: LogOptions): void {
|
|
75
|
+
if (shouldWrite(logOptions)) log.warn(message, { output });
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
error(message: string, logOptions?: LogOptions): void {
|
|
79
|
+
if (shouldWrite(logOptions)) log.error(message, { output });
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
debug(message: string): void {
|
|
83
|
+
if (!quiet) log.message(message, { output, symbol: pc.dim("trace") });
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async confirm(message: string): Promise<boolean> {
|
|
87
|
+
return withPromptIo(async (io) => {
|
|
88
|
+
const result = await clackConfirm({
|
|
89
|
+
message,
|
|
90
|
+
active: "Yes",
|
|
91
|
+
inactive: "No",
|
|
92
|
+
initialValue: false,
|
|
93
|
+
input: io.input,
|
|
94
|
+
output: io.output,
|
|
95
|
+
});
|
|
96
|
+
if (isCancel(result)) return false;
|
|
97
|
+
return result;
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
spinner(message: string, logOptions?: LogOptions): SpinnerResult {
|
|
102
|
+
if (!shouldWrite(logOptions)) return silentSpinner();
|
|
103
|
+
const active = spinner({ output });
|
|
104
|
+
active.start(message);
|
|
105
|
+
return active;
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
progress(message: string, max: number, logOptions?: LogOptions) {
|
|
109
|
+
if (!shouldWrite(logOptions)) return silentProgress();
|
|
110
|
+
const active = progress({ output, max });
|
|
111
|
+
active.start(message);
|
|
112
|
+
return active;
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
writeStdout(text: string): void {
|
|
116
|
+
stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const format = {
|
|
122
|
+
heading: (value: string) => pc.bold(value),
|
|
123
|
+
label: (value: string) => pc.cyan(value),
|
|
124
|
+
muted: (value: string) => pc.dim(value),
|
|
125
|
+
success: (value: string) => pc.green(value),
|
|
126
|
+
warn: (value: string) => pc.yellow(value),
|
|
127
|
+
error: (value: string) => pc.red(value),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export function diagnostic(message: string): void {
|
|
131
|
+
log.message(message, {
|
|
132
|
+
output: stderr,
|
|
133
|
+
symbol: pc.dim("ยท"),
|
|
134
|
+
spacing: 0,
|
|
135
|
+
withGuide: false,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function diagnosticError(message: string): void {
|
|
140
|
+
log.error(message, { output: stderr, spacing: 0, withGuide: false });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function promptIo(): PromptIo {
|
|
144
|
+
if (stdin.isTTY && stderr.isTTY) {
|
|
145
|
+
return { input: stdin, output: stderr, close: () => undefined };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (platform() === "win32") {
|
|
149
|
+
throw new Error(
|
|
150
|
+
"No interactive terminal found. Run `stupify` once in an interactive terminal to set up the model.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const input = createReadStream("/dev/tty");
|
|
155
|
+
const output = createWriteStream("/dev/tty");
|
|
156
|
+
return {
|
|
157
|
+
input,
|
|
158
|
+
output,
|
|
159
|
+
close: () => closePromptIo(input, output),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function closePromptIo(input: ReadStream, output: WriteStream): void {
|
|
164
|
+
input.destroy();
|
|
165
|
+
output.end();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function silentSpinner(): SpinnerResult {
|
|
169
|
+
return {
|
|
170
|
+
start: () => undefined,
|
|
171
|
+
stop: () => undefined,
|
|
172
|
+
cancel: () => undefined,
|
|
173
|
+
error: () => undefined,
|
|
174
|
+
message: () => undefined,
|
|
175
|
+
clear: () => undefined,
|
|
176
|
+
get isCancelled() {
|
|
177
|
+
return false;
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function silentProgress() {
|
|
183
|
+
return {
|
|
184
|
+
...silentSpinner(),
|
|
185
|
+
advance: () => undefined,
|
|
186
|
+
};
|
|
187
|
+
}
|