cc-model-selector 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.
Files changed (2) hide show
  1. package/cli.mjs +334 -0
  2. package/package.json +19 -0
package/cli.mjs ADDED
@@ -0,0 +1,334 @@
1
+ #!/usr/bin/env node
2
+
3
+ // cc-model-selector — Task complexity → Claude model recommendation
4
+ // Zero dependencies. Helps Claude Code users pick the right model.
5
+ //
6
+ // Shows: model pricing, task decision matrix, session-based cost analysis
7
+ //
8
+ // Usage:
9
+ // npx cc-model-selector # interactive decision guide
10
+ // npx cc-model-selector --task "write unit tests" # instant recommendation
11
+ // npx cc-model-selector --analyze # analyze your session history
12
+ // npx cc-model-selector --json # machine-readable output
13
+
14
+ import { readdir, stat } from 'node:fs/promises';
15
+ import { createReadStream } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+ import { createInterface } from 'node:readline';
19
+ import { createRequire } from 'node:module';
20
+
21
+ const PROJECTS_DIR = join(homedir(), '.claude', 'projects');
22
+
23
+ // Claude model pricing ($ per million tokens, as of 2025)
24
+ // Used for API-equivalent burn rate calculation
25
+ const MODELS = {
26
+ 'claude-haiku-4-5': {
27
+ label: 'Haiku 4.5',
28
+ input: 0.80,
29
+ output: 4.00,
30
+ cache_write: 1.00,
31
+ cache_read: 0.08,
32
+ tier: 1,
33
+ desc: 'Fastest, cheapest. Simple edits, lookups, formatting.',
34
+ },
35
+ 'claude-sonnet-4-6': {
36
+ label: 'Sonnet 4.6',
37
+ input: 3.00,
38
+ output: 15.00,
39
+ cache_write: 3.75,
40
+ cache_read: 0.30,
41
+ tier: 2,
42
+ desc: 'Balanced. Most tasks. Default for autonomous agents.',
43
+ },
44
+ 'claude-opus-4-6': {
45
+ label: 'Opus 4.6',
46
+ input: 15.00,
47
+ output: 75.00,
48
+ cache_write: 18.75,
49
+ cache_read: 1.50,
50
+ tier: 3,
51
+ desc: 'Most capable. Architecture, complex bugs, creative work.',
52
+ },
53
+ };
54
+
55
+ // Task complexity → model decision matrix
56
+ const TASK_RULES = [
57
+ {
58
+ keywords: ['fix typo', 'format', 'rename', 'comment', 'docstring', 'readme', 'changelog', 'lint'],
59
+ model: 'claude-haiku-4-5',
60
+ reason: 'Simple edit — no reasoning needed',
61
+ },
62
+ {
63
+ keywords: ['unit test', 'test', 'mock', 'fixture', 'migration', 'crud', 'boilerplate', 'scaffold'],
64
+ model: 'claude-sonnet-4-6',
65
+ reason: 'Structured but routine — Sonnet handles reliably',
66
+ },
67
+ {
68
+ keywords: ['refactor', 'implement', 'feature', 'api', 'integrate', 'debug', 'bug', 'fix'],
69
+ model: 'claude-sonnet-4-6',
70
+ reason: 'Mid-complexity coding — Sonnet default',
71
+ },
72
+ {
73
+ keywords: ['architecture', 'design', 'system', 'complex', 'optimize', 'performance', 'security', 'algorithm'],
74
+ model: 'claude-opus-4-6',
75
+ reason: 'High-stakes reasoning — Opus recommended',
76
+ },
77
+ {
78
+ keywords: ['review', 'analyze', 'explain', 'understand', 'research', 'strategy', 'plan'],
79
+ model: 'claude-opus-4-6',
80
+ reason: 'Deep analysis — Opus excels at nuanced judgment',
81
+ },
82
+ ];
83
+
84
+ const C = {
85
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
86
+ red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m',
87
+ blue: '\x1b[34m', cyan: '\x1b[36m', magenta: '\x1b[35m',
88
+ };
89
+
90
+ function pad(s, len, right = false) {
91
+ const t = String(s);
92
+ const p = ' '.repeat(Math.max(0, len - t.length));
93
+ return right ? p + t : t + p;
94
+ }
95
+
96
+ // ── Model recommendation for a task description ──────────────────────────────
97
+ function recommendModel(taskDesc) {
98
+ if (!taskDesc) return null;
99
+ const lower = taskDesc.toLowerCase();
100
+ for (const rule of TASK_RULES) {
101
+ if (rule.keywords.some(k => lower.includes(k))) {
102
+ return { modelId: rule.model, reason: rule.reason, model: MODELS[rule.model] };
103
+ }
104
+ }
105
+ // Default: Sonnet
106
+ return {
107
+ modelId: 'claude-sonnet-4-6',
108
+ reason: 'No specific pattern matched — Sonnet is a safe default',
109
+ model: MODELS['claude-sonnet-4-6'],
110
+ };
111
+ }
112
+
113
+ // ── Session analysis ──────────────────────────────────────────────────────────
114
+ function tokenCost(u, modelId) {
115
+ const m = MODELS[modelId] || MODELS['claude-sonnet-4-6'];
116
+ if (!u) return 0;
117
+ return (
118
+ (u.input_tokens || 0) * m.input / 1e6 +
119
+ (u.output_tokens || 0) * m.output / 1e6 +
120
+ (u.cache_creation_input_tokens || 0) * m.cache_write / 1e6 +
121
+ (u.cache_read_input_tokens || 0) * m.cache_read / 1e6
122
+ );
123
+ }
124
+
125
+ async function analyzeSession(filePath) {
126
+ const rl = createInterface({ input: createReadStream(filePath), crlfDelay: Infinity });
127
+ const result = { sonnetCost: 0, opusCost: 0, haikuCost: 0, totalMessages: 0, modelIds: new Set() };
128
+ for await (const line of rl) {
129
+ if (!line.trim()) continue;
130
+ let obj;
131
+ try { obj = JSON.parse(line); } catch { continue; }
132
+ if (obj.type !== 'assistant') continue;
133
+ const usage = obj.message?.usage || obj.usage;
134
+ if (!usage) continue;
135
+ const modelId = obj.message?.model || obj.model || 'claude-sonnet-4-6';
136
+ result.modelIds.add(modelId);
137
+ result.totalMessages++;
138
+ const cost = tokenCost(usage, modelId);
139
+ if (modelId.includes('opus')) result.opusCost += cost;
140
+ else if (modelId.includes('haiku')) result.haikuCost += cost;
141
+ else result.sonnetCost += cost;
142
+ }
143
+ return result;
144
+ }
145
+
146
+ async function collectSessionAnalysis() {
147
+ const totals = { sonnetCost: 0, opusCost: 0, haikuCost: 0, totalMessages: 0, modelIds: new Set() };
148
+ const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
149
+ try {
150
+ const projects = await readdir(PROJECTS_DIR);
151
+ for (const proj of projects) {
152
+ const dir = join(PROJECTS_DIR, proj);
153
+ let files;
154
+ try { files = await readdir(dir); } catch { continue; }
155
+ for (const f of files) {
156
+ if (!f.endsWith('.jsonl')) continue;
157
+ const fp = join(dir, f);
158
+ try {
159
+ const s = await stat(fp);
160
+ if (s.size < 100 || s.mtimeMs < cutoff) continue;
161
+ } catch { continue; }
162
+ const r = await analyzeSession(fp);
163
+ totals.sonnetCost += r.sonnetCost;
164
+ totals.opusCost += r.opusCost;
165
+ totals.haikuCost += r.haikuCost;
166
+ totals.totalMessages += r.totalMessages;
167
+ r.modelIds.forEach(id => totals.modelIds.add(id));
168
+ }
169
+ }
170
+ } catch {}
171
+ return totals;
172
+ }
173
+
174
+ // ── Render ────────────────────────────────────────────────────────────────────
175
+ function renderPricingTable() {
176
+ const lines = [];
177
+ lines.push(` ${C.dim}Model pricing (API-equivalent, $ per 1M tokens)${C.reset}`);
178
+ lines.push(` ${C.dim}${'Model'.padEnd(14)} ${'Input'.padStart(8)} ${'Output'.padStart(8)} ${'CacheR'.padStart(8)} Use when${C.reset}`);
179
+ lines.push(' ' + C.dim + '─'.repeat(70) + C.reset);
180
+ for (const [id, m] of Object.entries(MODELS)) {
181
+ const col = m.tier === 1 ? C.green : m.tier === 2 ? C.cyan : C.yellow;
182
+ lines.push(
183
+ ` ${col}${pad(m.label, 14)}${C.reset}` +
184
+ ` ${pad('$'+m.input.toFixed(2), 8, true)}` +
185
+ ` ${pad('$'+m.output.toFixed(2), 8, true)}` +
186
+ ` ${pad('$'+m.cache_read.toFixed(2), 8, true)}` +
187
+ ` ${C.dim}${m.desc}${C.reset}`
188
+ );
189
+ }
190
+ return lines;
191
+ }
192
+
193
+ function renderDecisionMatrix() {
194
+ const lines = [];
195
+ lines.push(` ${C.dim}Task → Model decision matrix${C.reset}`);
196
+ lines.push(' ' + C.dim + '─'.repeat(60) + C.reset);
197
+
198
+ const byModel = {};
199
+ for (const rule of TASK_RULES) {
200
+ if (!byModel[rule.model]) byModel[rule.model] = [];
201
+ byModel[rule.model].push(...rule.keywords.slice(0, 3));
202
+ }
203
+ for (const [modelId, keywords] of Object.entries(byModel)) {
204
+ const m = MODELS[modelId];
205
+ const col = m.tier === 1 ? C.green : m.tier === 2 ? C.cyan : C.yellow;
206
+ lines.push(` ${col}${m.label}${C.reset} ${keywords.slice(0, 5).join(', ')}${keywords.length > 5 ? ', ...' : ''}`);
207
+ }
208
+ return lines;
209
+ }
210
+
211
+ function renderCostMultipliers() {
212
+ const base = MODELS['claude-sonnet-4-6'];
213
+ const lines = [];
214
+ lines.push(` ${C.dim}Cost multiplier vs Sonnet${C.reset}`);
215
+ for (const [id, m] of Object.entries(MODELS)) {
216
+ const inputMult = (m.input / base.input).toFixed(1) + 'x';
217
+ const outputMult = (m.output / base.output).toFixed(1) + 'x';
218
+ const col = m.tier === 1 ? C.green : m.tier === 2 ? C.cyan : C.yellow;
219
+ lines.push(` ${col}${pad(m.label, 14)}${C.reset} input ${pad(inputMult, 6, true)} output ${pad(outputMult, 6, true)}`);
220
+ }
221
+ return lines;
222
+ }
223
+
224
+ function renderAnalysis(totals) {
225
+ const lines = [];
226
+ const total = totals.sonnetCost + totals.opusCost + totals.haikuCost;
227
+ const W = 52;
228
+
229
+ lines.push(` ${C.dim}Your model usage (last 30 days)${C.reset}`);
230
+ lines.push(' ' + C.dim + '─'.repeat(W) + C.reset);
231
+
232
+ if (total === 0) {
233
+ lines.push(` ${C.dim}No session data found in ${PROJECTS_DIR}${C.reset}`);
234
+ return lines;
235
+ }
236
+
237
+ const fmtCost = (n) => '$' + n.toFixed(3);
238
+ const fmtPct = (n) => (n * 100).toFixed(0) + '%';
239
+
240
+ lines.push(` Total (API-equiv) ${pad(fmtCost(total), 10, true)}`);
241
+ if (totals.sonnetCost > 0) lines.push(` Sonnet ${pad(fmtCost(totals.sonnetCost), 10, true)} ${pad(fmtPct(totals.sonnetCost/total), 5, true)}`);
242
+ if (totals.opusCost > 0) lines.push(` Opus ${pad(fmtCost(totals.opusCost), 10, true)} ${pad(fmtPct(totals.opusCost/total), 5, true)} ${C.yellow}← expensive${C.reset}`);
243
+ if (totals.haikuCost > 0) lines.push(` Haiku ${pad(fmtCost(totals.haikuCost), 10, true)} ${pad(fmtPct(totals.haikuCost/total), 5, true)} ${C.green}← cheapest${C.reset}`);
244
+ lines.push('');
245
+
246
+ // Savings suggestion
247
+ if (totals.opusCost > 0) {
248
+ const savings = totals.opusCost * 0.4; // if 40% of Opus tasks could use Sonnet
249
+ lines.push(` ${C.yellow}Savings opportunity:${C.reset}`);
250
+ lines.push(` If 40% of Opus tasks used Sonnet instead:`);
251
+ lines.push(` ~${fmtCost(savings)} API-equivalent savings over 30d`);
252
+ lines.push(` ${C.dim}(Useful for autonomous agents with mixed task types)${C.reset}`);
253
+ } else if (totals.sonnetCost > 0 && totals.haikuCost === 0) {
254
+ const potentialSavings = totals.sonnetCost * 0.2 * (3.0 / 0.8 - 1) * 0.8 / 3.0;
255
+ // rough: 20% of Sonnet tasks could be Haiku, Haiku is ~3.75x cheaper on input
256
+ lines.push(` ${C.cyan}Optimization note:${C.reset}`);
257
+ lines.push(` All sessions used Sonnet. Consider Haiku for simple tasks.`);
258
+ lines.push(` Potential savings: ~${fmtCost(potentialSavings)}/30d if 20% migrate to Haiku`);
259
+ }
260
+
261
+ return lines;
262
+ }
263
+
264
+ // ── Main ──────────────────────────────────────────────────────────────────────
265
+ const args = process.argv.slice(2);
266
+ const taskIdx = args.indexOf('--task');
267
+ const taskDesc = taskIdx >= 0 ? args.slice(taskIdx + 1).join(' ') : null;
268
+ const doAnalyze = args.includes('--analyze');
269
+ const jsonMode = args.includes('--json');
270
+
271
+ (async () => {
272
+ if (taskDesc) {
273
+ // Single recommendation
274
+ const rec = recommendModel(taskDesc);
275
+ if (jsonMode) {
276
+ console.log(JSON.stringify({ task: taskDesc, recommended: rec.modelId, reason: rec.reason, model: rec.model }));
277
+ return;
278
+ }
279
+ console.log('');
280
+ console.log(` ${C.dim}Task:${C.reset} ${taskDesc}`);
281
+ const col = rec.model.tier === 1 ? C.green : rec.model.tier === 2 ? C.cyan : C.yellow;
282
+ console.log(` ${C.bold}Recommended:${C.reset} ${col}${rec.model.label}${C.reset}`);
283
+ console.log(` ${C.dim}Reason:${C.reset} ${rec.reason}`);
284
+ console.log(` ${C.dim}Desc:${C.reset} ${rec.model.desc}`);
285
+ console.log('');
286
+ return;
287
+ }
288
+
289
+ const W = 52;
290
+ const div = C.dim + '─'.repeat(W) + C.reset;
291
+ const lines = [];
292
+
293
+ lines.push('');
294
+ lines.push(` ${C.bold}cc-model-selector${C.reset} ${C.dim}Task → Claude model guide${C.reset}`);
295
+ lines.push(' ' + div);
296
+ lines.push('');
297
+
298
+ // Pricing table
299
+ lines.push(...renderPricingTable());
300
+ lines.push('');
301
+
302
+ // Cost multipliers
303
+ lines.push(...renderCostMultipliers());
304
+ lines.push('');
305
+
306
+ // Decision matrix
307
+ lines.push(...renderDecisionMatrix());
308
+ lines.push('');
309
+
310
+ lines.push(' ' + div);
311
+ lines.push(` ${C.dim}Usage:${C.reset}`);
312
+ lines.push(` ${C.cyan}npx cc-model-selector --task "refactor auth module"${C.reset}`);
313
+ lines.push(` ${C.cyan}npx cc-model-selector --analyze${C.reset} ${C.dim}# analyze your 30d session history${C.reset}`);
314
+ lines.push('');
315
+
316
+ if (doAnalyze) {
317
+ process.stdout.write(C.dim + ' Analyzing sessions...\r' + C.reset);
318
+ const totals = await collectSessionAnalysis();
319
+ process.stdout.write(' '.repeat(40) + '\r');
320
+ lines.push(...renderAnalysis(totals));
321
+ lines.push('');
322
+ }
323
+
324
+ if (jsonMode) {
325
+ const out = {
326
+ models: Object.fromEntries(Object.entries(MODELS).map(([k, v]) => [k, v])),
327
+ decision_matrix: TASK_RULES,
328
+ };
329
+ console.log(JSON.stringify(out, null, 2));
330
+ return;
331
+ }
332
+
333
+ console.log(lines.join('\n'));
334
+ })();
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "cc-model-selector",
3
+ "version": "1.0.0",
4
+ "description": "Task complexity → Claude model recommendation for Claude Code users",
5
+ "type": "module",
6
+ "bin": {
7
+ "cc-model-selector": "cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "start": "node cli.mjs"
11
+ },
12
+ "keywords": ["claude", "claude-code", "ai", "model", "sonnet", "opus", "haiku", "cost", "optimization"],
13
+ "author": "yurukusa",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/yurukusa/cc-model-selector"
18
+ }
19
+ }