@stupify/cli 0.0.15 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.review/CORPUS.md +73 -0
  2. package/.review/REVIEW-PROMPT.md +52 -0
  3. package/.review/RUBRIC.md +46 -0
  4. package/LICENSE +1 -1
  5. package/README.md +41 -39
  6. package/package.json +24 -25
  7. package/src/cli.ts +358 -0
  8. package/src/review-sweep.ts +492 -0
  9. package/dist/analysis.d.ts +0 -16
  10. package/dist/analysis.js +0 -165
  11. package/dist/cache.d.ts +0 -2
  12. package/dist/cache.js +0 -57
  13. package/dist/checks.d.ts +0 -4
  14. package/dist/checks.js +0 -228
  15. package/dist/command.d.ts +0 -2
  16. package/dist/command.js +0 -147
  17. package/dist/constants.d.ts +0 -4
  18. package/dist/constants.js +0 -53
  19. package/dist/counter-scout.d.ts +0 -21
  20. package/dist/counter-scout.js +0 -167
  21. package/dist/diff.d.ts +0 -1
  22. package/dist/diff.js +0 -10
  23. package/dist/doctor.d.ts +0 -4
  24. package/dist/doctor.js +0 -131
  25. package/dist/git.d.ts +0 -12
  26. package/dist/git.js +0 -298
  27. package/dist/hooks.d.ts +0 -3
  28. package/dist/hooks.js +0 -117
  29. package/dist/index.d.ts +0 -1
  30. package/dist/index.js +0 -1
  31. package/dist/model.d.ts +0 -11
  32. package/dist/model.js +0 -296
  33. package/dist/prompts.d.ts +0 -8
  34. package/dist/prompts.js +0 -89
  35. package/dist/render.d.ts +0 -3
  36. package/dist/render.js +0 -151
  37. package/dist/repomix-provider.d.ts +0 -12
  38. package/dist/repomix-provider.js +0 -196
  39. package/dist/search-bench.d.ts +0 -1
  40. package/dist/search-bench.js +0 -677
  41. package/dist/search-profile.d.ts +0 -6
  42. package/dist/search-profile.js +0 -73
  43. package/dist/sem-provider.d.ts +0 -2
  44. package/dist/sem-provider.js +0 -252
  45. package/dist/stupify.d.ts +0 -38
  46. package/dist/stupify.js +0 -474
  47. package/dist/trace.d.ts +0 -31
  48. package/dist/trace.js +0 -86
  49. package/dist/types.d.ts +0 -328
  50. package/dist/types.js +0 -6
  51. package/dist/ui.d.ts +0 -34
  52. package/dist/ui.js +0 -143
  53. package/src/analysis.ts +0 -220
  54. package/src/cache.ts +0 -63
  55. package/src/checks.ts +0 -231
  56. package/src/command.ts +0 -173
  57. package/src/constants.ts +0 -56
  58. package/src/counter-scout.ts +0 -195
  59. package/src/diff.ts +0 -9
  60. package/src/doctor.ts +0 -140
  61. package/src/git.ts +0 -306
  62. package/src/hooks.ts +0 -134
  63. package/src/index.ts +0 -1
  64. package/src/model.ts +0 -367
  65. package/src/prompts.ts +0 -100
  66. package/src/render.ts +0 -154
  67. package/src/repomix-provider.ts +0 -219
  68. package/src/search-bench.ts +0 -783
  69. package/src/search-profile.ts +0 -89
  70. package/src/sem-provider.ts +0 -297
  71. package/src/stupify.ts +0 -571
  72. package/src/trace.ts +0 -126
  73. package/src/types.ts +0 -348
  74. package/src/ui.ts +0 -187
package/src/cli.ts ADDED
@@ -0,0 +1,358 @@
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, 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, note, outro, spinner, text } from '@clack/prompts'
16
+ import pc from 'picocolors'
17
+
18
+ const PKG_DIR = dirname(fileURLToPath(import.meta.url))
19
+ const HOME = process.env.STUPIFY_HOME ?? join(homedir(), '.stupify')
20
+ const STATE = join(HOME, 'state')
21
+ const REQUIRED = ['bun', 'gh', 'codex', 'git'] as const
22
+
23
+ function bail<T>(value: T | symbol): asserts value is T {
24
+ if (isCancel(value)) {
25
+ cancel('aborted.')
26
+ process.exit(0)
27
+ }
28
+ }
29
+
30
+ function which(bin: string): string | null {
31
+ return Bun.which(bin)
32
+ }
33
+
34
+ // A bun path the cron can rely on. Under `bunx`, the running bun lives in an EPHEMERAL /tmp/bun-node-… dir
35
+ // that's deleted after install — so never bake that into the crontab. Prefer a stable install location.
36
+ function stableBun(): string {
37
+ const running = which('bun')
38
+ if (running && !running.includes('/bun-node-') && !running.startsWith('/tmp/')) return running
39
+ for (const c of [join(homedir(), '.bun/bin/bun'), '/usr/local/bin/bun', '/usr/bin/bun']) {
40
+ if (existsSync(c)) return c
41
+ }
42
+ return running ?? 'bun'
43
+ }
44
+
45
+ function detectRepo(): string | null {
46
+ const r = spawnSync('git', ['config', '--get', 'remote.origin.url'], { encoding: 'utf8' })
47
+ if (r.status !== 0) return null
48
+ const slug = (r.stdout ?? '')
49
+ .trim()
50
+ .replace(/^[a-z]+:\/\/[^/]+\//, '')
51
+ .replace(/^git@[^:]+:/, '')
52
+ .replace(/\.git$/, '')
53
+ return /^[^/]+\/[^/]+$/.test(slug) ? slug : null
54
+ }
55
+
56
+ function installCron(opts: { ghHost: string }): string {
57
+ const bun = stableBun()
58
+ const prefix = opts.ghHost ? `GH_HOST=${opts.ghHost} ` : ''
59
+ // No flock — the sweep self-locks (state/sweep.lock), so overlapping cron ticks no-op on their own.
60
+ const line = `*/1 * * * * ${prefix}${bun} ${join(HOME, 'review-sweep.ts')} >> ${STATE}/cron.log 2>&1`
61
+ const current = spawnSync('crontab', ['-l'], { encoding: 'utf8' }).stdout ?? ''
62
+ const kept = current
63
+ .split('\n')
64
+ .filter((l) => l.trim() && !l.includes('review-sweep.ts'))
65
+ const next = [...kept, line].join('\n') + '\n'
66
+ const wrote = spawnSync('crontab', ['-'], { input: next })
67
+ if (wrote.status !== 0) throw new Error('could not write crontab')
68
+ return line
69
+ }
70
+
71
+ async function setup(argv: { repo?: string; host?: string; yes: boolean }): Promise<void> {
72
+ console.clear()
73
+ intro(pc.bgMagenta(pc.black(' stupify ')) + pc.dim(' — sounds dumb, reviews sharp'))
74
+
75
+ // 1. tools
76
+ const s = spinner()
77
+ s.start('checking your tools')
78
+ const missing = REQUIRED.filter((b) => !which(b))
79
+ if (missing.length) {
80
+ s.stop(pc.red(`missing: ${missing.join(', ')}`))
81
+ note(
82
+ `install them first:\n bun → ${pc.cyan('bun.sh')}\n gh → ${pc.cyan('cli.github.com')}\n codex → ${pc.cyan('github.com/openai/codex')}`,
83
+ 'missing tools',
84
+ )
85
+ process.exit(1)
86
+ }
87
+ s.stop(pc.green('bun, gh, codex, git') + pc.dim(' — all here'))
88
+
89
+ // 2. repo (auto-detect, else ask)
90
+ let repo = argv.repo ?? ''
91
+ if (!repo) {
92
+ const detected = detectRepo()
93
+ if (detected) {
94
+ if (argv.yes) {
95
+ repo = detected
96
+ log.success(`repo ${pc.bold(detected)} ${pc.dim('(from this checkout)')}`)
97
+ } else {
98
+ const keep = await confirm({ message: `Review ${pc.bold(detected)}? ${pc.dim('(detected from git remote)')}` })
99
+ bail(keep)
100
+ if (keep) repo = detected
101
+ }
102
+ }
103
+ }
104
+ if (!repo) {
105
+ const answer = await text({
106
+ message: 'GitHub repo to review',
107
+ placeholder: 'owner/repo',
108
+ validate: (v) => (/^[^/]+\/[^/]+$/.test((v ?? '').trim()) ? undefined : 'expected owner/repo'),
109
+ })
110
+ bail(answer)
111
+ repo = answer.trim()
112
+ }
113
+
114
+ // 3. integration host (exe.dev) — can't be detected
115
+ let host = argv.host ?? process.env.GH_HOST ?? ''
116
+ if (!host && !argv.yes) {
117
+ const answer = await text({
118
+ message: 'exe.dev integration host',
119
+ placeholder: 'your-integration.int.exe.xyz',
120
+ defaultValue: '',
121
+ })
122
+ bail(answer)
123
+ host = answer.trim()
124
+ }
125
+
126
+ // 4. plan + confirm
127
+ note(
128
+ [
129
+ `${pc.dim('repo ')} ${pc.bold(repo)}`,
130
+ host
131
+ ? `${pc.dim('auth ')} exe.dev integration ${pc.bold(host)} ${pc.dim('— exe-llm gateway, no keys')}`
132
+ : `${pc.dim('auth ')} your own gh + codex ${pc.dim('(run `gh auth login` first)')}`,
133
+ `${pc.dim('cadence')} every ~60s via cron`,
134
+ `${pc.dim('home ')} ${HOME}`,
135
+ ].join('\n'),
136
+ 'plan',
137
+ )
138
+ if (!argv.yes) {
139
+ const go = await confirm({ message: 'Set it up?' })
140
+ bail(go)
141
+ if (!go) {
142
+ cancel('aborted.')
143
+ process.exit(0)
144
+ }
145
+ }
146
+
147
+ // 5. install
148
+ const s2 = spinner()
149
+ s2.start('installing')
150
+ mkdirSync(STATE, { recursive: true })
151
+ copyFileSync(join(PKG_DIR, 'review-sweep.ts'), join(HOME, 'review-sweep.ts'))
152
+ const cfg = [`REPO_SLUG=${repo}`, host ? `GH_HOST=${host}` : '', '# tune anything else here — see the README']
153
+ .filter(Boolean)
154
+ .join('\n')
155
+ writeFileSync(join(HOME, 'config.env'), cfg + '\n')
156
+ installCron({ ghHost: host })
157
+ s2.stop(pc.green('installed') + pc.dim(` → ${HOME}`))
158
+
159
+ // 6. success + the two steps to a first review
160
+ note(
161
+ [
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)')}`,
165
+ ``,
166
+ `${pc.dim('→ a review lands within ~60s. preview anytime:')} ${pc.cyan(`DRY_RUN=1 bun ${join(HOME, 'review-sweep.ts')}`)}`,
167
+ ].join('\n'),
168
+ 'two steps to your first review',
169
+ )
170
+ outro(pc.green('stupify is watching ') + pc.bold(repo) + pc.green(' 👀'))
171
+ }
172
+
173
+ function run(dry: boolean): void {
174
+ const sweep = join(HOME, 'review-sweep.ts')
175
+ if (!Bun.file(sweep).size) {
176
+ log.error(`not set up yet — run ${pc.cyan('stupify')} first`)
177
+ process.exit(1)
178
+ }
179
+ const env = { ...process.env, ...(dry ? { DRY_RUN: '1' } : {}) }
180
+ const r = spawnSync('bun', [sweep], { stdio: 'inherit', env })
181
+ process.exit(r.status ?? 1)
182
+ }
183
+
184
+ // --- provision: spin up an exe.dev VM that runs stupify, from your laptop ---
185
+
186
+ function exe(args: string[], input = ''): { ok: boolean; out: string } {
187
+ const r = spawnSync('ssh', ['-o', 'ConnectTimeout=25', 'exe.dev', ...args], {
188
+ input,
189
+ encoding: 'utf8',
190
+ maxBuffer: 8 * 1024 * 1024,
191
+ })
192
+ return { ok: r.status === 0, out: (r.stdout ?? '') + (r.stderr ?? '') }
193
+ }
194
+
195
+ function githubIntegrationFor(repo: string): string | null {
196
+ const r = exe(['int', 'list', '--json'])
197
+ if (!r.ok) return null
198
+ try {
199
+ const list: { name: string; type: string; config?: { repositories?: string[] } }[] = JSON.parse(r.out)
200
+ return list.find((i) => i.type === 'github' && (i.config?.repositories ?? []).includes(repo))?.name ?? null
201
+ } catch {
202
+ return null
203
+ }
204
+ }
205
+
206
+ const vmNameFor = (repo: string): string => 'stupify-' + repo.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
207
+
208
+ async function provision(argv: { repo?: string; yes: boolean }): Promise<void> {
209
+ console.clear()
210
+ intro(pc.bgMagenta(pc.black(' stupify ')) + pc.dim(' — provision a reviewer on exe.dev'))
211
+
212
+ // 1. onboarded to exe.dev?
213
+ const s = spinner()
214
+ s.start('checking exe.dev')
215
+ const who = exe(['whoami'])
216
+ if (!who.ok) {
217
+ s.stop(pc.red('not connected to exe.dev'))
218
+ note(`onboarding is one step — run this once, then re-run stupify:\n\n ${pc.cyan('ssh exe.dev')}`, 'connect exe.dev')
219
+ process.exit(1)
220
+ }
221
+ s.stop(pc.green('exe.dev ready') + pc.dim(` — ${(who.out.match(/[\w.+-]+@[\w.-]+/) ?? [''])[0]}`))
222
+
223
+ // 2. repo (auto-detect, else ask)
224
+ let repo = argv.repo ?? ''
225
+ if (!repo) {
226
+ const detected = detectRepo()
227
+ if (detected) {
228
+ if (argv.yes) repo = detected
229
+ else {
230
+ const keep = await confirm({ message: `Review ${pc.bold(detected)}? ${pc.dim('(detected from git remote)')}` })
231
+ bail(keep)
232
+ if (keep) repo = detected
233
+ }
234
+ }
235
+ }
236
+ if (!repo) {
237
+ const answer = await text({
238
+ message: 'GitHub repo to review',
239
+ placeholder: 'owner/repo',
240
+ validate: (v) => (/^[^/]+\/[^/]+$/.test((v ?? '').trim()) ? undefined : 'expected owner/repo'),
241
+ })
242
+ bail(answer)
243
+ repo = answer.trim()
244
+ }
245
+
246
+ // 3. GitHub integration — reuse an existing one, else create it (needs your GitHub linked once, on the web)
247
+ const s2 = spinner()
248
+ s2.start('finding your GitHub integration')
249
+ let integration = githubIntegrationFor(repo)
250
+ if (integration) {
251
+ s2.stop(pc.green(`using integration ${pc.bold(integration)}`))
252
+ } else {
253
+ const name = vmNameFor(repo)
254
+ const add = exe(['integrations', 'add', 'github', '--name', name, '--repository', repo])
255
+ if (add.ok) {
256
+ integration = name
257
+ s2.stop(pc.green(`created integration ${pc.bold(name)}`))
258
+ } else {
259
+ s2.stop(pc.red(`no GitHub integration for ${repo}`))
260
+ 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')
261
+ process.exit(1)
262
+ }
263
+ }
264
+ const host = `${integration}.int.exe.xyz`
265
+
266
+ // 4. plan + confirm
267
+ note(
268
+ [
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')}`,
272
+ ].join('\n'),
273
+ 'plan',
274
+ )
275
+ if (!argv.yes) {
276
+ const go = await confirm({ message: 'Provision it?' })
277
+ bail(go)
278
+ if (!go) {
279
+ cancel('aborted.')
280
+ process.exit(0)
281
+ }
282
+ }
283
+
284
+ // 5. create the VM with a first-boot setup-script that installs stupify
285
+ const s3 = spinner()
286
+ s3.start('provisioning VM + installing stupify')
287
+ const vm = vmNameFor(repo)
288
+ const script = [
289
+ 'export PATH="$HOME/.bun/bin:/usr/local/bin:$PATH"',
290
+ 'command -v bun >/dev/null 2>&1 || curl -fsSL https://bun.sh/install | bash',
291
+ 'export PATH="$HOME/.bun/bin:$PATH"',
292
+ `exec bunx github:Octember/stupif.ai setup ${repo} --host ${host} --yes`,
293
+ ].join('\n')
294
+ const created = exe(['new', '--name', vm, '--integration', integration, '--json', '--setup-script', '/dev/stdin'], script)
295
+ if (!created.ok) {
296
+ s3.stop(pc.red('provision failed'))
297
+ log.error(created.out.trim().slice(0, 400))
298
+ process.exit(1)
299
+ }
300
+ let dest = `${vm}.exe.xyz`
301
+ try {
302
+ dest = (JSON.parse(created.out) as { ssh_dest?: string }).ssh_dest ?? dest
303
+ } catch {
304
+ /* keep the derived dest */
305
+ }
306
+ s3.stop(pc.green(`VM ${pc.bold(vm)} created`) + pc.dim(` (${dest})`))
307
+
308
+ // 6. success
309
+ note(
310
+ [
311
+ `${pc.bold(vm)} is booting and installing stupify ${pc.dim('(~15s)')}.`,
312
+ ``,
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)')}`,
315
+ ``,
316
+ `${pc.dim('watch:')} ${pc.cyan(`ssh ${dest} 'tail -f ~/.stupify/state/sweep.log'`)}`,
317
+ `${pc.dim('stop: ')} ${pc.cyan(`ssh exe.dev rm ${vm}`)}`,
318
+ ].join('\n'),
319
+ 'done',
320
+ )
321
+ outro(pc.green('stupify is provisioned for ') + pc.bold(repo) + pc.green(' 👀'))
322
+ }
323
+
324
+ function help(): void {
325
+ console.log(`${pc.bold('stupify')} — a code reviewer that talks like an idiot and catches real bugs
326
+
327
+ ${pc.dim('Usage')} ${pc.dim('(run from your laptop)')}
328
+ stupify provision an exe.dev VM that reviews your repo ${pc.dim('(the magic)')}
329
+ stupify <owner/repo> provision for a specific repo
330
+ stupify setup [repo] install on THIS machine instead of provisioning a VM
331
+ stupify run [--dry] run one review sweep now (where stupify is installed)
332
+ stupify --help
333
+
334
+ ${pc.dim('Flags')}
335
+ --host <h.int.exe.xyz> integration host (for 'setup')
336
+ --yes, -y accept detected defaults, no prompts (for CI / scripts)
337
+
338
+ ${pc.dim("Provisioning rides exe.dev — onboard once with 'ssh exe.dev', then one command does the rest.")} https://stupif.ai`)
339
+ }
340
+
341
+ // --- routing ---
342
+ const args = process.argv.slice(2)
343
+ 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')
347
+ const cmd = positional[0]
348
+
349
+ if (args.includes('-h') || args.includes('--help')) {
350
+ help()
351
+ } else if (cmd === 'run') {
352
+ run(args.includes('--dry'))
353
+ } else if (cmd === 'setup') {
354
+ await setup({ repo: positional[1], host, yes })
355
+ } else {
356
+ // default (and explicit `provision`): provision an exe.dev VM
357
+ await provision({ repo: cmd === 'provision' ? positional[1] : cmd, yes })
358
+ }