devspend 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zhx842htt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,50 @@
1
- # Temporary Holding Version
1
+ # devspend
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > **One ledger for your AI dev spend. Tagged by project. Ready to bill.**
4
+
5
+ Repo: <https://github.com/zhx842htt/devspend>
6
+
7
+ If you deliver client work with Claude Code, API keys, and AI subscriptions, you're probably spending **$80–300/month on AI** — and you can't say which client it went to, let alone put it on an invoice.
8
+
9
+ **devspend** reads your local agent logs and API usage, tags every dollar by project, and turns it into invoice-ready line items. 100% local. Zero cloud. Zero config.
10
+
11
+ ## Quickstart
12
+
13
+ ```bash
14
+ npx devspend report # spend by day (last 30d)
15
+ npx devspend projects # spend by project — the billing view
16
+ npx devspend models # spend by model
17
+ ```
18
+
19
+ It reads Claude Code session logs from `~/.claude/projects` by default. Point it anywhere:
20
+
21
+ ```bash
22
+ npx devspend projects --path /some/other/log/dir
23
+ ```
24
+
25
+ ## How billing-accurate pricing works
26
+
27
+ Token costs include cache pricing (cache writes ≈ 1.25× input, cache reads ≈ 0.1× input), and prices are fully overridable at `~/.devspend/prices.json`:
28
+
29
+ ```json
30
+ [{ "match": "claude-sonnet", "input": 3, "output": 15 }]
31
+ ```
32
+
33
+ Default prices ship built-in as sane estimates — **calibrate them to your plan before invoicing** (subscription plans like Claude Max don't pay per-token rates).
34
+
35
+ ## Roadmap
36
+
37
+ - **Phase 1** — API usage ingest (OpenRouter / OpenAI / Anthropic, read-only keys), subscription entries, project aliases, local web dashboard
38
+ - **Phase 2 (Pro)** — client entities, invoice-grade exports (CSV/PDF), budget alerts, optional encrypted sync. One-time license, no subscription.
39
+
40
+ ## How is this different from ccusage?
41
+
42
+ [ccusage](https://github.com/ryoppippi/ccusage) is an excellent free CLI for Claude Code usage stats — this project stands on its shoulders for the log format. devspend is built one layer up, for **billing**: multi-source ingest, project/client attribution, and invoice-ready exports. Use both if you like; devspend will always keep the CLI free.
43
+
44
+ ## Privacy
45
+
46
+ Everything runs on your machine. No telemetry, no accounts, no cloud. Open core (MIT); Pro adds billing workflows, not locks on your data.
47
+
48
+ ## License
49
+
50
+ MIT
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ // devspend — 你的 AI 开发开销账本
3
+ // 用法:
4
+ // npx devspend report [--path 日志目录] [--days 30] 按日汇总
5
+ // npx devspend projects [--path 日志目录] 按项目汇总(可计费归集的基础)
6
+ // npx devspend models [--path 日志目录] 按模型汇总
7
+ // npx devspend prices 查看当前价格表
8
+ import { defaultClaudeDir, collectUsage } from '../src/ingest/claude-code.mjs';
9
+ import { loadPrices, DEFAULT_PRICES } from '../src/pricing.mjs';
10
+ import { reportByDay, reportByProject, reportByModel } from '../src/report.mjs';
11
+
12
+ function parseArgs(argv) {
13
+ const out = { _: [] };
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const a = argv[i];
16
+ if (a === '--path') out.path = argv[++i];
17
+ else if (a === '--days') out.days = parseInt(argv[++i], 10);
18
+ else out._.push(a);
19
+ }
20
+ return out;
21
+ }
22
+
23
+ function help() {
24
+ console.log(`devspend — 你的 AI 开发开销账本
25
+
26
+ 命令:
27
+ report [--path 目录] [--days N] 按日汇总 Claude Code 用量与成本
28
+ projects [--path 目录] 按项目路径汇总(计费归集的基础)
29
+ models [--path 目录] 按模型汇总
30
+ prices 打印当前生效的价格表
31
+
32
+ 默认读取 ~/.claude/projects 下的会话日志,一切数据只在本机处理。`);
33
+ }
34
+
35
+ const args = parseArgs(process.argv.slice(2));
36
+ const cmd = args._[0] || 'report';
37
+
38
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
39
+ help();
40
+ } else if (cmd === 'prices') {
41
+ console.log('当前价格表(USD / 百万 token,用户覆盖优先):');
42
+ for (const p of loadPrices().list) console.log(` ${String(p.match)} in=$${p.input} out=$${p.output}`);
43
+ console.log(`\n内置默认 ${DEFAULT_PRICES.length} 条,可在 ~/.devspend/prices.json 覆盖。`);
44
+ } else if (cmd === 'report' || cmd === 'projects' || cmd === 'models') {
45
+ const dir = args.path || defaultClaudeDir();
46
+ const prices = loadPrices();
47
+ const rows = collectUsage(dir);
48
+ if (cmd === 'report') reportByDay(rows, prices, args.days || 30);
49
+ if (cmd === 'projects') reportByProject(rows, prices);
50
+ if (cmd === 'models') reportByModel(rows, prices);
51
+ } else {
52
+ console.error(`未知命令: ${cmd}`);
53
+ help();
54
+ process.exit(1);
55
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "devspend",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "One ledger for your AI dev spend — agent logs, API usage, subscriptions. Tagged by project, ready to bill.",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zhx842htt/devspend.git"
9
+ },
10
+ "homepage": "https://github.com/zhx842htt/devspend#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/zhx842htt/devspend/issues"
13
+ },
14
+ "bin": {
15
+ "devspend": "bin/devspend.mjs"
16
+ },
17
+ "engines": {
18
+ "node": ">=16"
19
+ },
20
+ "scripts": {
21
+ "test": "node bin/devspend.mjs report --path test/fixtures && node bin/devspend.mjs projects --path test/fixtures"
22
+ },
23
+ "keywords": [
24
+ "ai",
25
+ "cost",
26
+ "spending",
27
+ "claude",
28
+ "claude-code",
29
+ "cursor",
30
+ "openai",
31
+ "openrouter",
32
+ "invoice",
33
+ "freelance"
34
+ ],
35
+ "author": "",
36
+ "license": "MIT",
37
+ "files": [
38
+ "bin",
39
+ "src",
40
+ "README.md"
41
+ ]
42
+ }
@@ -0,0 +1,74 @@
1
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ // Claude Code 的本地会话日志: ~/.claude/projects/<project-slug>/<sessionId>.jsonl
6
+ // 已知格式(以 ccusage 等工具的解析口径为准):
7
+ // 每行一个 JSON,assistant 消息形如
8
+ // { type:"assistant", timestamp, sessionId, cwd,
9
+ // message:{ id, model, usage:{ input_tokens, output_tokens,
10
+ // cache_creation_input_tokens, cache_read_input_tokens } } }
11
+ // 字段在不同版本间有改名(cache_creation -> cache_creation_input_tokens),此处两个口径都收。
12
+
13
+ export function defaultClaudeDir() {
14
+ return join(homedir(), '.claude', 'projects');
15
+ }
16
+
17
+ export function* jsonlEntries(dir) {
18
+ if (!existsSync(dir)) return;
19
+ const jsonlFiles = [];
20
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
21
+ if (entry.isDirectory()) {
22
+ // Claude Code 原生布局: <dir>/<project-slug>/<sessionId>.jsonl
23
+ for (const f of readdirSync(join(dir, entry.name))) {
24
+ if (f.endsWith('.jsonl')) jsonlFiles.push(join(dir, entry.name, f));
25
+ }
26
+ } else if (entry.name.endsWith('.jsonl')) {
27
+ // 平铺布局: <dir>/*.jsonl(方便用户直接指定任意目录)
28
+ jsonlFiles.push(join(dir, entry.name));
29
+ }
30
+ }
31
+ for (const full of jsonlFiles) {
32
+ const text = readFileSync(full, 'utf8');
33
+ for (const line of text.split('\n')) {
34
+ const t = line.trim();
35
+ if (!t) continue;
36
+ let entry;
37
+ try {
38
+ entry = JSON.parse(t);
39
+ } catch {
40
+ continue; // 跳过截断/损坏行
41
+ }
42
+ yield entry;
43
+ }
44
+ }
45
+ }
46
+
47
+ // 汇总成统一的 usage 行;按 message id + 时间戳去重(流式写入可能产生重复行)
48
+ export function collectUsage(dir) {
49
+ const rows = [];
50
+ const seen = new Set();
51
+ for (const e of jsonlEntries(dir)) {
52
+ const u = e && e.message && e.message.usage;
53
+ if (!u || !e.timestamp) continue;
54
+ // message.id 全局唯一,以它为主键去重(流式/重放会产生重复行);
55
+ // 没有 id 的旧格式行退回到"时间戳+会话+用量指纹"去重
56
+ const fingerprint = `${u.input_tokens}|${u.output_tokens}|${u.cache_creation_input_tokens || 0}|${u.cache_read_input_tokens || 0}`;
57
+ const key = e.message.id || `${e.timestamp}|${e.sessionId || ''}|${fingerprint}`;
58
+ if (seen.has(key)) continue;
59
+ seen.add(key);
60
+ const usage = u || {};
61
+ const cacheObj = usage.cache_creation || {};
62
+ rows.push({
63
+ ts: new Date(e.timestamp),
64
+ sessionId: e.sessionId || 'unknown',
65
+ project: e.cwd || '(未知项目)',
66
+ model: (e.message && e.message.model) || 'unknown',
67
+ input: usage.input_tokens || 0,
68
+ output: usage.output_tokens || 0,
69
+ cacheWrite: usage.cache_creation_input_tokens ?? cacheObj.input_tokens ?? 0,
70
+ cacheRead: usage.cache_read_input_tokens ?? (usage.cache_read || {}).input_tokens ?? 0,
71
+ });
72
+ }
73
+ return rows;
74
+ }
@@ -0,0 +1,51 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ // 出厂默认价(USD / 每百万 token)。各家调价频繁,用户务必用 ~/.devspend/prices.json 覆盖校准。
6
+ // 覆盖格式:[{ "match": "claude-sonnet", "input": 3, "output": 15 }]
7
+ export const DEFAULT_PRICES = [
8
+ { match: /claude.*opus/i, input: 15, output: 75 },
9
+ { match: /claude.*(sonnet|4-5)/i, input: 3, output: 15 },
10
+ { match: /claude.*haiku/i, input: 0.8, output: 4 },
11
+ { match: /gpt-5/i, input: 1.25, output: 10 },
12
+ { match: /gpt-4o-mini/i, input: 0.15, output: 0.6 },
13
+ { match: /gpt-4o/i, input: 2.5, output: 10 },
14
+ { match: /gpt-4\.1/i, input: 2.5, output: 10 },
15
+ ];
16
+
17
+ const CACHE_READ_MULTIPLIER = 0.1; // 缓存读约为输入价 1 折
18
+ const CACHE_WRITE_MULTIPLIER = 1.25; // 缓存写约为输入价 1.25 倍
19
+
20
+ export function loadPrices() {
21
+ const overrides = [];
22
+ const overridePath = join(homedir(), '.devspend', 'prices.json');
23
+ if (existsSync(overridePath)) {
24
+ try {
25
+ const parsed = JSON.parse(readFileSync(overridePath, 'utf8'));
26
+ for (const o of parsed) {
27
+ overrides.push({ match: new RegExp(o.match, 'i'), input: o.input, output: o.output });
28
+ }
29
+ } catch {
30
+ console.error(`[devspend] 警告: ${overridePath} 解析失败,已忽略,使用默认价格表`);
31
+ }
32
+ }
33
+ // 用户覆盖优先于默认
34
+ return { list: [...overrides, ...DEFAULT_PRICES] };
35
+ }
36
+
37
+ export function costOf(model, usage, prices) {
38
+ const rule = prices.list.find((p) => p.match.test(model));
39
+ if (!rule) return { cost: 0, unknown: true };
40
+ const cacheWrite = usage.cacheWrite || 0;
41
+ const cacheRead = usage.cacheRead || 0;
42
+ const billableInput =
43
+ (usage.input || 0) +
44
+ cacheWrite * CACHE_WRITE_MULTIPLIER +
45
+ cacheRead * CACHE_READ_MULTIPLIER;
46
+ const billableOutput = usage.output || 0;
47
+ return {
48
+ cost: (billableInput * rule.input + billableOutput * rule.output) / 1e6,
49
+ unknown: false,
50
+ };
51
+ }
package/src/report.mjs ADDED
@@ -0,0 +1,82 @@
1
+ import { costOf } from './pricing.mjs';
2
+
3
+ function fmtUSD(n) {
4
+ if (n === 0) return '$0';
5
+ if (n < 0.01) return '$' + n.toFixed(4);
6
+ if (n < 1) return '$' + n.toFixed(3);
7
+ return '$' + n.toFixed(2);
8
+ }
9
+
10
+ function sumBy(rows, keyFn, prices) {
11
+ const map = new Map();
12
+ const unknownModels = new Set();
13
+ for (const r of rows) {
14
+ const { cost, unknown } = costOf(r.model, r, prices);
15
+ if (unknown) unknownModels.add(r.model);
16
+ const key = keyFn(r);
17
+ const cur = map.get(key) || { cost: 0, rows: 0, tokensIn: 0, tokensOut: 0 };
18
+ cur.cost += cost;
19
+ cur.rows += 1;
20
+ cur.tokensIn += r.input + r.cacheWrite + r.cacheRead;
21
+ cur.tokensOut += r.output;
22
+ map.set(key, cur);
23
+ }
24
+ return { map, unknownModels };
25
+ }
26
+
27
+ function printTable(title, entries, unit) {
28
+ console.log(`\n${title}`);
29
+ const w = [28, 12, 14, 16];
30
+ console.log(
31
+ [unit.padEnd(w[0]), '花费'.padStart(w[1]), '输入tok'.padStart(w[2]), '输出tok'.padStart(w[3])].join('')
32
+ );
33
+ let total = 0;
34
+ for (const [key, v] of entries) {
35
+ total += v.cost;
36
+ console.log(
37
+ [
38
+ key.slice(0, w[0]).padEnd(w[0]),
39
+ fmtUSD(v.cost).padStart(w[1]),
40
+ (v.tokensIn >= 1e6 ? (v.tokensIn / 1e6).toFixed(1) + 'M' : Math.round(v.tokensIn / 1e3) + 'K').padStart(w[2]),
41
+ (v.tokensOut >= 1e6 ? (v.tokensOut / 1e6).toFixed(1) + 'M' : Math.round(v.tokensOut / 1e3) + 'K').padStart(w[3]),
42
+ ].join('')
43
+ );
44
+ }
45
+ console.log('─'.repeat(w[0] + w[1] + w[2] + w[3]));
46
+ console.log(`${'合计'.padEnd(w[0])}${fmtUSD(total).padStart(w[1])}`);
47
+ return total;
48
+ }
49
+
50
+ export function reportByDay(rows, prices, days) {
51
+ const cutoff = Date.now() - days * 86400 * 1000;
52
+ const recent = rows.filter((r) => r.ts.getTime() >= cutoff);
53
+ if (recent.length === 0) {
54
+ console.log(`近 ${days} 天没有找到任何用量记录。`);
55
+ console.log('提示: 用 --path 指定日志目录,或先运行 Claude Code 产生本地日志。');
56
+ return;
57
+ }
58
+ const { map, unknownModels } = sumBy(recent, (r) => r.ts.toISOString().slice(0, 10), prices);
59
+ const total = printTable(`devspend · 近 ${days} 天按日汇总`, [...map.entries()].sort(), '日期');
60
+ printModelWarning(unknownModels);
61
+ console.log(`\n(价格为出厂估算,可在 ~/.devspend/prices.json 覆盖;本期合计 ${fmtUSD(total)})`);
62
+ }
63
+
64
+ export function reportByProject(rows, prices) {
65
+ const { map, unknownModels } = sumBy(rows, (r) => r.project, prices);
66
+ const entries = [...map.entries()].sort((a, b) => b[1].cost - a[1].cost);
67
+ printTable('devspend · 按项目汇总(全量)', entries, '项目路径');
68
+ printModelWarning(unknownModels);
69
+ }
70
+
71
+ export function reportByModel(rows, prices) {
72
+ const { map } = sumBy(rows, (r) => r.model, prices);
73
+ const entries = [...map.entries()].sort((a, b) => b[1].cost - a[1].cost);
74
+ printTable('devspend · 按模型汇总(全量)', entries, '模型');
75
+ }
76
+
77
+ function printModelWarning(unknownModels) {
78
+ if (unknownModels.size > 0) {
79
+ console.log(`\n⚠ 未知价格模型的 token 未计价: ${[...unknownModels].join(', ')}`);
80
+ console.log(' 请在 ~/.devspend/prices.json 里为它们添加价格规则。');
81
+ }
82
+ }