external-review 1.0.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.
@@ -0,0 +1,582 @@
1
+ #!/usr/bin/env node
2
+ // external-review — pick a second model, know what it costs you, know where
3
+ // your code goes, and run a review with it.
4
+ //
5
+ // No dependencies on purpose: this reads your API key and syncs your source, so
6
+ // the whole thing should be auditable in one sitting.
7
+
8
+ import { spawn, spawnSync } from 'node:child_process';
9
+ import {
10
+ existsSync, readFileSync, writeFileSync, readdirSync, statSync,
11
+ mkdirSync, copyFileSync,
12
+ } from 'node:fs';
13
+ import { homedir, tmpdir } from 'node:os';
14
+ import { join, dirname } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const OR = 'https://openrouter.ai/api/v1';
18
+
19
+ // ---------------------------------------------------------------- utilities
20
+
21
+ const C = process.stdout.isTTY
22
+ ? { dim: (s) => `\x1b[2m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m`,
23
+ g: (s) => `\x1b[32m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m`,
24
+ r: (s) => `\x1b[31m${s}\x1b[0m`, c: (s) => `\x1b[36m${s}\x1b[0m` }
25
+ : new Proxy({}, { get: () => (s) => s });
26
+
27
+ const die = (msg) => { console.error(`${C.r('error')} ${msg}`); process.exit(1); };
28
+ const info = (msg) => console.error(C.dim(msg));
29
+
30
+ /** Read the OpenRouter key from the env or from opencode's auth store. */
31
+ function apiKey({ required = true } = {}) {
32
+ if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY;
33
+ const authFile = join(homedir(), '.local/share/opencode/auth.json');
34
+ if (existsSync(authFile)) {
35
+ try {
36
+ const key = JSON.parse(readFileSync(authFile, 'utf8'))?.openrouter?.key;
37
+ if (key) return key;
38
+ } catch { /* fall through to the error below */ }
39
+ }
40
+ if (!required) return null;
41
+ die('no API key. Set OPENROUTER_API_KEY, or run `opencode auth login`.');
42
+ }
43
+
44
+ async function api(path, { key = apiKey(), ...init } = {}) {
45
+ const res = await fetch(`${OR}${path}`, {
46
+ ...init,
47
+ headers: { Authorization: `Bearer ${key}`, ...(init.headers || {}) },
48
+ });
49
+ if (!res.ok) die(`OpenRouter ${path} → HTTP ${res.status}`);
50
+ return res.json();
51
+ }
52
+
53
+ const num = (v) => (v == null ? null : Number(v));
54
+ const money = (v) => (v == null ? '—' : `$${Number(v).toFixed(4)}`);
55
+
56
+ /** Per-million-token price, which is how everyone actually compares models. */
57
+ function perM(pricing = {}) {
58
+ const p = num(pricing.prompt);
59
+ const c = num(pricing.completion);
60
+ if (p == null && c == null) return '—';
61
+ const fmt = (x) => (x == null ? '?' : x === 0 ? 'free' : `$${(x * 1e6).toFixed(2)}`);
62
+ return `${fmt(p)} in / ${fmt(c)} out`;
63
+ }
64
+
65
+ const isFree = (m) => num(m?.pricing?.prompt) === 0 && num(m?.pricing?.completion) === 0;
66
+
67
+ // ------------------------------------------------------------------ doctor
68
+
69
+ async function cmdDoctor() {
70
+ let bad = 0;
71
+ const check = (ok, label, detail) => {
72
+ console.log(`${ok ? C.g(' ok ') : C.y(' warn ')} ${label}${detail ? C.dim(` ${detail}`) : ''}`);
73
+ if (!ok) bad++;
74
+ };
75
+
76
+ console.log(C.b('\nexternal-review doctor\n'));
77
+
78
+ const key = apiKey({ required: false });
79
+ check(!!key, 'API key found',
80
+ key ? `${key.slice(0, 8)}…${key.slice(-4)}` : 'set OPENROUTER_API_KEY or run `opencode auth login`');
81
+
82
+ const runner = findRunner();
83
+ check(!!runner, 'a runner is installed', runner || 'install one: npm i -g opencode-ai');
84
+
85
+ for (const tool of ['rsync', 'ssh']) {
86
+ const ok = spawnSync('which', [tool]).status === 0;
87
+ check(ok, `${tool} available`, ok ? '' : 'needed only for the remote-machine workflow');
88
+ }
89
+
90
+ if (key) {
91
+ const { data } = await api('/key', { key });
92
+ const tier = data.is_free_tier ? 'free tier' : 'paid';
93
+ check(true, `key reachable (${tier})`, `spent today: ${money(data.usage_daily)}`);
94
+ if (data.is_free_tier) {
95
+ console.log(C.dim(
96
+ '\n Free tier: OpenRouter caps free-model requests per day (50/day, or\n' +
97
+ ' 1000/day once the account has ever purchased 10 credits). A long review\n' +
98
+ ' is many requests. `external-review quota` shows where you stand.'));
99
+ }
100
+ }
101
+
102
+ console.log(bad ? C.y('\nSome checks want attention.\n') : C.g('\nReady.\n'));
103
+ }
104
+
105
+ function findRunner() {
106
+ for (const c of [join(homedir(), '.opencode/bin/opencode'), 'opencode']) {
107
+ if (c.startsWith('/') ? existsSync(c) : spawnSync('which', [c]).status === 0) return c;
108
+ }
109
+ return null;
110
+ }
111
+
112
+ // ------------------------------------------------------------------- quota
113
+
114
+ async function cmdQuota() {
115
+ const { data } = await api('/key');
116
+ console.log(C.b('\nAccount\n'));
117
+ const row = (k, v) => console.log(` ${k.padEnd(18)} ${v}`);
118
+ row('tier', data.is_free_tier ? C.y('free') : C.g('paid'));
119
+ row('spent today', money(data.usage_daily));
120
+ row('spent this week', money(data.usage_weekly));
121
+ row('spent this month', money(data.usage_monthly));
122
+ row('spent all time', money(data.usage));
123
+
124
+ if (data.limit != null) {
125
+ const pct = Math.round((num(data.limit_remaining) / num(data.limit)) * 100);
126
+ const bar = '█'.repeat(Math.max(0, Math.round(pct / 5))).padEnd(20, '░');
127
+ row('credit limit', money(data.limit));
128
+ row('remaining', `${money(data.limit_remaining)} ${pct > 25 ? C.g(bar) : C.y(bar)} ${pct}%`);
129
+ if (data.limit_reset) row('resets', data.limit_reset);
130
+ } else {
131
+ row('credit limit', C.dim('none set'));
132
+ }
133
+
134
+ if (data.is_free_tier) {
135
+ console.log(C.y('\n Free-model request cap'));
136
+ console.log(C.dim(
137
+ ' :free models 50 requests/day, and 20/minute.\n' +
138
+ ' 1000/day once the account has EVER purchased 10\n' +
139
+ ' credits — permanent, not a subscription.\n' +
140
+ ' resets on the UTC day.\n\n' +
141
+ ' The daily counter is ACCOUNT-WIDE across every :free model, so switching\n' +
142
+ ' free models does not get you a fresh budget. It is not exposed by the\n' +
143
+ ' API, which is why it is not shown above.\n\n' +
144
+ ' STEALTH MODELS DRAW ON A SEPARATE POOL. An anonymous/cloaked preview\n' +
145
+ ' model is not a :free model and has its own, much larger allowance —\n' +
146
+ ' which is why a day can run far past 50 requests and then stop abruptly\n' +
147
+ ' when you switch to a genuine :free model. The two errors differ:\n' +
148
+ ' "Rate limit exceeded: free-models-per-day-stealth" → stealth pool\n' +
149
+ ' "Rate limit exceeded: free-models-per-day" → the 50/day pool\n\n' +
150
+ ' A whole-subsystem review is 40-150 requests. On the free tier that is\n' +
151
+ ' ONE pass, maybe two. Add credits, or use stealth models, or expect to\n' +
152
+ ' plan a day at a time.'));
153
+ }
154
+ console.log();
155
+ }
156
+
157
+ // ------------------------------------------------------------------ models
158
+
159
+ async function cmdModels(args) {
160
+ const wantFree = args.includes('--free');
161
+ const wantAll = args.includes('--all');
162
+ const limit = Number(argValue(args, '--limit') ?? (wantAll ? 1e9 : 25));
163
+ const minCtx = Number(argValue(args, '--min-context') ?? 60000);
164
+
165
+ const { data } = await api('/models');
166
+ let models = data.filter((m) => (num(m.context_length) ?? 0) >= minCtx);
167
+ if (wantFree) models = models.filter(isFree);
168
+
169
+ // A code review means reading a lot and writing a little, so rank by context
170
+ // first — a model that cannot hold the subsystem cannot review it.
171
+ models.sort((a, b) => (num(b.context_length) ?? 0) - (num(a.context_length) ?? 0));
172
+
173
+ console.log(C.b(`\n${models.length} model(s) with ≥${(minCtx / 1000) | 0}k context${wantFree ? ', free only' : ''}\n`));
174
+ console.log(C.dim(' context price / 1M tokens id'));
175
+ for (const m of models.slice(0, limit)) {
176
+ const ctx = `${Math.round((num(m.context_length) ?? 0) / 1000)}k`.padStart(7);
177
+ const price = perM(m.pricing).padEnd(26);
178
+ console.log(` ${ctx} ${isFree(m) ? C.g(price) : price} ${m.id}`);
179
+ }
180
+ if (models.length > limit) console.log(C.dim(`\n …${models.length - limit} more. --all to list them, --limit N to change.`));
181
+ console.log(C.dim('\n Next: `external-review providers <id>` to see who serves it and where.\n'));
182
+ }
183
+
184
+ function argValue(args, flag) {
185
+ const i = args.indexOf(flag);
186
+ return i >= 0 ? args[i + 1] : undefined;
187
+ }
188
+
189
+ // --------------------------------------------------------------- providers
190
+
191
+ async function cmdProviders(args) {
192
+ const model = args.find((a) => !a.startsWith('-'));
193
+ if (!model) die('usage: external-review providers <model-id>');
194
+
195
+ const [{ data: detail }, { data: providers }] = await Promise.all([
196
+ api(`/models/${model}/endpoints`),
197
+ api('/providers'),
198
+ ]);
199
+ const byName = new Map(providers.map((p) => [p.name, p]));
200
+
201
+ console.log(C.b(`\n${detail.name || model}\n`));
202
+
203
+ const endpoints = detail.endpoints || [];
204
+
205
+ // NO ENDPOINTS is an answer, not an error - and the most important one this
206
+ // command can give. Stealth and cloaked models publish no provider at all, so
207
+ // there is nothing to look up: you cannot learn who runs the machine your
208
+ // source is about to be sent to.
209
+ if (endpoints.length === 0) {
210
+ console.log(C.y(' This model does not disclose its providers.\n'));
211
+ console.log(C.dim(
212
+ ' OpenRouter lists no endpoints for it, which is characteristic of a\n' +
213
+ ' STEALTH or CLOAKED model: an unreleased model shipped under an\n' +
214
+ ' anonymous name to gather real-world usage before launch.\n\n' +
215
+ ' So the questions this command exists to answer — who operates it, from\n' +
216
+ ' where, under which policy — have no available answer. What IS known is\n' +
217
+ ' the arrangement: these models are offered free because prompts and\n' +
218
+ ' completions are logged and used to improve them. That is their purpose,\n' +
219
+ ' not a side effect.\n\n' +
220
+ ' They are genuinely good at review work and frequently frontier-class.\n' +
221
+ ' Use one with code you would not mind training a model. For anything\n' +
222
+ ' else, pick a model that names its providers.\n'));
223
+ return;
224
+ }
225
+
226
+ console.log(C.dim(' Your prompt is sent to ONE of these, chosen per request.\n'));
227
+
228
+ for (const ep of endpoints) {
229
+ const p = byName.get(ep.provider_name) || {};
230
+ const hq = p.headquarters || '?';
231
+ const dcs = Array.isArray(p.datacenters) && p.datacenters.length
232
+ ? p.datacenters.join(', ')
233
+ : C.dim('not published');
234
+ console.log(` ${C.c(ep.provider_name)}`);
235
+ console.log(` headquarters ${hq}`);
236
+ console.log(` datacenters ${dcs}`);
237
+ if (p.privacy_policy_url) console.log(` privacy ${C.dim(p.privacy_policy_url)}`);
238
+ if (p.terms_of_service_url) console.log(` terms ${C.dim(p.terms_of_service_url)}`);
239
+ console.log();
240
+ }
241
+
242
+ console.log(C.dim(
243
+ ' These are facts from OpenRouter, not a judgement. Where a provider is\n' +
244
+ ' based and where it runs its hardware may or may not matter for your code —\n' +
245
+ ' that depends on your obligations, not on ours. Read the linked policy.\n\n' +
246
+ ' To pin a single provider, use OpenRouter\'s `provider.order` routing, or\n' +
247
+ ' choose a model with only one endpoint.\n'));
248
+
249
+ // Detected from the endpoints' own PRICING, not from the id string: the
250
+ // canonical slug this command takes has no `:free` suffix, so matching on the
251
+ // name missed exactly the models the warning is for.
252
+ const anyFree = endpoints.some((e) => isFree(e));
253
+ if (anyFree) {
254
+ console.log(C.y(' Before you send source to this one'));
255
+ console.log(C.dim(
256
+ ' Free and stealth endpoints are free because of what they may do with\n' +
257
+ ' your data. OpenRouter will not route to them at all unless you have\n' +
258
+ ' enabled, in Settings → Privacy:\n\n' +
259
+ ' "Enable free endpoints that may train on inputs"\n' +
260
+ ' "Enable free endpoints that may publish prompts"\n\n' +
261
+ ' If free models work for you, those are ON, and the code you send may be\n' +
262
+ ' trained on and published. A stealth model is an unreleased model shipped\n' +
263
+ ' anonymously to gather real usage — logging your prompts IS its purpose.\n\n' +
264
+ ' If that is not acceptable for this code, use Zero Data Retention: a\n' +
265
+ ' privacy-settings toggle, a per-key guardrail, or `"zdr": true` per\n' +
266
+ ' request. It blocks storage and training, and it removes most free\n' +
267
+ ' endpoints — which is the trade being made either way.\n'));
268
+ }
269
+ }
270
+
271
+
272
+ // -------------------------------------------------------------------- scan
273
+
274
+ /* Content-based secret detection.
275
+ *
276
+ * The exclusion list catches a credential that lives in a FILE NAMED like a
277
+ * credential. It does nothing about the far commoner case: a live key pasted
278
+ * into an ordinary source file. `config.js` matches no pattern, and ships.
279
+ *
280
+ * Patterns are deliberately high-signal. A scanner that cries wolf gets
281
+ * `--force`d past on the second run and then protects nobody, so anything
282
+ * heuristic enough to fire on real code is left out. This finds keys with
283
+ * distinctive prefixes and real private-key blocks; it will NOT find every
284
+ * secret, and the report says so rather than implying a clean bill of health.
285
+ */
286
+ /* A private key SPANS LINES, so the per-line scan below cannot see it: the
287
+ * header is on one line and the base64 on the next. It also needs that base64
288
+ * to be present at all - a lone header is almost always a PARSER stripping it,
289
+ * which is what a real repo's only false positive of this class turned out to
290
+ * be. So it gets its own whole-text pattern, matched separately.
291
+ */
292
+ const PRIVATE_KEY_BLOCK =
293
+ /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----[\r\n\\]+\s*[A-Za-z0-9+/=]{20,}/;
294
+
295
+ const SECRET_PATTERNS = [
296
+ ['AWS access key id', /\bAKIA[0-9A-Z]{16}\b/],
297
+ ['GitHub token', /\bgh[pousr]_[A-Za-z0-9]{36,}\b/],
298
+ ['Slack token', /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/],
299
+ ['Stripe live key', /\bsk_live_[A-Za-z0-9]{20,}\b/],
300
+ ['OpenAI-style key', /\bsk-[A-Za-z0-9]{20,}\b/],
301
+ ['OpenRouter key', /\bsk-or-v1-[A-Za-z0-9]{20,}\b/],
302
+ ['Google API key', /\bAIza[0-9A-Za-z_-]{35}\b/],
303
+ ['Anthropic key', /\bsk-ant-[A-Za-z0-9_-]{20,}\b/],
304
+ ['JWT', /\bey[A-Za-z0-9_-]{10,}\.ey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/],
305
+ ['connection string with password', /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:[^\s:@/]+@[^\s/]+/],
306
+ ];
307
+
308
+ const SCAN_SKIP_DIRS = new Set([
309
+ '.git', 'node_modules', 'build', 'dist', 'target', '.venv', 'venv',
310
+ '__pycache__', '.dart_tool', '.gradle', 'Pods', 'vendor', '.next',
311
+ ]);
312
+
313
+ const SCAN_SKIP_EXT = new Set([
314
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.svg', '.pdf', '.zip',
315
+ '.gz', '.tar', '.mp4', '.mov', '.mp3', '.woff', '.woff2', '.ttf', '.otf',
316
+ '.jks', '.keystore', '.p12', '.der', '.apk', '.aab', '.so', '.dylib',
317
+ ]);
318
+
319
+ /* Files whose "secrets" are public by design.
320
+ *
321
+ * Firebase client config (API key, app id, sender id) is an IDENTIFIER, not a
322
+ * credential: Google documents it as safe to embed, and access is gated by app
323
+ * signature / bundle id and security rules, not by the key. Every mobile repo
324
+ * has these committed, so flagging them trains people to ignore the scanner -
325
+ * which costs more than the warning is worth.
326
+ */
327
+ const PUBLIC_BY_DESIGN = [
328
+ /(^|\/)google-services\.json$/,
329
+ /(^|\/)GoogleService-Info\.plist$/,
330
+ /(^|\/)firebase_options\.dart$/,
331
+ /(^|\/)firebase-config\.(js|ts|json)$/,
332
+ ];
333
+
334
+ function scanTree(root) {
335
+ const hits = [];
336
+ const walk = (dir) => {
337
+ let entries;
338
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
339
+ for (const e of entries) {
340
+ const full = join(dir, e.name);
341
+ if (e.isDirectory()) {
342
+ if (!SCAN_SKIP_DIRS.has(e.name)) walk(full);
343
+ continue;
344
+ }
345
+ if (!e.isFile()) continue;
346
+ const dot = e.name.lastIndexOf('.');
347
+ if (dot > 0 && SCAN_SKIP_EXT.has(e.name.slice(dot).toLowerCase())) continue;
348
+ const rel = full.slice(root.length + 1);
349
+ if (PUBLIC_BY_DESIGN.some((re) => re.test(rel))) continue;
350
+ let text;
351
+ try {
352
+ if (statSync(full).size > 2_000_000) continue; // not source
353
+ text = readFileSync(full, 'utf8');
354
+ } catch { continue; }
355
+ if (text.includes('\u0000')) continue; // binary
356
+
357
+ const pem = PRIVATE_KEY_BLOCK.exec(text);
358
+ if (pem) {
359
+ hits.push({
360
+ file: rel,
361
+ line: text.slice(0, pem.index).split('\n').length,
362
+ label: 'private key block',
363
+ });
364
+ }
365
+
366
+ const lines = text.split('\n');
367
+ for (let i = 0; i < lines.length; i++) {
368
+ for (const [label, re] of SECRET_PATTERNS) {
369
+ if (re.test(lines[i])) {
370
+ hits.push({ file: rel, line: i + 1, label });
371
+ break;
372
+ }
373
+ }
374
+ }
375
+ }
376
+ };
377
+ walk(root);
378
+ return hits;
379
+ }
380
+
381
+ function cmdScan(args) {
382
+ const root = argValue(args, '--in') ?? process.cwd();
383
+ const hits = scanTree(root);
384
+
385
+ if (hits.length === 0) {
386
+ console.log(C.g(`\n no high-signal secrets found in ${root}`));
387
+ console.log(C.dim(
388
+ '\n This is NOT a clean bill of health. It looks for credentials with\n' +
389
+ ' distinctive shapes - key prefixes, private-key blocks, passworded\n' +
390
+ ' connection strings. A bare 32-character token in a config file looks\n' +
391
+ ' exactly like any other string and cannot be found this way.\n' +
392
+ ' Read your own diff before sending it somewhere.\n'));
393
+ return hits;
394
+ }
395
+
396
+ console.log(C.r(`\n ${hits.length} possible secret(s) in ${root}\n`));
397
+ for (const h of hits) {
398
+ console.log(` ${C.y(h.label.padEnd(28))} ${h.file}:${h.line}`);
399
+ }
400
+ console.log(C.dim(
401
+ '\n Filename exclusions would NOT stop these - they are inside ordinary\n' +
402
+ ' source files. Before sending this tree to a model:\n' +
403
+ ' - move the value to an environment variable, or\n' +
404
+ ' - add the file with --exclude, or\n' +
405
+ ' - confirm it is a placeholder / already-public identifier.\n' +
406
+ ' If a live credential has already been sent, rotate it. A provider\n' +
407
+ ' retention policy is not a recall.\n'));
408
+ return hits;
409
+ }
410
+
411
+ // -------------------------------------------------------------------- sync
412
+
413
+ const DEFAULT_EXCLUDES = [
414
+ '.git/', 'node_modules/', 'build/', 'dist/', 'target/', '.venv/', 'venv/',
415
+ '__pycache__/', '.dart_tool/', '.gradle/', 'Pods/',
416
+ // Anything that is or holds a credential. Extend with --exclude.
417
+ '.env', '.env.*', '*.pem', '*.der', '*.key', '*.jks', '*.keystore', '*.p12',
418
+ '*.mobileprovision', 'id_rsa*', '*.crt',
419
+ 'secrets.*', '*.secrets.*', 'credentials.*', 'service-account*.json',
420
+ ];
421
+
422
+ /** Paths that must NOT exist in the synced copy, verified after the sync. */
423
+ function verifyList(extra) {
424
+ return ['.env', '.git', ...extra];
425
+ }
426
+
427
+ function cmdSync(args) {
428
+ const dest = argValue(args, '--to');
429
+ const src = argValue(args, '--from') ?? process.cwd();
430
+ const extra = args.filter((a, i) => args[i - 1] === '--exclude');
431
+ if (!dest) die('usage: external-review sync --to user@host:~/review-dir [--from .] [--exclude PATH]');
432
+
433
+ // SCAN BEFORE SENDING, and refuse by default. This is the whole reason the
434
+ // command exists rather than telling people to run rsync themselves: the
435
+ // moment a review copy leaves the machine is the last moment anything can be
436
+ // done about a key inside it.
437
+ if (!args.includes('--skip-scan')) {
438
+ const hits = cmdScan(['--in', src]);
439
+ if (hits.length && !args.includes('--force')) {
440
+ die('refusing to sync. Resolve the findings above, or pass --force if ' +
441
+ 'every one is a placeholder or an already-public identifier.');
442
+ }
443
+ if (hits.length) info('--force given; syncing anyway');
444
+ }
445
+
446
+ const excludes = [...DEFAULT_EXCLUDES, ...extra].flatMap((e) => ['--exclude', e]);
447
+ info(`syncing ${src} → ${dest}`);
448
+ const r = spawnSync('rsync', ['-az', '--delete', ...excludes, `${src}/`, dest], { stdio: 'inherit' });
449
+ if (r.status !== 0) die('rsync failed');
450
+
451
+ // VERIFY, do not assume. An exclusion that silently did not match is the
452
+ // whole risk this command exists to manage.
453
+ const [userHost, remoteDir] = splitDest(dest);
454
+ if (userHost) {
455
+ const checks = verifyList(extra)
456
+ .map((f) => `if [ -e "${f}" ]; then echo "LEAKED: ${f}"; fi`)
457
+ .join('; ');
458
+ const out = spawnSync('ssh', [userHost, `cd ${remoteDir} && { ${checks}; } ; echo VERIFY_DONE`], { encoding: 'utf8' });
459
+ const leaked = (out.stdout || '').split('\n').filter((l) => l.startsWith('LEAKED:'));
460
+ if (leaked.length) {
461
+ console.error(C.r('\nSecrets reached the review copy:'));
462
+ leaked.forEach((l) => console.error(` ${l}`));
463
+ die('delete the remote copy and re-sync with the right --exclude flags');
464
+ }
465
+ console.log(C.g('\n verified: no excluded path is present in the copy\n'));
466
+ }
467
+ }
468
+
469
+ function splitDest(dest) {
470
+ const m = /^([^:]+):(.+)$/.exec(dest);
471
+ return m ? [m[1], m[2]] : [null, dest];
472
+ }
473
+
474
+ // --------------------------------------------------------------------- run
475
+
476
+ function cmdRun(args) {
477
+ const promptFile = argValue(args, '--prompt');
478
+ const model = argValue(args, '--model');
479
+ const cwd = argValue(args, '--in') ?? process.cwd();
480
+ const out = argValue(args, '--out') ?? join(tmpdir(), `review-${Date.now()}.md`);
481
+ if (!promptFile || !model) {
482
+ die('usage: external-review run --prompt FILE --model ID [--in DIR] [--out FILE]');
483
+ }
484
+ if (!existsSync(promptFile)) die(`no such prompt file: ${promptFile}`);
485
+
486
+ const runner = findRunner();
487
+ if (!runner) die('no runner found. Install one: npm i -g opencode-ai');
488
+
489
+ const prompt = readFileSync(promptFile, 'utf8');
490
+ info(`model ${model}`);
491
+ info(`scope ${cwd}`);
492
+ info(`output ${out}`);
493
+
494
+ const child = spawn(runner, ['run', '-m', model, prompt], {
495
+ cwd, stdio: ['ignore', 'pipe', 'inherit'],
496
+ });
497
+ let buf = '';
498
+ child.stdout.on('data', (d) => { buf += d; process.stdout.write(d); });
499
+ child.on('close', (code) => {
500
+ writeFileSync(out, buf);
501
+ console.error(code === 0 ? C.g(`\nwrote ${out}`) : C.y(`\nrunner exited ${code}; partial output in ${out}`));
502
+ process.exit(code ?? 0);
503
+ });
504
+ }
505
+
506
+
507
+ // ----------------------------------------------------------- install-skill
508
+
509
+ /* Copy the skill to where the assistant looks for it.
510
+ *
511
+ * A command rather than a documented `cp`, because the path depends on how the
512
+ * package was installed: a GLOBAL install puts it under the npm root, a local
513
+ * one under ./node_modules, and running from a clone puts it next to this file.
514
+ * The README shipped the local path beside the global install instruction,
515
+ * which is a paper cut on the very first thing a new user does.
516
+ */
517
+ function cmdInstallSkill(args) {
518
+ const global = args.includes('--global');
519
+ const dest = join(global ? homedir() : process.cwd(),
520
+ '.claude', 'skills', 'external-review');
521
+
522
+ // Resolve relative to THIS file, so it works from a global install, a local
523
+ // one, or a git clone without knowing which.
524
+ const src = join(dirname(fileURLToPath(import.meta.url)),
525
+ '..', 'skills', 'external-review');
526
+ if (!existsSync(join(src, 'SKILL.md'))) {
527
+ die(`cannot find the skill next to the CLI (looked in ${src})`);
528
+ }
529
+
530
+ if (existsSync(join(dest, 'SKILL.md')) && !args.includes('--force')) {
531
+ die(`${dest} already exists. Pass --force to overwrite it.`);
532
+ }
533
+
534
+ mkdirSync(dest, { recursive: true });
535
+ copyFileSync(join(src, 'SKILL.md'), join(dest, 'SKILL.md'));
536
+
537
+ console.log(C.g(`\n installed → ${dest}`));
538
+ console.log(C.dim(
539
+ `\n ${global ? 'Available in every project.' : 'Available in this project.'}` +
540
+ `${global ? '' : ' Use --global for every project.'}\n` +
541
+ ' Now ask your assistant to "review this with a second model".\n'));
542
+ }
543
+
544
+ // -------------------------------------------------------------------- help
545
+
546
+ const HELP = `
547
+ ${C.b('external-review')} — run a code review with a second, independent model.
548
+
549
+ ${C.b('Commands')}
550
+ install-skill [--global] put the skill where your assistant will find it
551
+ doctor check your setup and say what is missing
552
+ quota spend so far, credit limit, and the free-tier cap
553
+ models [--free] [--all] candidate models, ranked by context window
554
+ [--min-context N] [--limit N]
555
+ providers <model-id> who actually serves that model, and from where
556
+ scan [--in DIR] find credentials INSIDE source files, which no
557
+ filename exclusion can catch
558
+ sync --to HOST:DIR copy your source to a review machine, secrets
559
+ [--from DIR] [--exclude PATH] excluded — then VERIFY they are absent
560
+ run --prompt FILE --model ID [--in DIR] [--out FILE]
561
+
562
+ ${C.b('Typical first run')}
563
+ external-review doctor
564
+ external-review models --free
565
+ external-review providers <the-one-you-liked>
566
+ external-review run --prompt ./review.txt --model <id> --out findings.md
567
+
568
+ ${C.dim('Docs, and the review prompts that actually found bugs:')}
569
+ ${C.dim('https://github.com/yevgavrikov/claude-external-review')}
570
+ `;
571
+
572
+ // -------------------------------------------------------------------- main
573
+
574
+ const [cmd, ...rest] = process.argv.slice(2);
575
+ const run = {
576
+ doctor: cmdDoctor, quota: cmdQuota, models: cmdModels,
577
+ providers: cmdProviders, scan: cmdScan, sync: cmdSync, run: cmdRun,
578
+ 'install-skill': cmdInstallSkill,
579
+ }[cmd];
580
+
581
+ if (!run) { console.log(HELP); process.exit(cmd ? 1 : 0); }
582
+ await run(rest);