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.
- package/cli.mjs +334 -0
- 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
|
+
}
|