smoothie-code 1.1.0 → 2.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.
package/dist/blend-cli.js CHANGED
@@ -9,15 +9,21 @@
9
9
  * Queries Codex + OpenRouter models in parallel, prints JSON results to stdout.
10
10
  * Progress goes to stderr so it doesn't interfere with hook JSON output.
11
11
  */
12
- import { readFileSync } from 'fs';
12
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
13
13
  import { fileURLToPath } from 'url';
14
14
  import { dirname, join } from 'path';
15
+ import { homedir } from 'os';
15
16
  import { execFile as execFileCb } from 'child_process';
16
17
  import { promisify } from 'util';
17
18
  import { createInterface } from 'readline';
18
19
  const execFile = promisify(execFileCb);
19
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
21
  const PROJECT_ROOT = join(__dirname, '..');
22
+ const SMOOTHIE_HOME = join(homedir(), '.smoothie');
23
+ try {
24
+ mkdirSync(SMOOTHIE_HOME, { recursive: true });
25
+ }
26
+ catch { }
21
27
  // ---------------------------------------------------------------------------
22
28
  // .env loader
23
29
  // ---------------------------------------------------------------------------
@@ -53,7 +59,12 @@ async function queryCodex(prompt) {
53
59
  catch {
54
60
  response = '';
55
61
  }
56
- return { model: 'Codex', response: response || '(empty response)' };
62
+ const estimatedTokens = Math.ceil(prompt.length / 3) + Math.ceil(response.length / 3);
63
+ return {
64
+ model: 'Codex',
65
+ response: response || '(empty response)',
66
+ tokens: { prompt: Math.ceil(prompt.length / 3), completion: Math.ceil(response.length / 3), total: estimatedTokens },
67
+ };
57
68
  }
58
69
  catch (err) {
59
70
  const message = err instanceof Error ? err.message : String(err);
@@ -68,7 +79,7 @@ async function queryOpenRouter(prompt, modelId, modelLabel) {
68
79
  method: 'POST',
69
80
  headers: {
70
81
  'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
71
- 'HTTP-Referer': 'https://hotairbag.github.io/smoothie',
82
+ 'HTTP-Referer': 'https://smoothiecode.com',
72
83
  'X-Title': 'Smoothie',
73
84
  'Content-Type': 'application/json',
74
85
  },
@@ -81,7 +92,12 @@ async function queryOpenRouter(prompt, modelId, modelLabel) {
81
92
  clearTimeout(timer);
82
93
  const data = (await res.json());
83
94
  const text = data.choices?.[0]?.message?.content ?? 'No response content';
84
- return { model: modelLabel, response: text };
95
+ const usage = data.usage;
96
+ return {
97
+ model: modelLabel,
98
+ response: text,
99
+ tokens: usage ? { prompt: usage.prompt_tokens || 0, completion: usage.completion_tokens || 0, total: usage.total_tokens || 0 } : undefined,
100
+ };
85
101
  }
86
102
  catch (err) {
87
103
  const message = err instanceof Error ? err.message : String(err);
@@ -166,20 +182,63 @@ async function main() {
166
182
  startTimes[label] = Date.now();
167
183
  return fn()
168
184
  .then((result) => {
169
- const elapsed = ((Date.now() - startTimes[label]) / 1000).toFixed(1);
170
- process.stderr.write(` ✓ ${label.padEnd(26)} done (${elapsed}s)\n`);
171
- return result;
185
+ const elapsed = ((Date.now() - startTimes[label]) / 1000);
186
+ process.stderr.write(` ✓ ${label.padEnd(26)} done (${elapsed.toFixed(1)}s)\n`);
187
+ return { ...result, elapsed_s: parseFloat(elapsed.toFixed(1)) };
172
188
  })
173
189
  .catch((err) => {
174
- const elapsed = ((Date.now() - startTimes[label]) / 1000).toFixed(1);
190
+ const elapsed = ((Date.now() - startTimes[label]) / 1000);
175
191
  const message = err instanceof Error ? err.message : String(err);
176
- process.stderr.write(` ✗ ${label.padEnd(26)} failed (${elapsed}s)\n`);
177
- return { model: label, response: `Error: ${message}` };
192
+ process.stderr.write(` ✗ ${label.padEnd(26)} failed (${elapsed.toFixed(1)}s)\n`);
193
+ return { model: label, response: `Error: ${message}`, elapsed_s: parseFloat(elapsed.toFixed(1)) };
178
194
  });
179
195
  });
180
196
  const results = await Promise.all(promises);
181
197
  process.stderr.write('\n ◆ All done.\n\n');
182
198
  // Output JSON to stdout (for hook consumption)
183
199
  process.stdout.write(JSON.stringify({ results }, null, 2));
200
+ // Save for share command + append to history
201
+ try {
202
+ const { appendFileSync } = await import('fs');
203
+ writeFileSync(join(SMOOTHIE_HOME, '.last-blend.json'), JSON.stringify({ results }, null, 2));
204
+ const entry = {
205
+ ts: new Date().toISOString(),
206
+ type: deep ? 'deep' : 'blend',
207
+ models: results.map(r => ({ model: r.model, elapsed_s: r.elapsed_s, tokens: r.tokens, error: r.response.startsWith('Error:') })),
208
+ };
209
+ appendFileSync(join(SMOOTHIE_HOME, '.smoothie-history.jsonl'), JSON.stringify(entry) + '\n');
210
+ }
211
+ catch { }
212
+ // Submit to leaderboard if opted in
213
+ try {
214
+ const cfg = JSON.parse(readFileSync(join(PROJECT_ROOT, 'config.json'), 'utf8'));
215
+ if (cfg.leaderboard && cfg.github) {
216
+ const now = new Date();
217
+ const jan1 = new Date(now.getFullYear(), 0, 1);
218
+ const days = Math.floor((now.getTime() - jan1.getTime()) / 86400000);
219
+ const weekNum = Math.ceil((days + jan1.getDay() + 1) / 7);
220
+ const week = `${now.getFullYear()}-W${String(weekNum).padStart(2, '0')}`;
221
+ const totalTokens = results.reduce((s, r) => s + (r.tokens?.total || 0), 0);
222
+ const blendId = `${cfg.github}-${Date.now()}`;
223
+ await fetch('https://api.smoothiecode.com/api/submit', {
224
+ method: 'POST',
225
+ headers: { 'Content-Type': 'application/json' },
226
+ body: JSON.stringify({
227
+ github: cfg.github,
228
+ blend_id: blendId,
229
+ tokens: totalTokens,
230
+ blends: 1,
231
+ models: results.map(r => ({ model: r.model, tokens: r.tokens?.total || 0 })),
232
+ week,
233
+ }),
234
+ signal: AbortSignal.timeout(5000),
235
+ }).catch(() => { });
236
+ }
237
+ }
238
+ catch { }
239
+ const totalTime = Math.max(...results.map(r => r.elapsed_s || 0));
240
+ const totalTokens = results.reduce((sum, r) => sum + (r.tokens?.total || 0), 0);
241
+ const responded = results.filter(r => !r.response.startsWith('Error:')).length;
242
+ process.stderr.write(` ${responded}/${results.length} models · ${totalTime.toFixed(1)}s · ${totalTokens} tokens\n\n`);
184
243
  }
185
244
  main();
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
- import { readFileSync } from 'fs';
1
+ import { readFileSync, mkdirSync } from 'fs';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { dirname, join } from 'path';
4
+ import { homedir } from 'os';
4
5
  import { execFile as execFileCb, execFileSync } from 'child_process';
5
6
  import { promisify } from 'util';
6
7
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
@@ -9,6 +10,17 @@ import { z } from 'zod';
9
10
  const execFile = promisify(execFileCb);
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
12
  const PROJECT_ROOT = join(__dirname, '..');
13
+ const SMOOTHIE_HOME = join(homedir(), '.smoothie');
14
+ try {
15
+ mkdirSync(SMOOTHIE_HOME, { recursive: true });
16
+ }
17
+ catch { }
18
+ // Read version from package.json
19
+ let CURRENT_VERSION = '0.0.0';
20
+ try {
21
+ CURRENT_VERSION = JSON.parse(readFileSync(join(PROJECT_ROOT, 'package.json'), 'utf8')).version;
22
+ }
23
+ catch { }
12
24
  // ---------------------------------------------------------------------------
13
25
  // .env loader (no dotenv dependency)
14
26
  // ---------------------------------------------------------------------------
@@ -44,7 +56,12 @@ async function queryCodex(prompt) {
44
56
  catch {
45
57
  response = '';
46
58
  }
47
- return { model: 'Codex', response: response || '(empty response)' };
59
+ const estimatedTokens = Math.ceil(prompt.length / 3) + Math.ceil(response.length / 3);
60
+ return {
61
+ model: 'Codex',
62
+ response: response || '(empty response)',
63
+ tokens: { prompt: Math.ceil(prompt.length / 3), completion: Math.ceil(response.length / 3), total: estimatedTokens },
64
+ };
48
65
  }
49
66
  catch (err) {
50
67
  const message = err instanceof Error ? err.message : String(err);
@@ -59,7 +76,7 @@ async function queryOpenRouter(prompt, modelId, modelLabel) {
59
76
  method: 'POST',
60
77
  headers: {
61
78
  'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
62
- 'HTTP-Referer': 'https://hotairbag.github.io/smoothie',
79
+ 'HTTP-Referer': 'https://smoothiecode.com',
63
80
  'X-Title': 'Smoothie',
64
81
  'Content-Type': 'application/json',
65
82
  },
@@ -75,7 +92,12 @@ async function queryOpenRouter(prompt, modelId, modelLabel) {
75
92
  }
76
93
  const data = (await res.json());
77
94
  const text = data.choices?.[0]?.message?.content ?? 'No response content';
78
- return { model: modelLabel, response: text };
95
+ const usage = data.usage;
96
+ return {
97
+ model: modelLabel,
98
+ response: text,
99
+ tokens: usage ? { prompt: usage.prompt_tokens || 0, completion: usage.completion_tokens || 0, total: usage.total_tokens || 0 } : undefined,
100
+ };
79
101
  }
80
102
  catch (err) {
81
103
  const message = err instanceof Error ? err.message : String(err);
@@ -154,7 +176,7 @@ function buildDeepContext(prompt) {
154
176
  // MCP Server
155
177
  // ---------------------------------------------------------------------------
156
178
  const server = new McpServer({ name: 'smoothie', version: '1.0.0' });
157
- server.tool('smoothie_blend', {
179
+ server.tool('smoothie_review', {
158
180
  prompt: z.string().describe('The prompt to send to all models'),
159
181
  deep: z.boolean().optional().describe('Full context mode with project files and git diff'),
160
182
  }, async ({ prompt, deep }) => {
@@ -189,7 +211,7 @@ server.tool('smoothie_blend', {
189
211
  });
190
212
  }
191
213
  // Print initial progress
192
- process.stderr.write('\n\u{1F9C3} Smoothie blending...\n\n');
214
+ process.stderr.write('\n\u{1F9C3} Smoothie reviewing...\n\n');
193
215
  for (const { label } of models) {
194
216
  process.stderr.write(` \u23F3 ${label.padEnd(26)} waiting...\n`);
195
217
  }
@@ -200,23 +222,85 @@ server.tool('smoothie_blend', {
200
222
  startTimes[label] = Date.now();
201
223
  return fn()
202
224
  .then((result) => {
203
- const elapsed = ((Date.now() - startTimes[label]) / 1000).toFixed(1);
204
- process.stderr.write(` \u2713 ${label.padEnd(26)} done (${elapsed}s)\n`);
205
- return result;
225
+ const elapsed = ((Date.now() - startTimes[label]) / 1000);
226
+ process.stderr.write(` \u2713 ${label.padEnd(26)} done (${elapsed.toFixed(1)}s)\n`);
227
+ return { ...result, elapsed_s: parseFloat(elapsed.toFixed(1)) };
206
228
  })
207
229
  .catch((err) => {
208
- const elapsed = ((Date.now() - startTimes[label]) / 1000).toFixed(1);
230
+ const elapsed = ((Date.now() - startTimes[label]) / 1000);
209
231
  const message = err instanceof Error ? err.message : String(err);
210
- process.stderr.write(` \u2717 ${label.padEnd(26)} failed (${elapsed}s)\n`);
211
- return { model: label, response: `Error: ${message}` };
232
+ process.stderr.write(` \u2717 ${label.padEnd(26)} failed (${elapsed.toFixed(1)}s)\n`);
233
+ return { model: label, response: `Error: ${message}`, elapsed_s: parseFloat(elapsed.toFixed(1)) };
212
234
  });
213
235
  });
214
236
  const results = await Promise.all(promises);
215
- const judgeNames = { claude: 'Claude', codex: 'Codex', gemini: 'Gemini' };
237
+ const judgeNames = { claude: 'Claude', codex: 'Codex', gemini: 'Gemini', cursor: 'Cursor' };
216
238
  const judgeName = judgeNames[platform] || 'the judge';
217
239
  process.stderr.write(`\n \u25C6 All done. Handing to ${judgeName}...\n\n`);
240
+ // Save for share command + append to history
241
+ try {
242
+ const { writeFileSync, appendFileSync } = await import('fs');
243
+ writeFileSync(join(SMOOTHIE_HOME, '.last-review.json'), JSON.stringify({ results }, null, 2));
244
+ const entry = {
245
+ ts: new Date().toISOString(),
246
+ type: deep ? 'deep' : 'review',
247
+ models: results.map(r => ({ model: r.model, elapsed_s: r.elapsed_s, tokens: r.tokens, error: r.response.startsWith('Error:') })),
248
+ };
249
+ appendFileSync(join(SMOOTHIE_HOME, '.smoothie-history.jsonl'), JSON.stringify(entry) + '\n');
250
+ }
251
+ catch { }
252
+ // Submit to leaderboard if opted in
253
+ try {
254
+ const cfg = JSON.parse(readFileSync(join(PROJECT_ROOT, 'config.json'), 'utf8'));
255
+ if (cfg.leaderboard && cfg.github) {
256
+ const now = new Date();
257
+ const jan1 = new Date(now.getFullYear(), 0, 1);
258
+ const days = Math.floor((now.getTime() - jan1.getTime()) / 86400000);
259
+ const weekNum = Math.ceil((days + jan1.getDay() + 1) / 7);
260
+ const week = `${now.getFullYear()}-W${String(weekNum).padStart(2, '0')}`;
261
+ const totalTokens = results.reduce((s, r) => s + (r.tokens?.total || 0), 0);
262
+ const reviewId = `${cfg.github}-${Date.now()}`;
263
+ await fetch('https://api.smoothiecode.com/api/submit', {
264
+ method: 'POST',
265
+ headers: { 'Content-Type': 'application/json' },
266
+ body: JSON.stringify({
267
+ github: cfg.github,
268
+ review_id: reviewId,
269
+ tokens: totalTokens,
270
+ blends: 1,
271
+ models: results.map(r => ({ model: r.model, tokens: r.tokens?.total || 0 })),
272
+ week,
273
+ }),
274
+ signal: AbortSignal.timeout(5000),
275
+ }).catch(() => { });
276
+ }
277
+ }
278
+ catch { }
279
+ // Weekly update check (non-blocking)
280
+ let updateNote = '';
281
+ try {
282
+ const checkFile = join(SMOOTHIE_HOME, '.update-check');
283
+ let shouldCheck = true;
284
+ try {
285
+ const last = readFileSync(checkFile, 'utf8').trim();
286
+ if (Date.now() - parseInt(last) < 7 * 24 * 60 * 60 * 1000)
287
+ shouldCheck = false;
288
+ }
289
+ catch { }
290
+ if (shouldCheck) {
291
+ const { writeFileSync: wfs } = await import('fs');
292
+ wfs(checkFile, String(Date.now()));
293
+ const res = await fetch('https://registry.npmjs.org/smoothie-code/latest', { signal: AbortSignal.timeout(3000) });
294
+ const { version: latest } = (await res.json());
295
+ const current = CURRENT_VERSION;
296
+ if (latest && latest !== current) {
297
+ updateNote = `\n\n⬆ Smoothie update available: ${current} → ${latest} — run: npx smoothie-code`;
298
+ }
299
+ }
300
+ }
301
+ catch { }
218
302
  return {
219
- content: [{ type: 'text', text: JSON.stringify({ results }, null, 2) }],
303
+ content: [{ type: 'text', text: JSON.stringify({ results }, null, 2) + updateNote }],
220
304
  };
221
305
  });
222
306
  server.tool('smoothie_estimate', {
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * review-cli.ts — Standalone blend runner for hooks.
4
+ *
5
+ * Usage:
6
+ * node dist/review-cli.js "Review this plan: ..."
7
+ * echo "plan text" | node dist/review-cli.js
8
+ *
9
+ * Queries Codex + OpenRouter models in parallel, prints JSON results to stdout.
10
+ * Progress goes to stderr so it doesn't interfere with hook JSON output.
11
+ */
12
+ export {};
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * review-cli.ts — Standalone blend runner for hooks.
4
+ *
5
+ * Usage:
6
+ * node dist/review-cli.js "Review this plan: ..."
7
+ * echo "plan text" | node dist/review-cli.js
8
+ *
9
+ * Queries Codex + OpenRouter models in parallel, prints JSON results to stdout.
10
+ * Progress goes to stderr so it doesn't interfere with hook JSON output.
11
+ */
12
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
13
+ import { fileURLToPath } from 'url';
14
+ import { dirname, join } from 'path';
15
+ import { homedir } from 'os';
16
+ import { execFile as execFileCb } from 'child_process';
17
+ import { promisify } from 'util';
18
+ import { createInterface } from 'readline';
19
+ const execFile = promisify(execFileCb);
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+ const PROJECT_ROOT = join(__dirname, '..');
22
+ const SMOOTHIE_HOME = join(homedir(), '.smoothie');
23
+ try {
24
+ mkdirSync(SMOOTHIE_HOME, { recursive: true });
25
+ }
26
+ catch { }
27
+ // ---------------------------------------------------------------------------
28
+ // .env loader
29
+ // ---------------------------------------------------------------------------
30
+ function loadEnv() {
31
+ try {
32
+ const env = readFileSync(join(PROJECT_ROOT, '.env'), 'utf8');
33
+ for (const line of env.split('\n')) {
34
+ const [key, ...val] = line.split('=');
35
+ if (key && val.length)
36
+ process.env[key.trim()] = val.join('=').trim();
37
+ }
38
+ }
39
+ catch {
40
+ // no .env
41
+ }
42
+ }
43
+ loadEnv();
44
+ // ---------------------------------------------------------------------------
45
+ // Model queries (same as index.ts)
46
+ // ---------------------------------------------------------------------------
47
+ async function queryCodex(prompt) {
48
+ try {
49
+ const tmpFile = join(PROJECT_ROOT, `.codex-out-${Date.now()}.txt`);
50
+ await execFile('codex', ['exec', '--full-auto', '-o', tmpFile, prompt], {
51
+ timeout: 0,
52
+ });
53
+ let response;
54
+ try {
55
+ response = readFileSync(tmpFile, 'utf8').trim();
56
+ const { unlinkSync } = await import('fs');
57
+ unlinkSync(tmpFile);
58
+ }
59
+ catch {
60
+ response = '';
61
+ }
62
+ const estimatedTokens = Math.ceil(prompt.length / 3) + Math.ceil(response.length / 3);
63
+ return {
64
+ model: 'Codex',
65
+ response: response || '(empty response)',
66
+ tokens: { prompt: Math.ceil(prompt.length / 3), completion: Math.ceil(response.length / 3), total: estimatedTokens },
67
+ };
68
+ }
69
+ catch (err) {
70
+ const message = err instanceof Error ? err.message : String(err);
71
+ return { model: 'Codex', response: `Error: ${message}` };
72
+ }
73
+ }
74
+ async function queryOpenRouter(prompt, modelId, modelLabel) {
75
+ try {
76
+ const controller = new AbortController();
77
+ const timer = setTimeout(() => controller.abort(), 60_000);
78
+ const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
79
+ method: 'POST',
80
+ headers: {
81
+ 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
82
+ 'HTTP-Referer': 'https://smoothiecode.com',
83
+ 'X-Title': 'Smoothie',
84
+ 'Content-Type': 'application/json',
85
+ },
86
+ body: JSON.stringify({
87
+ model: modelId,
88
+ messages: [{ role: 'user', content: prompt }],
89
+ }),
90
+ signal: controller.signal,
91
+ });
92
+ clearTimeout(timer);
93
+ const data = (await res.json());
94
+ const text = data.choices?.[0]?.message?.content ?? 'No response content';
95
+ const usage = data.usage;
96
+ return {
97
+ model: modelLabel,
98
+ response: text,
99
+ tokens: usage ? { prompt: usage.prompt_tokens || 0, completion: usage.completion_tokens || 0, total: usage.total_tokens || 0 } : undefined,
100
+ };
101
+ }
102
+ catch (err) {
103
+ const message = err instanceof Error ? err.message : String(err);
104
+ return { model: modelLabel, response: `Error: ${message}` };
105
+ }
106
+ }
107
+ // ---------------------------------------------------------------------------
108
+ // Read prompt from arg or stdin
109
+ // ---------------------------------------------------------------------------
110
+ async function getPrompt() {
111
+ if (process.argv[2])
112
+ return process.argv[2];
113
+ // Read from stdin
114
+ const rl = createInterface({ input: process.stdin });
115
+ const lines = [];
116
+ for await (const line of rl) {
117
+ lines.push(line);
118
+ }
119
+ return lines.join('\n');
120
+ }
121
+ // ---------------------------------------------------------------------------
122
+ // Main
123
+ // ---------------------------------------------------------------------------
124
+ async function main() {
125
+ const args = process.argv.slice(2);
126
+ const deep = args.includes('--deep');
127
+ const filteredArgs = args.filter(a => a !== '--deep');
128
+ // Temporarily override argv for getPrompt
129
+ process.argv = [process.argv[0], process.argv[1], ...filteredArgs];
130
+ const prompt = await getPrompt();
131
+ if (!prompt.trim()) {
132
+ process.stderr.write('review-cli: no prompt provided\n');
133
+ process.exit(1);
134
+ }
135
+ let finalPrompt = prompt;
136
+ if (deep) {
137
+ // Read context file
138
+ for (const name of ['GEMINI.md', 'CLAUDE.md', 'AGENTS.md']) {
139
+ try {
140
+ const content = readFileSync(join(process.cwd(), name), 'utf8');
141
+ if (content.trim()) {
142
+ finalPrompt = `## Context File\n${content}\n\n## Prompt\n${prompt}`;
143
+ break;
144
+ }
145
+ }
146
+ catch {
147
+ // file not found, try next
148
+ }
149
+ }
150
+ // Add git diff
151
+ try {
152
+ const { execFileSync } = await import('child_process');
153
+ const diff = execFileSync('git', ['diff', 'HEAD~3'], { encoding: 'utf8', maxBuffer: 100 * 1024, timeout: 10000 });
154
+ if (diff)
155
+ finalPrompt += `\n\n## Recent Git Diff\n${diff.slice(0, 40000)}`;
156
+ }
157
+ catch {
158
+ // no git diff available
159
+ }
160
+ }
161
+ let config;
162
+ try {
163
+ config = JSON.parse(readFileSync(join(PROJECT_ROOT, 'config.json'), 'utf8'));
164
+ }
165
+ catch {
166
+ config = { openrouter_models: [] };
167
+ }
168
+ const models = [
169
+ { fn: () => queryCodex(finalPrompt), label: 'Codex' },
170
+ ...config.openrouter_models.map((m) => ({
171
+ fn: () => queryOpenRouter(finalPrompt, m.id, m.label),
172
+ label: m.label,
173
+ })),
174
+ ];
175
+ process.stderr.write('\n🧃 Smoothie reviewing...\n\n');
176
+ for (const { label } of models) {
177
+ process.stderr.write(` ⏳ ${label.padEnd(26)} waiting...\n`);
178
+ }
179
+ process.stderr.write('\n');
180
+ const startTimes = {};
181
+ const promises = models.map(({ fn, label }) => {
182
+ startTimes[label] = Date.now();
183
+ return fn()
184
+ .then((result) => {
185
+ const elapsed = ((Date.now() - startTimes[label]) / 1000);
186
+ process.stderr.write(` ✓ ${label.padEnd(26)} done (${elapsed.toFixed(1)}s)\n`);
187
+ return { ...result, elapsed_s: parseFloat(elapsed.toFixed(1)) };
188
+ })
189
+ .catch((err) => {
190
+ const elapsed = ((Date.now() - startTimes[label]) / 1000);
191
+ const message = err instanceof Error ? err.message : String(err);
192
+ process.stderr.write(` ✗ ${label.padEnd(26)} failed (${elapsed.toFixed(1)}s)\n`);
193
+ return { model: label, response: `Error: ${message}`, elapsed_s: parseFloat(elapsed.toFixed(1)) };
194
+ });
195
+ });
196
+ const results = await Promise.all(promises);
197
+ process.stderr.write('\n ◆ All done.\n\n');
198
+ // Output JSON to stdout (for hook consumption)
199
+ process.stdout.write(JSON.stringify({ results }, null, 2));
200
+ // Save for share command + append to history
201
+ try {
202
+ const { appendFileSync } = await import('fs');
203
+ writeFileSync(join(SMOOTHIE_HOME, '.last-review.json'), JSON.stringify({ results }, null, 2));
204
+ const entry = {
205
+ ts: new Date().toISOString(),
206
+ type: deep ? 'deep' : 'review',
207
+ models: results.map(r => ({ model: r.model, elapsed_s: r.elapsed_s, tokens: r.tokens, error: r.response.startsWith('Error:') })),
208
+ };
209
+ appendFileSync(join(SMOOTHIE_HOME, '.smoothie-history.jsonl'), JSON.stringify(entry) + '\n');
210
+ }
211
+ catch { }
212
+ // Submit to leaderboard if opted in
213
+ try {
214
+ const cfg = JSON.parse(readFileSync(join(PROJECT_ROOT, 'config.json'), 'utf8'));
215
+ if (cfg.leaderboard && cfg.github) {
216
+ const now = new Date();
217
+ const jan1 = new Date(now.getFullYear(), 0, 1);
218
+ const days = Math.floor((now.getTime() - jan1.getTime()) / 86400000);
219
+ const weekNum = Math.ceil((days + jan1.getDay() + 1) / 7);
220
+ const week = `${now.getFullYear()}-W${String(weekNum).padStart(2, '0')}`;
221
+ const totalTokens = results.reduce((s, r) => s + (r.tokens?.total || 0), 0);
222
+ const reviewId = `${cfg.github}-${Date.now()}`;
223
+ await fetch('https://api.smoothiecode.com/api/submit', {
224
+ method: 'POST',
225
+ headers: { 'Content-Type': 'application/json' },
226
+ body: JSON.stringify({
227
+ github: cfg.github,
228
+ review_id: reviewId,
229
+ tokens: totalTokens,
230
+ blends: 1,
231
+ models: results.map(r => ({ model: r.model, tokens: r.tokens?.total || 0 })),
232
+ week,
233
+ }),
234
+ signal: AbortSignal.timeout(5000),
235
+ }).catch(() => { });
236
+ }
237
+ }
238
+ catch { }
239
+ const totalTime = Math.max(...results.map(r => r.elapsed_s || 0));
240
+ const totalTokens = results.reduce((sum, r) => sum + (r.tokens?.total || 0), 0);
241
+ const responded = results.filter(r => !r.response.startsWith('Error:')).length;
242
+ process.stderr.write(` ${responded}/${results.length} models · ${totalTime.toFixed(1)}s · ${totalTokens} tokens\n\n`);
243
+ }
244
+ main();
@@ -173,9 +173,11 @@ async function cmdPick(apiKey, configPath) {
173
173
  claude: ['anthropic'],
174
174
  codex: ['openai'],
175
175
  gemini: ['google'],
176
+ cursor: [], // Cursor uses various models, nothing to exclude
176
177
  };
177
178
  const excluded = excludePrefixes[platform] || [];
178
179
  topModels = topModels.filter(m => !excluded.some(prefix => m.id.startsWith(prefix + '/')));
180
+ console.log(' \x1b[90mSee trending: https://openrouter.ai/rankings/programming\x1b[0m');
179
181
  // Default selection: first 3
180
182
  const selected = new Set([0, 1, 2]);
181
183
  // Print list with selection markers
@@ -232,7 +234,7 @@ async function cmdPick(apiKey, configPath) {
232
234
  finalSelection.push(p);
233
235
  }
234
236
  }
235
- // Preserve existing config fields (like auto_blend)
237
+ // Preserve existing config fields (like auto_review)
236
238
  const existing = loadConfig(configPath);
237
239
  existing.openrouter_models = finalSelection;
238
240
  saveConfig(configPath, existing);