@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/stupify.js CHANGED
@@ -13,44 +13,47 @@ import { helpText, renderSearchRun } from "./render.js";
13
13
  import { effectiveMaxCandidates, effectiveMaxSearchInputTokens, effectiveRepomixConfig, effectiveSearchChecks, loadSearchProfile, } from "./search-profile.js";
14
14
  import { semChangeSetForCommand } from "./sem-provider.js";
15
15
  import { createTracer } from "./trace.js";
16
+ import { createCliUi } from "./ui.js";
16
17
  export async function main(argv = process.argv.slice(2)) {
17
18
  const startedAt = Date.now();
19
+ let ui = createCliUi();
18
20
  try {
19
21
  const command = parseCommand(argv);
20
22
  if (command.kind === "help") {
21
- console.log(helpText());
23
+ ui.writeStdout(helpText());
22
24
  return 0;
23
25
  }
24
26
  if (command.kind === "hook") {
25
- console.log(await runHookCommand(command.action));
27
+ ui.writeStdout(await runHookCommand(command.action));
26
28
  return 0;
27
29
  }
28
30
  if (command.kind === "doctor") {
29
31
  const result = await runDoctor();
30
- console.log(result.text);
32
+ ui.writeStdout(result.text);
31
33
  return result.exitCode;
32
34
  }
33
35
  if (command.kind === "bench-search") {
34
36
  const { runSearchBench } = await import("./search-bench.js");
35
- console.log(await runSearchBench(command.configPath));
37
+ ui.writeStdout(await runSearchBench(command.configPath));
36
38
  return 0;
37
39
  }
38
- const run = await runSearchCommand(command, startedAt);
39
- console.log(renderSearchRun(run, command));
40
+ ui = createCliUi({ quiet: command.json });
41
+ const run = await runSearchCommand(command, startedAt, ui);
42
+ ui.writeStdout(renderSearchRun(run, command));
40
43
  return 0;
41
44
  }
42
45
  catch (error) {
43
- console.error(error instanceof Error ? error.message : String(error));
46
+ ui.error(error instanceof Error ? error.message : String(error), { force: true });
44
47
  return 1;
45
48
  }
46
49
  }
47
- export async function runSearchCommand(command, startedAt) {
50
+ export async function runSearchCommand(command, startedAt, ui = createCliUi({ quiet: command.json })) {
48
51
  const t = createTracer({
49
52
  writeLine: () => undefined,
50
53
  onEvent: (event) => {
51
54
  if (command.json)
52
55
  return;
53
- console.error(formatStep(event.name, event.ms, event.count, event.detail));
56
+ ui.step(formatStep(event.name, event.ms, event.count, event.detail));
54
57
  },
55
58
  });
56
59
  const profile = await loadSearchProfile(command.searchProfilePath);
@@ -58,7 +61,7 @@ export async function runSearchCommand(command, startedAt) {
58
61
  const patternIds = checks.map((check) => check.id);
59
62
  const maxCandidates = effectiveMaxCandidates(command.maxCandidates, profile);
60
63
  const maxSearchInputTokens = effectiveMaxSearchInputTokens(command.maxSearchInputTokens, profile);
61
- printRunPlan(command, patternIds);
64
+ printRunPlan(command, patternIds, ui);
62
65
  const { value: changeSet } = await t.trace("entity.diff", () => semChangeSetForCommand(command), {
63
66
  count: (v) => v.summary.total,
64
67
  detail: (v) => `${v.summary.fileCount} files`,
@@ -78,6 +81,7 @@ export async function runSearchCommand(command, startedAt) {
78
81
  stats: {
79
82
  elapsedMs: Date.now() - startedAt,
80
83
  modelCalls: 0,
84
+ committers: changeSet.committers,
81
85
  skipped: true,
82
86
  skipReason: "no_candidates",
83
87
  filesChanged: changeSet.summary.fileCount,
@@ -114,6 +118,7 @@ export async function runSearchCommand(command, startedAt) {
114
118
  stats: {
115
119
  elapsedMs: Date.now() - startedAt,
116
120
  modelCalls: 0,
121
+ committers: changeSet.committers,
117
122
  skipped: true,
118
123
  skipReason: "no_candidates",
119
124
  filesChanged: changeSet.summary.fileCount,
@@ -156,6 +161,7 @@ export async function runSearchCommand(command, startedAt) {
156
161
  modelCalls: 0,
157
162
  inputTokens: batches.estimatedInputTokens,
158
163
  inputTokenCap: maxSearchInputTokens,
164
+ committers: changeSet.committers,
159
165
  skipped: true,
160
166
  skipReason: "input_too_large",
161
167
  filesChanged: changeSet.summary.fileCount,
@@ -175,13 +181,13 @@ export async function runSearchCommand(command, startedAt) {
175
181
  };
176
182
  }
177
183
  if (batches.wasSplit && !command.json) {
178
- console.error(`Search input is large; queued ${batches.batches.length} smaller search batches.`);
184
+ ui.warn(`Search input is large; queued ${batches.batches.length} smaller search batches.`);
179
185
  if (batches.skippedTargets > 0) {
180
- console.error(`Skipped ${batches.skippedTargets} oversized targets that could not fit alone.`);
186
+ ui.warn(`Skipped ${batches.skippedTargets} oversized targets that could not fit alone.`);
181
187
  }
182
188
  }
183
- const modelPath = await firstRunModelBootstrap(command.model);
184
- const model = await loadLocalModel(modelPath, command.model, "scout");
189
+ const modelPath = await firstRunModelBootstrap(command.model, ui);
190
+ const model = await loadLocalModel(modelPath, command.model, "scout", ui);
185
191
  const matches = [];
186
192
  let modelCalls = 0;
187
193
  let inputTokens = 0;
@@ -192,7 +198,7 @@ export async function runSearchCommand(command, startedAt) {
192
198
  if (batchInputTokens > maxSearchInputTokens) {
193
199
  exactSkippedTargets += batch.contexts.length;
194
200
  if (!command.json) {
195
- console.error(`Skipped ${batch.contexts.length} targets after exact token count exceeded the limit.`);
201
+ ui.warn(`Skipped ${batch.contexts.length} targets after exact token count exceeded the limit.`);
196
202
  }
197
203
  continue;
198
204
  }
@@ -212,6 +218,7 @@ export async function runSearchCommand(command, startedAt) {
212
218
  modelCalls,
213
219
  inputTokens,
214
220
  inputTokenCap: maxSearchInputTokens,
221
+ committers: changeSet.committers,
215
222
  filesChanged: changeSet.summary.fileCount,
216
223
  entitiesScanned: changeSet.summary.total,
217
224
  candidates: contexts.length,
@@ -320,12 +327,14 @@ function buildSearchRequest(changeSet, contexts, pack, patterns, profile, includ
320
327
  includeCounterReasonInPrompt: profile?.includeCounterReasonInPrompt ?? includeCounterReasonInPrompt,
321
328
  });
322
329
  }
323
- function printRunPlan(command, patternIds) {
330
+ function printRunPlan(command, patternIds, ui) {
324
331
  if (command.json)
325
332
  return;
326
- console.error("🧙 stupify 🪄");
327
- console.error(`Search: ${sourceLabel(command)}`);
328
- console.error(`Patterns: ${patternIds.join(", ")}`);
333
+ ui.intro("stupify");
334
+ ui.note([
335
+ `Search: ${sourceLabel(command)}`,
336
+ `Patterns: ${patternIds.join(", ")}`,
337
+ ].join("\n"), "Run");
329
338
  }
330
339
  function formatStep(name, ms, count, detail) {
331
340
  if (name === "entity.diff")
package/dist/types.d.ts CHANGED
@@ -105,6 +105,7 @@ export type SourceRange = Readonly<{
105
105
  label: string;
106
106
  base: string;
107
107
  target: string;
108
+ committers?: readonly string[];
108
109
  stats: NetDiffStats;
109
110
  }>;
110
111
  export type SemChange = Readonly<{
@@ -130,6 +131,7 @@ export type SemChangeSet = Readonly<{
130
131
  label: string;
131
132
  base: string;
132
133
  target: string;
134
+ committers?: readonly string[];
133
135
  contextCwd: string;
134
136
  cleanup: () => Promise<void>;
135
137
  changes: readonly SemChange[];
@@ -214,6 +216,7 @@ export type SearchRunJson = Readonly<{
214
216
  inputTokenCap?: number;
215
217
  skipped?: boolean;
216
218
  skipReason?: "input_too_large" | "no_candidates";
219
+ committers?: readonly string[];
217
220
  filesChanged?: number;
218
221
  entitiesScanned?: number;
219
222
  candidates?: number;
package/dist/ui.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { type SpinnerResult } from "@clack/prompts";
2
+ export type CliUi = ReturnType<typeof createCliUi>;
3
+ export type CliUiOptions = Readonly<{
4
+ quiet?: boolean;
5
+ }>;
6
+ type LogOptions = Readonly<{
7
+ force?: boolean;
8
+ }>;
9
+ export declare function createCliUi(options?: CliUiOptions): {
10
+ intro(title: string, logOptions?: LogOptions): void;
11
+ outro(message: string, logOptions?: LogOptions): void;
12
+ note(message: string, title?: string, logOptions?: LogOptions): void;
13
+ info(message: string, logOptions?: LogOptions): void;
14
+ step(message: string, logOptions?: LogOptions): void;
15
+ success(message: string, logOptions?: LogOptions): void;
16
+ warn(message: string, logOptions?: LogOptions): void;
17
+ error(message: string, logOptions?: LogOptions): void;
18
+ debug(message: string): void;
19
+ confirm(message: string): Promise<boolean>;
20
+ spinner(message: string, logOptions?: LogOptions): SpinnerResult;
21
+ progress(message: string, max: number, logOptions?: LogOptions): import("@clack/prompts").ProgressResult;
22
+ writeStdout(text: string): void;
23
+ };
24
+ export declare const format: {
25
+ heading: (value: string) => string;
26
+ label: (value: string) => string;
27
+ muted: (value: string) => string;
28
+ success: (value: string) => string;
29
+ warn: (value: string) => string;
30
+ error: (value: string) => string;
31
+ };
32
+ export declare function diagnostic(message: string): void;
33
+ export declare function diagnosticError(message: string): void;
34
+ export {};
package/dist/ui.js ADDED
@@ -0,0 +1,143 @@
1
+ import { confirm as clackConfirm, intro as clackIntro, isCancel, log, note, outro as clackOutro, progress, spinner, } from "@clack/prompts";
2
+ import { createReadStream, createWriteStream } from "node:fs";
3
+ import { platform } from "node:os";
4
+ import { stdin, stderr, stdout } from "node:process";
5
+ import pc from "picocolors";
6
+ export function createCliUi(options = {}) {
7
+ const quiet = options.quiet ?? false;
8
+ const output = stderr;
9
+ function shouldWrite(logOptions) {
10
+ return logOptions?.force === true || !quiet;
11
+ }
12
+ function withPromptIo(run) {
13
+ const io = promptIo();
14
+ return run(io).finally(() => io.close());
15
+ }
16
+ return {
17
+ intro(title, logOptions) {
18
+ if (shouldWrite(logOptions))
19
+ clackIntro(title, { output });
20
+ },
21
+ outro(message, logOptions) {
22
+ if (shouldWrite(logOptions))
23
+ clackOutro(message, { output });
24
+ },
25
+ note(message, title, logOptions) {
26
+ if (shouldWrite(logOptions))
27
+ note(message, title, { output });
28
+ },
29
+ info(message, logOptions) {
30
+ if (shouldWrite(logOptions))
31
+ log.info(message, { output });
32
+ },
33
+ step(message, logOptions) {
34
+ if (shouldWrite(logOptions))
35
+ log.step(message, { output });
36
+ },
37
+ success(message, logOptions) {
38
+ if (shouldWrite(logOptions))
39
+ log.success(message, { output });
40
+ },
41
+ warn(message, logOptions) {
42
+ if (shouldWrite(logOptions))
43
+ log.warn(message, { output });
44
+ },
45
+ error(message, logOptions) {
46
+ if (shouldWrite(logOptions))
47
+ log.error(message, { output });
48
+ },
49
+ debug(message) {
50
+ if (!quiet)
51
+ log.message(message, { output, symbol: pc.dim("trace") });
52
+ },
53
+ async confirm(message) {
54
+ return withPromptIo(async (io) => {
55
+ const result = await clackConfirm({
56
+ message,
57
+ active: "Yes",
58
+ inactive: "No",
59
+ initialValue: false,
60
+ input: io.input,
61
+ output: io.output,
62
+ });
63
+ if (isCancel(result))
64
+ return false;
65
+ return result;
66
+ });
67
+ },
68
+ spinner(message, logOptions) {
69
+ if (!shouldWrite(logOptions))
70
+ return silentSpinner();
71
+ const active = spinner({ output });
72
+ active.start(message);
73
+ return active;
74
+ },
75
+ progress(message, max, logOptions) {
76
+ if (!shouldWrite(logOptions))
77
+ return silentProgress();
78
+ const active = progress({ output, max });
79
+ active.start(message);
80
+ return active;
81
+ },
82
+ writeStdout(text) {
83
+ stdout.write(text.endsWith("\n") ? text : `${text}\n`);
84
+ },
85
+ };
86
+ }
87
+ export const format = {
88
+ heading: (value) => pc.bold(value),
89
+ label: (value) => pc.cyan(value),
90
+ muted: (value) => pc.dim(value),
91
+ success: (value) => pc.green(value),
92
+ warn: (value) => pc.yellow(value),
93
+ error: (value) => pc.red(value),
94
+ };
95
+ export function diagnostic(message) {
96
+ log.message(message, {
97
+ output: stderr,
98
+ symbol: pc.dim("·"),
99
+ spacing: 0,
100
+ withGuide: false,
101
+ });
102
+ }
103
+ export function diagnosticError(message) {
104
+ log.error(message, { output: stderr, spacing: 0, withGuide: false });
105
+ }
106
+ function promptIo() {
107
+ if (stdin.isTTY && stderr.isTTY) {
108
+ return { input: stdin, output: stderr, close: () => undefined };
109
+ }
110
+ if (platform() === "win32") {
111
+ throw new Error("No interactive terminal found. Run `stupify` once in an interactive terminal to set up the model.");
112
+ }
113
+ const input = createReadStream("/dev/tty");
114
+ const output = createWriteStream("/dev/tty");
115
+ return {
116
+ input,
117
+ output,
118
+ close: () => closePromptIo(input, output),
119
+ };
120
+ }
121
+ function closePromptIo(input, output) {
122
+ input.destroy();
123
+ output.end();
124
+ }
125
+ function silentSpinner() {
126
+ return {
127
+ start: () => undefined,
128
+ stop: () => undefined,
129
+ cancel: () => undefined,
130
+ error: () => undefined,
131
+ message: () => undefined,
132
+ clear: () => undefined,
133
+ get isCancelled() {
134
+ return false;
135
+ },
136
+ };
137
+ }
138
+ function silentProgress() {
139
+ return {
140
+ ...silentSpinner(),
141
+ advance: () => undefined,
142
+ };
143
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stupify/cli",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "description": "Local-only diagnostic CLI for checking whether AI is making you dumber.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -44,6 +44,8 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@ataraxy-labs/sem": "^0.3.24",
47
+ "@clack/prompts": "^1.2.0",
48
+ "picocolors": "^1.1.1",
47
49
  "repomix": "^1.14.0"
48
50
  }
49
51
  }
package/src/analysis.ts CHANGED
@@ -2,6 +2,7 @@ import { cachedJson, fingerprint } from "./cache.ts";
2
2
  import type { LocalModel } from "./model.ts";
3
3
  import { searchPrompt } from "./prompts.ts";
4
4
  import type { SearchMatch, SemChangeSet, SemContext, SemContextPack, StupifyCheck } from "./types.ts";
5
+ import { diagnostic, diagnosticError } from "./ui.ts";
5
6
 
6
7
  export async function runSearch(
7
8
  model: LocalModel,
@@ -150,8 +151,8 @@ Your previous response was not valid JSON. Return the requested JSON object only
150
151
  const retryParsed = parseJson(retry);
151
152
  if (retryParsed.ok) return retryParsed.value;
152
153
 
153
- console.error("Raw model output:");
154
- console.error(retry);
154
+ diagnosticError("Raw model output:");
155
+ diagnostic(retry);
155
156
  throw new Error("Model returned invalid JSON.");
156
157
  }
157
158
 
package/src/constants.ts CHANGED
@@ -1,4 +1,4 @@
1
- export const VERSION = "0.0.8";
1
+ export const VERSION = "0.0.10";
2
2
  import type { ModelConfig, ModelId } from "./types.ts";
3
3
 
4
4
  export const DEFAULT_MODEL_ID: ModelId = "gemma-4-e2b";
package/src/git.ts CHANGED
@@ -96,6 +96,15 @@ export async function gitPath(pathspec: string): Promise<string> {
96
96
  }
97
97
  }
98
98
 
99
+ export async function gitUserLabel(): Promise<string> {
100
+ const [name, email] = await Promise.all([
101
+ gitConfig("user.name"),
102
+ gitConfig("user.email"),
103
+ ]);
104
+ if (name && email) return `${name} <${email}>`;
105
+ return name || email || "working tree";
106
+ }
107
+
99
108
  async function netDiff(base: string, target: string, label: string, id?: NetDiff["id"]): Promise<NetDiff> {
100
109
  const [text, stats, shortBase, shortTarget] = await Promise.all([
101
110
  diff(base, target),
@@ -114,20 +123,54 @@ async function netDiff(base: string, target: string, label: string, id?: NetDiff
114
123
  }
115
124
 
116
125
  async function sourceRange(base: string, target: string, label: string, id?: SourceRange["id"]): Promise<SourceRange> {
117
- const [stats, shortBase, shortTarget] = await Promise.all([
126
+ const [stats, shortBase, shortTarget, committers] = await Promise.all([
118
127
  diffStats(base, target),
119
128
  shortCommit(base),
120
129
  shortCommit(target),
130
+ committersForRange(base, target),
121
131
  ]);
122
132
  return {
123
133
  id: id ?? sourceId(`net:${shortBase}..${shortTarget}`),
124
134
  label,
125
135
  base,
126
136
  target,
137
+ committers,
127
138
  stats,
128
139
  };
129
140
  }
130
141
 
142
+ async function gitConfig(key: string): Promise<string> {
143
+ try {
144
+ const { stdout } = await execFileAsync("git", ["config", "--get", key]);
145
+ return stdout.trim();
146
+ } catch {
147
+ return "";
148
+ }
149
+ }
150
+
151
+ async function committersForRange(base: string, target: string): Promise<readonly string[]> {
152
+ try {
153
+ const { stdout } = await execFileAsync("git", ["log", "--format=%cn <%ce>", `${base}..${target}`], {
154
+ maxBuffer: 4 * 1024 * 1024,
155
+ });
156
+ return uniqueLines(stdout);
157
+ } catch {
158
+ return [];
159
+ }
160
+ }
161
+
162
+ function uniqueLines(value: string): readonly string[] {
163
+ const seen = new Set<string>();
164
+ const lines: string[] = [];
165
+ for (const line of value.split(/\r?\n/)) {
166
+ const trimmed = line.trim();
167
+ if (!trimmed || seen.has(trimmed)) continue;
168
+ seen.add(trimmed);
169
+ lines.push(trimmed);
170
+ }
171
+ return lines;
172
+ }
173
+
131
174
  async function baseBefore(since: string): Promise<string> {
132
175
  try {
133
176
  const { stdout } = await execFileAsync("git", [
package/src/model.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
- import { createReadStream, createWriteStream } from "node:fs";
3
2
  import {
4
3
  mkdir,
5
4
  open,
@@ -10,16 +9,11 @@ import {
10
9
  writeFile,
11
10
  } from "node:fs/promises";
12
11
  import { homedir, platform } from "node:os";
13
- import {
14
- stdin as input,
15
- stderr as statusOutput,
16
- stdout as output,
17
- } from "node:process";
18
- import { createInterface } from "node:readline/promises";
19
12
  import path from "node:path";
20
13
  import { promisify } from "node:util";
21
14
  import { MODEL_REGISTRY } from "./constants.ts";
22
15
  import type { ModelId } from "./types.ts";
16
+ import type { CliUi } from "./ui.ts";
23
17
 
24
18
  const execFileAsync = promisify(execFile);
25
19
  const LLAMA_SERVER_HOST = "127.0.0.1";
@@ -51,22 +45,23 @@ export type LocalModel = Readonly<{
51
45
 
52
46
  export async function firstRunModelBootstrap(
53
47
  modelId: ModelId,
48
+ ui: CliUi,
54
49
  ): Promise<string> {
55
50
  const selectedModel = MODEL_REGISTRY[modelId];
56
51
  const modelDir = path.join(cacheDir(), "models");
57
52
  const modelPath = path.join(modelDir, selectedModel.file);
58
53
  if (await exists(modelPath)) return modelPath;
59
54
 
60
- console.error(`No local Stupify model found.
55
+ ui.note(`No local Stupify model found.
61
56
  Stupify runs locally.
62
57
  Download this model now?
63
58
  Model: ${selectedModel.name}
64
- Size: ${selectedModel.size}`);
59
+ Size: ${selectedModel.size}`, "Setup", { force: true });
65
60
 
66
- if (!(await confirm("Continue? y/N "))) throw new Error("Setup cancelled.");
61
+ if (!(await ui.confirm("Continue?"))) throw new Error("Setup cancelled.");
67
62
 
68
63
  await mkdir(modelDir, { recursive: true });
69
- await downloadModel(modelPath, selectedModel.url);
64
+ await downloadModel(modelPath, selectedModel.url, ui);
70
65
  if (!(await exists(modelPath)))
71
66
  throw new Error("Model download failed: file was not created.");
72
67
  return modelPath;
@@ -75,18 +70,17 @@ Size: ${selectedModel.size}`);
75
70
  export async function loadLocalModel(
76
71
  modelPath: string,
77
72
  modelId: ModelId,
78
- profile: ModelProfile = "scout",
73
+ profile: ModelProfile,
74
+ ui: CliUi,
79
75
  ): Promise<LocalModel> {
80
76
  const selectedModel = MODEL_REGISTRY[modelId];
81
77
  const runtime = modelRuntime(profile);
82
78
  const runningModel = await runningServerModel(runtime.baseUrl);
83
79
 
84
80
  if (runningModel) {
85
- if (runningModel !== modelId) await stopManagedServer(runtime);
81
+ if (runningModel !== modelId) await stopManagedServer(runtime, ui);
86
82
  if (runningModel === modelId) {
87
- console.error(
88
- `Using local model: ${selectedModel.name}`,
89
- );
83
+ ui.info(`Using local model: ${selectedModel.name}`);
90
84
  return {
91
85
  id: modelId,
92
86
  name: selectedModel.name,
@@ -97,8 +91,15 @@ export async function loadLocalModel(
97
91
  }
98
92
 
99
93
  await ensureLlamaServerBinary();
100
- await startLlamaServer(modelPath, modelId, selectedModel.name, runtime);
101
- await waitForServer(runtime.baseUrl, modelId);
94
+ await startLlamaServer(modelPath, modelId, selectedModel.name, runtime, ui);
95
+ const ready = ui.spinner(`Waiting for local ${profile} model`);
96
+ try {
97
+ await waitForServer(runtime.baseUrl, modelId);
98
+ ready.stop(`Local ${profile} model ready`);
99
+ } catch (error) {
100
+ ready.error(`Local ${profile} model failed to start`);
101
+ throw error;
102
+ }
102
103
  return {
103
104
  id: modelId,
104
105
  name: selectedModel.name,
@@ -159,6 +160,7 @@ async function startLlamaServer(
159
160
  modelId: ModelId,
160
161
  modelName: string,
161
162
  runtime: ModelRuntime,
163
+ ui: CliUi,
162
164
  ): Promise<void> {
163
165
  const logDir = path.join(cacheDir(), "logs");
164
166
  await mkdir(logDir, { recursive: true });
@@ -166,8 +168,8 @@ async function startLlamaServer(
166
168
  const out = await open(logPath, "a");
167
169
  const err = await open(logPath, "a");
168
170
 
169
- console.error(`Starting local model server: ${modelName}`);
170
- console.error(`llama-server log: ${logPath}`);
171
+ ui.step(`Starting local model server: ${modelName}`);
172
+ ui.info(`llama-server log: ${logPath}`);
171
173
 
172
174
  const args = [
173
175
  "-m",
@@ -206,7 +208,7 @@ async function startLlamaServer(
206
208
  await err.close();
207
209
  }
208
210
 
209
- async function stopManagedServer(runtime: ModelRuntime): Promise<void> {
211
+ async function stopManagedServer(runtime: ModelRuntime, ui: CliUi): Promise<void> {
210
212
  const pid = await managedServerPid(runtime);
211
213
  if (!pid) {
212
214
  const runningModel = await runningServerModel(runtime.baseUrl);
@@ -214,9 +216,7 @@ async function stopManagedServer(runtime: ModelRuntime): Promise<void> {
214
216
  Stop it before switching models, or use STUPIFY_LLAMA_SERVER_URL for that server.`);
215
217
  }
216
218
 
217
- console.error(
218
- "Restarting local model server for selected model.",
219
- );
219
+ ui.step("Restarting local model server for selected model.");
220
220
  try {
221
221
  process.kill(pid, "SIGTERM");
222
222
  } catch {
@@ -279,11 +279,13 @@ function sleep(ms: number): Promise<void> {
279
279
  async function downloadModel(
280
280
  modelPath: string,
281
281
  modelUrl: string,
282
+ ui: CliUi,
282
283
  ): Promise<void> {
283
284
  const tempPath = `${modelPath}.download`;
284
285
  await rm(tempPath, { force: true });
285
286
 
286
- console.error("Downloading model...");
287
+ const downloadSpinner = ui.spinner("Downloading model");
288
+ let downloadProgress: ReturnType<CliUi["progress"]> | null = null;
287
289
  try {
288
290
  const response = await fetch(modelUrl);
289
291
  if (!response.ok || !response.body)
@@ -292,8 +294,13 @@ async function downloadModel(
292
294
  const total = Number(response.headers.get("content-length") ?? 0);
293
295
  let received = 0;
294
296
  let lastPrint = 0;
297
+ let lastProgressBytes = 0;
295
298
  const reader = response.body.getReader();
296
299
  const file = await open(tempPath, "wx");
300
+ if (total > 0) {
301
+ downloadSpinner.clear();
302
+ downloadProgress = ui.progress("Downloading model", total);
303
+ }
297
304
 
298
305
  try {
299
306
  while (true) {
@@ -304,51 +311,38 @@ async function downloadModel(
304
311
  const now = Date.now();
305
312
  if (total > 0 && now - lastPrint > 500) {
306
313
  lastPrint = now;
307
- statusOutput.write(
308
- `\r${formatBytes(received)} / ${formatBytes(total)}`,
314
+ downloadProgress?.advance(
315
+ received - lastProgressBytes,
316
+ `${formatBytes(received)} / ${formatBytes(total)}`,
309
317
  );
318
+ lastProgressBytes = received;
310
319
  }
311
320
  }
312
321
  } finally {
313
322
  await file.close();
314
323
  }
315
324
 
316
- if (total > 0)
317
- statusOutput.write(
318
- `\r${formatBytes(received)} / ${formatBytes(total)}\n`,
325
+ if (downloadProgress && received > lastProgressBytes) {
326
+ downloadProgress.advance(
327
+ received - lastProgressBytes,
328
+ `${formatBytes(received)} / ${formatBytes(total)}`,
319
329
  );
330
+ }
331
+ const activeProgress = downloadProgress ?? downloadSpinner;
332
+ activeProgress.stop(
333
+ total > 0
334
+ ? `Downloaded ${formatBytes(received)} / ${formatBytes(total)}`
335
+ : "Downloaded model",
336
+ );
320
337
  await rename(tempPath, modelPath);
321
338
  } catch (error) {
339
+ const activeProgress = downloadProgress ?? downloadSpinner;
340
+ activeProgress.error("Model download failed");
322
341
  await rm(tempPath, { force: true });
323
342
  throw error;
324
343
  }
325
344
  }
326
345
 
327
- async function confirm(question: string): Promise<boolean> {
328
- const rl = createInterface(terminalIo());
329
- try {
330
- const answer = (await rl.question(question)).trim().toLowerCase();
331
- return answer === "y" || answer === "yes";
332
- } finally {
333
- rl.close();
334
- }
335
- }
336
-
337
- function terminalIo(): {
338
- input: NodeJS.ReadableStream;
339
- output: NodeJS.WritableStream;
340
- } {
341
- if (input.isTTY) return { input, output };
342
- if (platform() !== "win32")
343
- return {
344
- input: createReadStream("/dev/tty"),
345
- output: createWriteStream("/dev/tty"),
346
- };
347
- throw new Error(
348
- "No local Stupify model found. Run `stupify` once in an interactive terminal to set up the model.",
349
- );
350
- }
351
-
352
346
  function cacheDir(): string {
353
347
  if (process.env.STUPIFY_CACHE_DIR) return process.env.STUPIFY_CACHE_DIR;
354
348
  if (process.env.XDG_CACHE_HOME)