coderplan-tier 0.1.0 → 0.3.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/README.md CHANGED
@@ -23,43 +23,49 @@ Developers need guidance on which Claude Code pricing tier best fits their usage
23
23
 
24
24
  ## How It Works
25
25
 
26
- 1. Reads your Claude Code usage data via [ccusage](https://github.com/ryoppippi/ccusage)
27
- 2. Analyzes usage patterns (sessions, tokens, costs, model mix)
26
+ 1. Reads usage data from both:
27
+ - **Claude Code** via [ccusage](https://github.com/ryoppippi/ccusage)
28
+ - **OpenAI Codex** via [@ccusage/codex](https://www.npmjs.com/package/@ccusage/codex)
29
+ 2. Combines and analyzes usage patterns (sessions, tokens, costs, model mix)
28
30
  3. Calculates break-even points for each tier
29
31
  4. Recommends the tier that minimizes your total cost
30
32
 
33
+ This is especially useful for developers transitioning from Codex to Claude - see your combined costs and what Claude tier would cover your usage.
34
+
31
35
  ## Example Output
32
36
 
33
37
  ```
34
- ╭─────────────────────────────────────────────────────────╮
35
- CoderPlan Tier Recommendation
36
- ╰─────────────────────────────────────────────────────────╯
38
+ ╭──────────────────────────────────────────────────────────╮
39
+ CoderPlan Tier Recommendation
40
+ ╰──────────────────────────────────────────────────────────╯
37
41
 
38
42
  Your Usage (Last 30 Days)
39
43
  Sessions: 47
40
44
  Active Days: 22/30
41
45
  Total Tokens: 2.4M
42
- API Cost: $67.32
43
- Primary Model: Claude Sonnet (82%), Opus (18%)
46
+ API Cost: $127.32
47
+ ├─ Claude: $67.32 (53%)
48
+ └─ Codex: $60.00 (47%)
49
+ Primary Model: Claude Opus (45%), Codex gpt-5 (40%), Claude Sonnet (15%)
44
50
 
45
51
  Recommendation: Max 5x ($100/month)
46
52
  Confidence: HIGH (consistent daily usage pattern)
47
53
 
48
54
  Reasoning:
49
- - You use Claude Code 22 days/month (heavy usage)
50
- - Current API costs ($67/mo) exceed Pro break-even (~$30)
55
+ - Heavy usage (22 active days/month)
56
+ - API costs ($127/mo) exceed Pro break-even (~$25)
51
57
  - Max 5x limits would cover your typical usage
52
58
 
53
- Projected Savings: ~$47/month vs API-only
59
+ Projected Savings: ~$65/month vs API-only
54
60
 
55
- ╭─────────────────────────────────────────────────────────╮
56
- │ Tier Comparison
57
- ├─────────────────────────────────────────────────────────┤
58
- │ API Only $67/month (current spend) │
59
- │ Pro $35/month ($20 + ~$15 overflow) │
60
- │ Max 5x $100/month (no overflow expected) <--
61
- │ Max 20x $200/month (overkill)
62
- ╰─────────────────────────────────────────────────────────╯
61
+ ╭──────────────────────────────────────────────────────────╮
62
+ │ Tier Comparison
63
+ ├──────────────────────────────────────────────────────────┤
64
+ │ API Only $127/month (current spend) │
65
+ │ Pro $58/month ($20 + ~$38 overflow) │
66
+ │ Max 5x $113/month ($100 + ~$13 overflow) <--
67
+ │ Max 20x $203/month ($200 + ~$3 overflow)
68
+ ╰──────────────────────────────────────────────────────────╯
63
69
  ```
64
70
 
65
71
  ## CLI Options
package/dist/collector.js CHANGED
@@ -1,32 +1,49 @@
1
1
  import { execSync } from "child_process";
2
2
  export async function getUsageData(days) {
3
3
  const since = formatDateYYYYMMDD(new Date(Date.now() - days * 24 * 60 * 60 * 1000));
4
- // Get daily data (more granular for active days calculation)
5
- const dailyData = runCcusage("daily", since);
6
- // Aggregate totals from daily data
4
+ // Fetch both Claude and Codex usage in parallel
5
+ const [claudeData, codexData] = await Promise.all([
6
+ fetchClaudeUsage(since),
7
+ fetchCodexUsage(since),
8
+ ]);
9
+ // Aggregate totals
7
10
  let totalCost = 0;
8
11
  let totalInputTokens = 0;
9
12
  let totalOutputTokens = 0;
10
13
  let totalCacheCreationTokens = 0;
11
14
  let totalCacheReadTokens = 0;
15
+ let claudeCost = 0;
16
+ let codexCost = 0;
12
17
  const modelMap = new Map();
13
- for (const day of dailyData.daily) {
18
+ const dailyMap = new Map();
19
+ // Process Claude data
20
+ for (const day of claudeData.daily) {
14
21
  totalCost += day.totalCost;
22
+ claudeCost += day.totalCost;
15
23
  totalInputTokens += day.inputTokens;
16
24
  totalOutputTokens += day.outputTokens;
17
- totalCacheCreationTokens += day.cacheCreationTokens;
18
- totalCacheReadTokens += day.cacheReadTokens;
25
+ totalCacheCreationTokens += day.cacheCreationTokens ?? 0;
26
+ totalCacheReadTokens += day.cacheReadTokens ?? 0;
27
+ // Track daily usage
28
+ const existing = dailyMap.get(day.date);
29
+ if (existing) {
30
+ existing.cost += day.totalCost;
31
+ }
32
+ else {
33
+ dailyMap.set(day.date, { date: day.date, cost: day.totalCost, sessions: 1 });
34
+ }
19
35
  // Aggregate model usage
20
- for (const model of day.modelBreakdowns) {
21
- const existing = modelMap.get(model.modelName);
22
- if (existing) {
23
- existing.cost += model.cost;
24
- existing.inputTokens += model.inputTokens;
25
- existing.outputTokens += model.outputTokens;
36
+ for (const model of day.modelBreakdowns ?? []) {
37
+ const modelName = `Claude ${simplifyModelName(model.modelName)}`;
38
+ const existingModel = modelMap.get(modelName);
39
+ if (existingModel) {
40
+ existingModel.cost += model.cost;
41
+ existingModel.inputTokens += model.inputTokens;
42
+ existingModel.outputTokens += model.outputTokens;
26
43
  }
27
44
  else {
28
- modelMap.set(model.modelName, {
29
- model: model.modelName,
45
+ modelMap.set(modelName, {
46
+ model: modelName,
30
47
  cost: model.cost,
31
48
  inputTokens: model.inputTokens,
32
49
  outputTokens: model.outputTokens,
@@ -35,8 +52,49 @@ export async function getUsageData(days) {
35
52
  }
36
53
  }
37
54
  }
38
- const activeDays = dailyData.daily.filter((d) => d.totalCost > 0).length;
39
- const sessionCount = dailyData.daily.length; // Approximation: 1 session per active day
55
+ // Process Codex data
56
+ for (const day of codexData.daily) {
57
+ const dayCost = day.costUSD ?? 0;
58
+ totalCost += dayCost;
59
+ codexCost += dayCost;
60
+ totalInputTokens += day.inputTokens;
61
+ totalOutputTokens += day.outputTokens;
62
+ // Track daily usage
63
+ const existing = dailyMap.get(day.date);
64
+ if (existing) {
65
+ existing.cost += dayCost;
66
+ }
67
+ else {
68
+ dailyMap.set(day.date, { date: day.date, cost: dayCost, sessions: 1 });
69
+ }
70
+ // Aggregate model usage from models object
71
+ if (day.models) {
72
+ for (const [modelName, modelData] of Object.entries(day.models)) {
73
+ const displayName = `Codex ${modelName}`;
74
+ const existingModel = modelMap.get(displayName);
75
+ // Estimate cost per model proportionally
76
+ const modelTokens = modelData.inputTokens + modelData.outputTokens;
77
+ const dayTotalTokens = day.inputTokens + day.outputTokens;
78
+ const modelCost = dayTotalTokens > 0 ? (modelTokens / dayTotalTokens) * dayCost : 0;
79
+ if (existingModel) {
80
+ existingModel.cost += modelCost;
81
+ existingModel.inputTokens += modelData.inputTokens;
82
+ existingModel.outputTokens += modelData.outputTokens;
83
+ }
84
+ else {
85
+ modelMap.set(displayName, {
86
+ model: displayName,
87
+ cost: modelCost,
88
+ inputTokens: modelData.inputTokens,
89
+ outputTokens: modelData.outputTokens,
90
+ percentage: 0,
91
+ });
92
+ }
93
+ }
94
+ }
95
+ }
96
+ const activeDays = Array.from(dailyMap.values()).filter((d) => d.cost > 0).length;
97
+ const sessionCount = dailyMap.size;
40
98
  // Calculate percentages
41
99
  const modelBreakdown = Array.from(modelMap.values()).map((m) => ({
42
100
  ...m,
@@ -44,11 +102,7 @@ export async function getUsageData(days) {
44
102
  }));
45
103
  // Sort by cost descending
46
104
  modelBreakdown.sort((a, b) => b.cost - a.cost);
47
- const dailyUsage = dailyData.daily.map((d) => ({
48
- date: d.date,
49
- cost: d.totalCost,
50
- sessions: 1,
51
- }));
105
+ const dailyUsage = Array.from(dailyMap.values()).sort((a, b) => a.date.localeCompare(b.date));
52
106
  return {
53
107
  totalCost,
54
108
  totalInputTokens,
@@ -60,37 +114,40 @@ export async function getUsageData(days) {
60
114
  periodDays: days,
61
115
  modelBreakdown,
62
116
  dailyUsage,
117
+ claudeCost,
118
+ codexCost,
63
119
  };
64
120
  }
65
- function runCcusage(command, since) {
121
+ async function fetchClaudeUsage(since) {
122
+ return runCommand(`npx ccusage@latest daily --json --since ${since}`, { daily: [] });
123
+ }
124
+ async function fetchCodexUsage(since) {
125
+ return runCommand(`npx @ccusage/codex@latest daily --json --since ${since}`, { daily: [] });
126
+ }
127
+ function runCommand(command, fallback) {
66
128
  try {
67
- const output = execSync(`npx ccusage@latest ${command} --json --since ${since}`, {
129
+ const output = execSync(command, {
68
130
  encoding: "utf-8",
69
131
  stdio: ["pipe", "pipe", "pipe"],
70
- timeout: 30000,
132
+ timeout: 60000,
71
133
  });
72
134
  return JSON.parse(output);
73
135
  }
74
136
  catch (error) {
137
+ // Try to parse stdout even on error (some tools exit non-zero with valid output)
75
138
  if (error instanceof Error && "stdout" in error) {
76
139
  const stdout = error.stdout;
77
- // ccusage may return empty data with exit code 0
78
- if (stdout) {
140
+ if (stdout?.trim()) {
79
141
  try {
80
142
  return JSON.parse(stdout);
81
143
  }
82
144
  catch {
83
- // Fall through to error handling
145
+ // Fall through
84
146
  }
85
147
  }
86
148
  }
87
- // Return empty data structure for no data case
88
- if (error instanceof Error &&
89
- (error.message.includes("No usage data") ||
90
- error.message.includes("ENOENT"))) {
91
- return { monthly: [], daily: [] };
92
- }
93
- throw new Error(`Failed to run ccusage: ${error instanceof Error ? error.message : String(error)}`);
149
+ // Return fallback for missing data
150
+ return fallback;
94
151
  }
95
152
  }
96
153
  function formatDateYYYYMMDD(date) {
@@ -99,3 +156,13 @@ function formatDateYYYYMMDD(date) {
99
156
  const day = String(date.getDate()).padStart(2, "0");
100
157
  return `${year}${month}${day}`;
101
158
  }
159
+ function simplifyModelName(model) {
160
+ const lower = model.toLowerCase();
161
+ if (lower.includes("opus"))
162
+ return "Opus";
163
+ if (lower.includes("sonnet"))
164
+ return "Sonnet";
165
+ if (lower.includes("haiku"))
166
+ return "Haiku";
167
+ return model.split("-").slice(0, 2).join(" ");
168
+ }
@@ -40,6 +40,8 @@ export function recommend(usage) {
40
40
  const apiOnlyCost = projectedCosts.find((c) => c.tier === "API Only")?.monthlyCost ?? currentMonthlyCost;
41
41
  const recommendedCost = projectedCosts.find((c) => c.tier === tier)?.monthlyCost ?? currentMonthlyCost;
42
42
  const savings = apiOnlyCost - recommendedCost;
43
+ // Generate explanation comparing recommended tier to next tier up
44
+ const explanation = generateExplanation(tier, projectedCosts);
43
45
  return {
44
46
  tier,
45
47
  confidence,
@@ -47,6 +49,7 @@ export function recommend(usage) {
47
49
  projectedCosts,
48
50
  currentMonthlyCost,
49
51
  savings: Math.max(0, savings),
52
+ explanation,
50
53
  };
51
54
  }
52
55
  function selectLowestCostTier(projectedCosts) {
@@ -130,3 +133,44 @@ function generateReasons(tier, monthlyCost, activeDays, usage) {
130
133
  }
131
134
  return reasons;
132
135
  }
136
+ const TIER_ORDER = ["API Only", "Pro", "Max 5x", "Max 20x"];
137
+ function generateExplanation(recommendedTier, projectedCosts) {
138
+ const recommendedIdx = TIER_ORDER.indexOf(recommendedTier);
139
+ const recommendedCost = projectedCosts.find((c) => c.tier === recommendedTier);
140
+ if (!recommendedCost)
141
+ return null;
142
+ // Compare to next tier up (or down if at max)
143
+ let comparedTier;
144
+ if (recommendedIdx < TIER_ORDER.length - 1) {
145
+ comparedTier = TIER_ORDER[recommendedIdx + 1];
146
+ }
147
+ else {
148
+ comparedTier = TIER_ORDER[recommendedIdx - 1];
149
+ }
150
+ const comparedCost = projectedCosts.find((c) => c.tier === comparedTier);
151
+ if (!comparedCost)
152
+ return null;
153
+ const subscriptionDiff = comparedCost.subscriptionCost - recommendedCost.subscriptionCost;
154
+ const overflowDiff = recommendedCost.overflowCost - comparedCost.overflowCost;
155
+ // Calculate break-even point
156
+ // At break-even: recSub + recOverflow(x) = compSub + compOverflow(x)
157
+ // recSub + x*(1-recCoverage) = compSub + x*(1-compCoverage)
158
+ // x*(compCoverage - recCoverage) = compSub - recSub
159
+ // x = (compSub - recSub) / (compCoverage - recCoverage)
160
+ const recCoverage = COVERAGE_RATIO[recommendedTier];
161
+ const compCoverage = COVERAGE_RATIO[comparedTier];
162
+ const coverageDiff = compCoverage - recCoverage;
163
+ let breakEvenCost = 0;
164
+ if (coverageDiff > 0) {
165
+ breakEvenCost = Math.round(subscriptionDiff / coverageDiff);
166
+ }
167
+ return {
168
+ recommendedTier,
169
+ comparedTier,
170
+ recommendedCost,
171
+ comparedCost,
172
+ subscriptionDiff,
173
+ overflowDiff,
174
+ breakEvenCost,
175
+ };
176
+ }
package/dist/renderer.js CHANGED
@@ -13,6 +13,14 @@ export function renderRecommendation(usage, rec, verbose) {
13
13
  lines.push(` Active Days: ${usage.activeDays}/${usage.periodDays}`);
14
14
  lines.push(` Total Tokens: ${formatTokens(usage.totalInputTokens + usage.totalOutputTokens)}`);
15
15
  lines.push(` API Cost: $${usage.totalCost.toFixed(2)}`);
16
+ // Show Claude/Codex breakdown if both have usage
17
+ if (usage.claudeCost > 0 && usage.codexCost > 0) {
18
+ lines.push(` ${pc.dim("├─")} Claude: $${usage.claudeCost.toFixed(2)} (${((usage.claudeCost / usage.totalCost) * 100).toFixed(0)}%)`);
19
+ lines.push(` ${pc.dim("└─")} Codex: $${usage.codexCost.toFixed(2)} (${((usage.codexCost / usage.totalCost) * 100).toFixed(0)}%)`);
20
+ }
21
+ else if (usage.codexCost > 0) {
22
+ lines.push(` ${pc.dim("└─")} Codex only`);
23
+ }
16
24
  // Model breakdown
17
25
  if (usage.modelBreakdown.length > 0) {
18
26
  const modelStr = usage.modelBreakdown
@@ -53,6 +61,11 @@ export function renderRecommendation(usage, rec, verbose) {
53
61
  lines.push(boxContent(line));
54
62
  }
55
63
  lines.push(boxBottom());
64
+ // Why this tier? explanation
65
+ if (rec.explanation) {
66
+ lines.push("");
67
+ lines.push(...renderExplanation(rec.explanation));
68
+ }
56
69
  // Verbose: show detailed metrics
57
70
  if (verbose) {
58
71
  lines.push("");
@@ -76,6 +89,8 @@ export function renderJson(usage, rec) {
76
89
  activeDays: usage.activeDays,
77
90
  sessionCount: usage.sessionCount,
78
91
  totalCost: usage.totalCost,
92
+ claudeCost: usage.claudeCost,
93
+ codexCost: usage.codexCost,
79
94
  totalTokens: usage.totalInputTokens + usage.totalOutputTokens,
80
95
  modelBreakdown: usage.modelBreakdown,
81
96
  },
@@ -86,9 +101,29 @@ export function renderJson(usage, rec) {
86
101
  currentMonthlyCost: rec.currentMonthlyCost,
87
102
  projectedSavings: rec.savings,
88
103
  tierCosts: rec.projectedCosts,
104
+ explanation: rec.explanation,
89
105
  },
90
106
  }, null, 2);
91
107
  }
108
+ function renderExplanation(exp) {
109
+ const lines = [];
110
+ const rec = exp.recommendedCost;
111
+ const comp = exp.comparedCost;
112
+ lines.push(pc.bold(`Why ${exp.recommendedTier} over ${exp.comparedTier}?`));
113
+ lines.push(` ${padRight(exp.recommendedTier + ":", 10)} $${rec.subscriptionCost} subscription + $${rec.overflowCost.toFixed(0)} overflow = ${pc.green(`$${rec.monthlyCost.toFixed(0)} total`)}`);
114
+ lines.push(` ${padRight(exp.comparedTier + ":", 10)} $${comp.subscriptionCost} subscription + $${comp.overflowCost.toFixed(0)} overflow = ${pc.dim(`$${comp.monthlyCost.toFixed(0)} total`)}`);
115
+ lines.push("");
116
+ if (exp.subscriptionDiff > 0 && exp.overflowDiff > 0) {
117
+ lines.push(` The $${exp.subscriptionDiff} subscription difference outweighs the $${exp.overflowDiff.toFixed(0)} overflow savings.`);
118
+ }
119
+ else if (exp.subscriptionDiff < 0) {
120
+ lines.push(` Lower subscription ($${Math.abs(exp.subscriptionDiff)} less) with acceptable overflow.`);
121
+ }
122
+ if (exp.breakEvenCost > 0) {
123
+ lines.push(` ${pc.dim(`${exp.comparedTier} becomes better when API costs reach ~$${exp.breakEvenCost}/month.`)}`);
124
+ }
125
+ return lines;
126
+ }
92
127
  // Box drawing helpers
93
128
  function boxTop() {
94
129
  return pc.dim("╭" + "─".repeat(BOX_WIDTH) + "╮");
@@ -128,14 +163,13 @@ function formatTokens(tokens) {
128
163
  return tokens.toString();
129
164
  }
130
165
  function simplifyModelName(model) {
131
- const lower = model.toLowerCase();
132
- if (lower.includes("opus"))
133
- return "Opus";
134
- if (lower.includes("sonnet"))
135
- return "Sonnet";
136
- if (lower.includes("haiku"))
137
- return "Haiku";
138
- return model.split("-").slice(0, 2).join(" ");
166
+ // Model names already have "Claude" or "Codex" prefix from collector
167
+ // Just clean up for display
168
+ return model
169
+ .replace("claude-", "")
170
+ .replace("-20251101", "")
171
+ .replace("-20251001", "")
172
+ .replace("-20250929", "");
139
173
  }
140
174
  function getTierColor(tier) {
141
175
  switch (tier) {
package/dist/types.d.ts CHANGED
@@ -9,6 +9,8 @@ export interface UsageData {
9
9
  periodDays: number;
10
10
  modelBreakdown: ModelUsage[];
11
11
  dailyUsage: DailyUsage[];
12
+ claudeCost: number;
13
+ codexCost: number;
12
14
  }
13
15
  export interface ModelUsage {
14
16
  model: string;
@@ -31,6 +33,16 @@ export interface Recommendation {
31
33
  projectedCosts: TierCost[];
32
34
  currentMonthlyCost: number;
33
35
  savings: number;
36
+ explanation: TierExplanation | null;
37
+ }
38
+ export interface TierExplanation {
39
+ recommendedTier: Tier;
40
+ comparedTier: Tier;
41
+ recommendedCost: TierCost;
42
+ comparedCost: TierCost;
43
+ subscriptionDiff: number;
44
+ overflowDiff: number;
45
+ breakEvenCost: number;
34
46
  }
35
47
  export interface TierCost {
36
48
  tier: Tier;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coderplan-tier",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Analyze Claude Code usage and get tier recommendations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",