@stupify/cli 0.0.1 → 0.0.3

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.
Files changed (63) hide show
  1. package/README.md +60 -0
  2. package/dist/analysis.d.ts +14 -0
  3. package/dist/analysis.js +276 -0
  4. package/dist/batcher.d.ts +3 -0
  5. package/dist/batcher.js +142 -0
  6. package/dist/cache.d.ts +2 -0
  7. package/dist/cache.js +59 -0
  8. package/dist/candidate-context.d.ts +2 -0
  9. package/dist/candidate-context.js +40 -0
  10. package/dist/checks.d.ts +3 -0
  11. package/dist/checks.js +131 -0
  12. package/dist/command.d.ts +2 -0
  13. package/dist/command.js +183 -0
  14. package/dist/constants.d.ts +4 -0
  15. package/dist/constants.js +53 -0
  16. package/dist/counter-scout.d.ts +14 -0
  17. package/dist/counter-scout.js +97 -0
  18. package/dist/diff.d.ts +1 -0
  19. package/dist/diff.js +10 -0
  20. package/dist/experiment.d.ts +1 -0
  21. package/dist/experiment.js +225 -0
  22. package/dist/git.d.ts +8 -0
  23. package/dist/git.js +219 -0
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.js +1 -0
  26. package/dist/model.d.ts +24 -0
  27. package/dist/model.js +281 -0
  28. package/dist/prompts.d.ts +5 -0
  29. package/dist/prompts.js +197 -0
  30. package/dist/render.d.ts +3 -0
  31. package/dist/render.js +101 -0
  32. package/dist/repomix-provider.d.ts +4 -0
  33. package/dist/repomix-provider.js +145 -0
  34. package/dist/sem-provider.d.ts +2 -0
  35. package/dist/sem-provider.js +221 -0
  36. package/dist/stupify.d.ts +2 -0
  37. package/dist/stupify.js +387 -0
  38. package/dist/trace.d.ts +29 -0
  39. package/dist/trace.js +64 -0
  40. package/dist/types.d.ts +236 -0
  41. package/dist/types.js +6 -0
  42. package/package.json +42 -5
  43. package/src/analysis.ts +408 -0
  44. package/src/batcher.ts +198 -0
  45. package/src/cache.ts +65 -0
  46. package/src/candidate-context.ts +43 -0
  47. package/src/checks.ts +132 -0
  48. package/src/command.ts +218 -0
  49. package/src/constants.ts +56 -0
  50. package/src/counter-scout.ts +119 -0
  51. package/src/diff.ts +9 -0
  52. package/src/experiment.ts +317 -0
  53. package/src/git.ts +228 -0
  54. package/src/index.ts +1 -0
  55. package/src/model.ts +360 -0
  56. package/src/prompts.ts +234 -0
  57. package/src/render.ts +107 -0
  58. package/src/repomix-provider.ts +163 -0
  59. package/src/sem-provider.ts +255 -0
  60. package/src/stupify.ts +598 -0
  61. package/src/trace.ts +103 -0
  62. package/src/types.ts +264 -0
  63. package/bin/stupify.mjs +0 -3
package/src/git.ts ADDED
@@ -0,0 +1,228 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { sourceId, type NetDiff, type NetDiffStats, type SourceRange } from "./types.ts";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ export async function netDiffSince(since: string): Promise<NetDiff> {
8
+ const range = await sourceRangeSince(since);
9
+ return netDiff(range.base, range.target, range.label, range.id);
10
+ }
11
+
12
+ export async function netDiffForCommit(commit: string): Promise<NetDiff> {
13
+ const range = await sourceRangeForCommit(commit);
14
+ return netDiff(range.base, range.target, range.label, range.id);
15
+ }
16
+
17
+ export async function netDiffForRecentCommits(count: number): Promise<NetDiff> {
18
+ const range = await sourceRangeForRecentCommits(count);
19
+ return netDiff(range.base, range.target, range.label, range.id);
20
+ }
21
+
22
+ export async function sourceRangeSince(since: string): Promise<SourceRange> {
23
+ const [base, target] = await Promise.all([baseBefore(since), revParse("HEAD")]);
24
+ return sourceRange(base, target, `last ${since}`);
25
+ }
26
+
27
+ export async function sourceRangeForCommit(commit: string): Promise<SourceRange> {
28
+ const [base, target, shortTarget, message] = await Promise.all([
29
+ revParse(`${commit}^1`),
30
+ revParse(commit),
31
+ shortCommit(commit),
32
+ commitMessage(commit),
33
+ ]);
34
+ return sourceRange(base, target, firstLine(message) || shortTarget, sourceId(shortTarget));
35
+ }
36
+
37
+ export async function sourceRangeForRecentCommits(count: number): Promise<SourceRange> {
38
+ const commits = await recentCommits(count);
39
+ if (commits.length === 0) throw new Error("No non-merge commits found.");
40
+
41
+ const oldest = commits[0];
42
+ const newest = commits[commits.length - 1];
43
+ const [base, target, shortBase, shortTarget] = await Promise.all([
44
+ revParse(`${oldest}^1`),
45
+ revParse(newest),
46
+ shortCommit(`${oldest}^1`),
47
+ shortCommit(newest),
48
+ ]);
49
+
50
+ return sourceRange(base, target, `${commits.length} recent commits`, sourceId(`range:${shortBase}..${shortTarget}`));
51
+ }
52
+
53
+ export async function netDiffFromStdin(text: string): Promise<NetDiff> {
54
+ if (!text.trim()) throw new Error("No diff received on stdin.");
55
+ return {
56
+ id: sourceId("stdin"),
57
+ label: "stdin",
58
+ base: "stdin",
59
+ target: "stdin",
60
+ text,
61
+ stats: statsFromDiff(text),
62
+ };
63
+ }
64
+
65
+ async function netDiff(base: string, target: string, label: string, id?: NetDiff["id"]): Promise<NetDiff> {
66
+ const [text, stats, shortBase, shortTarget] = await Promise.all([
67
+ diff(base, target),
68
+ diffStats(base, target),
69
+ shortCommit(base),
70
+ shortCommit(target),
71
+ ]);
72
+ return {
73
+ id: id ?? sourceId(`net:${shortBase}..${shortTarget}`),
74
+ label,
75
+ base,
76
+ target,
77
+ text,
78
+ stats,
79
+ };
80
+ }
81
+
82
+ async function sourceRange(base: string, target: string, label: string, id?: SourceRange["id"]): Promise<SourceRange> {
83
+ const [stats, shortBase, shortTarget] = await Promise.all([
84
+ diffStats(base, target),
85
+ shortCommit(base),
86
+ shortCommit(target),
87
+ ]);
88
+ return {
89
+ id: id ?? sourceId(`net:${shortBase}..${shortTarget}`),
90
+ label,
91
+ base,
92
+ target,
93
+ stats,
94
+ };
95
+ }
96
+
97
+ async function baseBefore(since: string): Promise<string> {
98
+ try {
99
+ const { stdout } = await execFileAsync("git", [
100
+ "log",
101
+ "--first-parent",
102
+ "--before",
103
+ since,
104
+ "-1",
105
+ "--format=%H",
106
+ ]);
107
+ const commit = stdout.trim();
108
+ if (commit) return commit;
109
+ return rootCommit();
110
+ } catch {
111
+ throw new Error(`Could not resolve base commit before ${since}.`);
112
+ }
113
+ }
114
+
115
+ async function rootCommit(): Promise<string> {
116
+ try {
117
+ const { stdout } = await execFileAsync("git", ["rev-list", "--max-parents=0", "HEAD"]);
118
+ return stdout.trim().split(/\r?\n/, 1)[0] ?? "";
119
+ } catch {
120
+ throw new Error("Could not resolve repository root commit.");
121
+ }
122
+ }
123
+
124
+ async function diff(base: string, target: string): Promise<string> {
125
+ try {
126
+ const { stdout } = await execFileAsync("git", [
127
+ "diff",
128
+ "--no-ext-diff",
129
+ "--no-color",
130
+ "--unified=8",
131
+ base,
132
+ target,
133
+ "--",
134
+ ], { maxBuffer: 128 * 1024 * 1024 });
135
+ if (!stdout.trim()) throw new Error("empty diff");
136
+ return stdout;
137
+ } catch {
138
+ throw new Error(`No diff found for ${base}..${target}.`);
139
+ }
140
+ }
141
+
142
+ async function diffStats(base: string, target: string): Promise<NetDiffStats> {
143
+ try {
144
+ const { stdout } = await execFileAsync("git", ["diff", "--numstat", base, target, "--"], {
145
+ maxBuffer: 16 * 1024 * 1024,
146
+ });
147
+ return statsFromNumstat(stdout);
148
+ } catch {
149
+ return { filesChanged: 0, additions: 0, deletions: 0 };
150
+ }
151
+ }
152
+
153
+ function statsFromDiff(diffText: string): NetDiffStats {
154
+ const files = new Set<string>();
155
+ let additions = 0;
156
+ let deletions = 0;
157
+ for (const line of diffText.split(/\r?\n/)) {
158
+ const fileMatch = /^diff --git a\/.+ b\/(.+)$/.exec(line);
159
+ if (fileMatch) files.add(fileMatch[1]);
160
+ else if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
161
+ else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
162
+ }
163
+ return { filesChanged: files.size, additions, deletions };
164
+ }
165
+
166
+ function statsFromNumstat(numstat: string): NetDiffStats {
167
+ let filesChanged = 0;
168
+ let additions = 0;
169
+ let deletions = 0;
170
+
171
+ for (const line of numstat.split(/\r?\n/)) {
172
+ if (!line.trim()) continue;
173
+ const [added, deleted] = line.split(/\s+/, 3);
174
+ filesChanged += 1;
175
+ additions += Number(added) || 0;
176
+ deletions += Number(deleted) || 0;
177
+ }
178
+
179
+ return { filesChanged, additions, deletions };
180
+ }
181
+
182
+ async function recentCommits(count: number): Promise<readonly string[]> {
183
+ try {
184
+ const { stdout } = await execFileAsync("git", [
185
+ "log",
186
+ "--first-parent",
187
+ "--no-merges",
188
+ "--format=%H",
189
+ `-${count}`,
190
+ ]);
191
+ return stdout.split(/\r?\n/).filter(Boolean).reverse();
192
+ } catch {
193
+ throw new Error(`Could not read last ${count} commits.`);
194
+ }
195
+ }
196
+
197
+ async function revParse(rev: string): Promise<string> {
198
+ try {
199
+ const { stdout } = await execFileAsync("git", ["rev-parse", rev]);
200
+ return stdout.trim();
201
+ } catch {
202
+ throw new Error(`Could not resolve ${rev}.`);
203
+ }
204
+ }
205
+
206
+ async function shortCommit(commit: string): Promise<string> {
207
+ try {
208
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--short", commit]);
209
+ return stdout.trim();
210
+ } catch {
211
+ throw new Error(`Could not resolve commit ${commit}.`);
212
+ }
213
+ }
214
+
215
+ async function commitMessage(commit: string): Promise<string> {
216
+ try {
217
+ const { stdout } = await execFileAsync("git", ["show", "--no-patch", "--format=%B", commit], {
218
+ maxBuffer: 1024 * 1024,
219
+ });
220
+ return stdout;
221
+ } catch {
222
+ throw new Error(`Could not read commit message for ${commit}.`);
223
+ }
224
+ }
225
+
226
+ function firstLine(value: string): string {
227
+ return value.trim().split(/\r?\n/, 1)[0]?.trim() ?? "";
228
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { main } from "./stupify.ts";
package/src/model.ts ADDED
@@ -0,0 +1,360 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { createReadStream, createWriteStream } from "node:fs";
3
+ import {
4
+ mkdir,
5
+ open,
6
+ readFile,
7
+ rename,
8
+ rm,
9
+ stat,
10
+ writeFile,
11
+ } from "node:fs/promises";
12
+ 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
+ import path from "node:path";
20
+ import { promisify } from "node:util";
21
+ import { MODEL_REGISTRY } from "./constants.ts";
22
+ import type { ModelId } from "./types.ts";
23
+
24
+ const execFileAsync = promisify(execFile);
25
+ const LLAMA_SERVER_HOST = "127.0.0.1";
26
+
27
+ export type ModelProfile = "scout" | "audit";
28
+
29
+ type ModelRuntime = Readonly<{
30
+ profile: ModelProfile;
31
+ baseUrl: string;
32
+ port: string;
33
+ reasoning: "on" | "off" | "auto";
34
+ reasoningBudget?: number;
35
+ }>;
36
+
37
+ export type LocalModel = Readonly<{
38
+ id: ModelId;
39
+ name: string;
40
+ baseUrl: string;
41
+ profile: ModelProfile;
42
+ }>;
43
+
44
+ export async function loadLocalModels(modelId: ModelId) {
45
+ const modelPath = await firstRunModelBootstrap(modelId);
46
+ const scoutModel = await loadLocalModel(modelPath, modelId, "scout");
47
+ const auditModel = await loadLocalModel(modelPath, modelId, "audit");
48
+ return { scoutModel, auditModel };
49
+ }
50
+
51
+ export async function firstRunModelBootstrap(
52
+ modelId: ModelId,
53
+ ): Promise<string> {
54
+ const selectedModel = MODEL_REGISTRY[modelId];
55
+ const modelDir = path.join(cacheDir(), "models");
56
+ const modelPath = path.join(modelDir, selectedModel.file);
57
+ if (await exists(modelPath)) return modelPath;
58
+
59
+ console.error(`No local Stupify model found.
60
+ Stupify runs locally.
61
+ Download this model now?
62
+ Model: ${selectedModel.name}
63
+ Size: ${selectedModel.size}`);
64
+
65
+ if (!(await confirm("Continue? y/N "))) throw new Error("Setup cancelled.");
66
+
67
+ await mkdir(modelDir, { recursive: true });
68
+ await downloadModel(modelPath, selectedModel.url);
69
+ if (!(await exists(modelPath)))
70
+ throw new Error("Model download failed: file was not created.");
71
+ return modelPath;
72
+ }
73
+
74
+ export async function loadLocalModel(
75
+ modelPath: string,
76
+ modelId: ModelId,
77
+ profile: ModelProfile = "scout",
78
+ ): Promise<LocalModel> {
79
+ const selectedModel = MODEL_REGISTRY[modelId];
80
+ const runtime = modelRuntime(profile);
81
+ const runningModel = await runningServerModel(runtime.baseUrl);
82
+
83
+ if (runningModel) {
84
+ if (runningModel !== modelId) await stopManagedServer(runtime);
85
+ if (runningModel === modelId) {
86
+ console.error(
87
+ `Using already-loaded local ${profile} model: ${selectedModel.name}`,
88
+ );
89
+ return {
90
+ id: modelId,
91
+ name: selectedModel.name,
92
+ baseUrl: runtime.baseUrl,
93
+ profile,
94
+ };
95
+ }
96
+ }
97
+
98
+ await ensureLlamaServerBinary();
99
+ await startLlamaServer(modelPath, modelId, selectedModel.name, runtime);
100
+ await waitForServer(runtime.baseUrl, modelId);
101
+ return {
102
+ id: modelId,
103
+ name: selectedModel.name,
104
+ baseUrl: runtime.baseUrl,
105
+ profile,
106
+ };
107
+ }
108
+
109
+ function modelRuntime(profile: ModelProfile): ModelRuntime {
110
+ if (profile === "audit") {
111
+ const baseUrl =
112
+ process.env.STUPIFY_AUDIT_LLAMA_SERVER_URL ?? "http://127.0.0.1:8092";
113
+ return {
114
+ profile,
115
+ baseUrl,
116
+ port: new URL(baseUrl).port || "8092",
117
+ reasoning: "on",
118
+ reasoningBudget: 4_096,
119
+ };
120
+ }
121
+
122
+ const baseUrl =
123
+ process.env.STUPIFY_SCOUT_LLAMA_SERVER_URL ??
124
+ process.env.STUPIFY_LLAMA_SERVER_URL ??
125
+ "http://127.0.0.1:8091";
126
+ return {
127
+ profile,
128
+ baseUrl,
129
+ port: new URL(baseUrl).port || "8091",
130
+ reasoning: "off",
131
+ };
132
+ }
133
+
134
+ async function runningServerModel(baseUrl: string): Promise<string | null> {
135
+ try {
136
+ const response = await fetch(`${baseUrl}/v1/models`, {
137
+ signal: AbortSignal.timeout(500),
138
+ });
139
+ if (!response.ok) return null;
140
+ const body = (await response.json()) as { data?: Array<{ id?: unknown }> };
141
+ const id = body.data?.[0]?.id;
142
+ return typeof id === "string" ? id : null;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ async function ensureLlamaServerBinary(): Promise<void> {
149
+ try {
150
+ await execFileAsync("llama-server", ["--version"], {
151
+ maxBuffer: 1024 * 1024,
152
+ });
153
+ } catch {
154
+ throw new Error(`Stupify needs llama-server for local inference.
155
+ Install llama.cpp first:
156
+ brew install llama.cpp`);
157
+ }
158
+ }
159
+
160
+ async function startLlamaServer(
161
+ modelPath: string,
162
+ modelId: ModelId,
163
+ modelName: string,
164
+ runtime: ModelRuntime,
165
+ ): Promise<void> {
166
+ const logDir = path.join(cacheDir(), "logs");
167
+ await mkdir(logDir, { recursive: true });
168
+ const logPath = path.join(logDir, "llama-server.log");
169
+ const out = await open(logPath, "a");
170
+ const err = await open(logPath, "a");
171
+
172
+ console.error(`Starting local ${runtime.profile} model server: ${modelName}`);
173
+ console.error(`llama-server log: ${logPath}`);
174
+
175
+ const args = [
176
+ "-m",
177
+ modelPath,
178
+ "-a",
179
+ modelId,
180
+ "--host",
181
+ LLAMA_SERVER_HOST,
182
+ "--port",
183
+ runtime.port,
184
+ "-c",
185
+ "65536",
186
+ "--reasoning",
187
+ runtime.reasoning,
188
+ "--no-warmup",
189
+ ];
190
+ if (runtime.reasoningBudget !== undefined) {
191
+ args.push("--reasoning-budget", String(runtime.reasoningBudget));
192
+ }
193
+
194
+ const child = spawn("llama-server", args, {
195
+ detached: true,
196
+ stdio: ["ignore", out.fd, err.fd],
197
+ });
198
+
199
+ child.unref();
200
+ if (child.pid) await writeFile(pidPath(runtime), String(child.pid));
201
+ await out.close();
202
+ await err.close();
203
+ }
204
+
205
+ async function stopManagedServer(runtime: ModelRuntime): Promise<void> {
206
+ const pid = await managedServerPid(runtime);
207
+ if (!pid) {
208
+ const runningModel = await runningServerModel(runtime.baseUrl);
209
+ throw new Error(`A llama-server is already running with ${runningModel ?? "another model"}.
210
+ Stop it before switching models, or use STUPIFY_LLAMA_SERVER_URL for that server.`);
211
+ }
212
+
213
+ console.error(
214
+ `Restarting local ${runtime.profile} model server for selected model.`,
215
+ );
216
+ try {
217
+ process.kill(pid, "SIGTERM");
218
+ } catch {
219
+ await rm(pidPath(runtime), { force: true });
220
+ return;
221
+ }
222
+
223
+ const deadline = Date.now() + 15_000;
224
+ while (Date.now() < deadline) {
225
+ if (!(await runningServerModel(runtime.baseUrl))) {
226
+ await rm(pidPath(runtime), { force: true });
227
+ return;
228
+ }
229
+ await sleep(250);
230
+ }
231
+
232
+ throw new Error("Timed out while stopping existing llama-server.");
233
+ }
234
+
235
+ async function managedServerPid(runtime: ModelRuntime): Promise<number | null> {
236
+ try {
237
+ const value = Number((await readFile(pidPath(runtime), "utf8")).trim());
238
+ return Number.isInteger(value) && value > 0 ? value : null;
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ function pidPath(runtime: ModelRuntime): string {
245
+ const filename =
246
+ runtime.profile === "scout"
247
+ ? "llama-server.pid"
248
+ : `llama-server-${runtime.profile}.pid`;
249
+ return path.join(cacheDir(), filename);
250
+ }
251
+
252
+ async function waitForServer(baseUrl: string, modelId: ModelId): Promise<void> {
253
+ const deadline = Date.now() + 120_000;
254
+ while (Date.now() < deadline) {
255
+ const runningModel = await runningServerModel(baseUrl);
256
+ if (runningModel === modelId) return;
257
+ await sleep(500);
258
+ }
259
+ throw new Error(`llama-server did not become ready for ${modelId}.`);
260
+ }
261
+
262
+ function sleep(ms: number): Promise<void> {
263
+ return new Promise((resolve) => setTimeout(resolve, ms));
264
+ }
265
+
266
+ async function downloadModel(
267
+ modelPath: string,
268
+ modelUrl: string,
269
+ ): Promise<void> {
270
+ const tempPath = `${modelPath}.download`;
271
+ await rm(tempPath, { force: true });
272
+
273
+ console.error("Downloading model...");
274
+ try {
275
+ const response = await fetch(modelUrl);
276
+ if (!response.ok || !response.body)
277
+ throw new Error(`Model download failed: HTTP ${response.status}`);
278
+
279
+ const total = Number(response.headers.get("content-length") ?? 0);
280
+ let received = 0;
281
+ let lastPrint = 0;
282
+ const reader = response.body.getReader();
283
+ const file = await open(tempPath, "wx");
284
+
285
+ try {
286
+ while (true) {
287
+ const { done, value } = await reader.read();
288
+ if (done) break;
289
+ received += value.byteLength;
290
+ await file.write(value);
291
+ const now = Date.now();
292
+ if (total > 0 && now - lastPrint > 500) {
293
+ lastPrint = now;
294
+ statusOutput.write(
295
+ `\r${formatBytes(received)} / ${formatBytes(total)}`,
296
+ );
297
+ }
298
+ }
299
+ } finally {
300
+ await file.close();
301
+ }
302
+
303
+ if (total > 0)
304
+ statusOutput.write(
305
+ `\r${formatBytes(received)} / ${formatBytes(total)}\n`,
306
+ );
307
+ await rename(tempPath, modelPath);
308
+ } catch (error) {
309
+ await rm(tempPath, { force: true });
310
+ throw error;
311
+ }
312
+ }
313
+
314
+ async function confirm(question: string): Promise<boolean> {
315
+ const rl = createInterface(terminalIo());
316
+ try {
317
+ const answer = (await rl.question(question)).trim().toLowerCase();
318
+ return answer === "y" || answer === "yes";
319
+ } finally {
320
+ rl.close();
321
+ }
322
+ }
323
+
324
+ function terminalIo(): {
325
+ input: NodeJS.ReadableStream;
326
+ output: NodeJS.WritableStream;
327
+ } {
328
+ if (input.isTTY) return { input, output };
329
+ if (platform() !== "win32")
330
+ return {
331
+ input: createReadStream("/dev/tty"),
332
+ output: createWriteStream("/dev/tty"),
333
+ };
334
+ throw new Error(
335
+ "No local Stupify model found. Run `stupify` once in an interactive terminal to set up the model.",
336
+ );
337
+ }
338
+
339
+ function cacheDir(): string {
340
+ if (process.env.STUPIFY_CACHE_DIR) return process.env.STUPIFY_CACHE_DIR;
341
+ if (process.env.XDG_CACHE_HOME)
342
+ return path.join(process.env.XDG_CACHE_HOME, "stupify");
343
+ if (platform() === "darwin")
344
+ return path.join(homedir(), "Library", "Caches", "stupify");
345
+ if (platform() === "win32" && process.env.LOCALAPPDATA)
346
+ return path.join(process.env.LOCALAPPDATA, "stupify", "Cache");
347
+ return path.join(homedir(), ".cache", "stupify");
348
+ }
349
+
350
+ async function exists(filePath: string): Promise<boolean> {
351
+ try {
352
+ return (await stat(filePath)).isFile();
353
+ } catch {
354
+ return false;
355
+ }
356
+ }
357
+
358
+ function formatBytes(bytes: number): string {
359
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
360
+ }