agent-spend-guard 0.1.0-alpha.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 +21 -0
- package/README.md +97 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +179 -0
- package/dist/guard.d.ts +1 -0
- package/dist/guard.js +129 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +3 -0
- package/dist/pricing.d.ts +1 -0
- package/dist/pricing.js +45 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.js +1 -0
- package/dist/usage.d.ts +1 -0
- package/dist/usage.js +132 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bhaskar Pandey
|
|
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
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# agent-spend-guard
|
|
2
|
+
|
|
3
|
+
**Token budgets + a kill-switch for AI coding agents. Know what you're burning; stop it before the invoice does.**
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx agent-spend-guard # spend vs budgets, right now
|
|
7
|
+
npx agent-spend-guard init # drop a .spend-guard.json to set limits
|
|
8
|
+
npx agent-spend-guard check # exit 1 when over budget — wire into hooks/CI
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Read-only. Zero dependencies. Nothing leaves your machine.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Why
|
|
16
|
+
|
|
17
|
+
2026 is the year the token bill came due: teams blowing entire annual AI budgets
|
|
18
|
+
by April, "3x over budget" panic, licenses getting revoked. Dashboards tell you
|
|
19
|
+
what you spent *after the fact* — nothing indie-sized actually **enforces** a
|
|
20
|
+
limit. This does:
|
|
21
|
+
|
|
22
|
+
- **Meter**: parses real usage — Claude Code transcripts (`~/.claude/projects`)
|
|
23
|
+
and generic JSON event files from any agent/proxy — and prices it with a
|
|
24
|
+
built-in (overridable) price table.
|
|
25
|
+
- **Budget**: daily / monthly / per-project USD ceilings in `.spend-guard.json`,
|
|
26
|
+
with a warn threshold (default 80%).
|
|
27
|
+
- **Kill-switch**: `check` exits 1 the moment a ceiling is crossed, so a hook or
|
|
28
|
+
CI gate can stop the agent instead of emailing you next month.
|
|
29
|
+
|
|
30
|
+
## Wiring the kill-switch
|
|
31
|
+
|
|
32
|
+
**Claude Code** — stop tool calls when over budget (`.claude/settings.json`):
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"hooks": {
|
|
37
|
+
"PreToolUse": [
|
|
38
|
+
{ "matcher": "Bash",
|
|
39
|
+
"hooks": [{ "type": "command", "command": "npx agent-spend-guard check --cwd ." }] }
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**CI** — fail the pipeline before it gets expensive:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npx agent-spend-guard check --strict # exit 1 on warn too
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Config
|
|
52
|
+
|
|
53
|
+
`.spend-guard.json` in your project (create with `init`):
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"budgets": {
|
|
58
|
+
"dailyUsd": 25,
|
|
59
|
+
"monthlyUsd": 300,
|
|
60
|
+
"perProjectUsd": { "my-app": 100 },
|
|
61
|
+
"warnAtPct": 0.8
|
|
62
|
+
},
|
|
63
|
+
"prices": {
|
|
64
|
+
"claude-sonnet": { "input": 3, "output": 15 }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Prices are USD per **million** tokens, matched by longest model-id prefix;
|
|
70
|
+
built-ins cover Claude / GPT / Gemini families with a conservative fallback.
|
|
71
|
+
They aim to be budget-accurate, not invoice-accurate — override freely.
|
|
72
|
+
|
|
73
|
+
## Commands & options
|
|
74
|
+
|
|
75
|
+
| | |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `status` (default) | spend summary + budget lines |
|
|
78
|
+
| `check` | same, but exit 1 on STOP (with `--strict`, on WARN too) |
|
|
79
|
+
| `init` | write a sample `.spend-guard.json` |
|
|
80
|
+
| `--usage <file>` | add a `.jsonl` transcript or JSON event array (repeatable) |
|
|
81
|
+
| `--no-user` | skip `~/.claude/projects` scan (hermetic/CI) |
|
|
82
|
+
| `--json` | machine-readable report |
|
|
83
|
+
| `--verbose` | per-project breakdown |
|
|
84
|
+
|
|
85
|
+
## Sibling tools (AI dev hygiene suite)
|
|
86
|
+
|
|
87
|
+
[`ai-instruct-sync`](https://www.npmjs.com/package/ai-instruct-sync) · [`ai-setup-doctor`](https://www.npmjs.com/package/ai-setup-doctor) · [`mcp-config-sync`](https://www.npmjs.com/package/mcp-config-sync) · `agent-skill-scan`
|
|
88
|
+
|
|
89
|
+
## Honest limits
|
|
90
|
+
|
|
91
|
+
Estimates use list prices and parsed transcripts — subscription plans, provider
|
|
92
|
+
discounts, and non-transcript usage aren't visible to it. The kill-switch stops
|
|
93
|
+
what's wired through it (hooks/CI); it cannot reach into a provider account.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { CONFIG_FILENAME, buildReport, loadConfig, sampleConfig } from "./guard.js";
|
|
7
|
+
import { collectClaudeCodeUsage, parseClaudeCodeJsonl, parseGenericEvents } from "./usage.js";
|
|
8
|
+
const useColor = process.stdout.isTTY && !("NO_COLOR" in process.env);
|
|
9
|
+
const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
10
|
+
const green = paint(32);
|
|
11
|
+
const yellow = paint(33);
|
|
12
|
+
const red = paint(31);
|
|
13
|
+
const cyan = paint(36);
|
|
14
|
+
const dim = paint(2);
|
|
15
|
+
const bold = paint(1);
|
|
16
|
+
function verdictLabel(v) {
|
|
17
|
+
if (v === "stop")
|
|
18
|
+
return red(bold("STOP"));
|
|
19
|
+
if (v === "warn")
|
|
20
|
+
return yellow("WARN");
|
|
21
|
+
return green("OK");
|
|
22
|
+
}
|
|
23
|
+
function usd(n) {
|
|
24
|
+
return `$${n.toFixed(2)}`;
|
|
25
|
+
}
|
|
26
|
+
function printReport(report, verbose) {
|
|
27
|
+
console.log(bold("agent-spend-guard") + dim(` — ${report.cwd}`));
|
|
28
|
+
console.log(dim(`Node ${report.node} · ${report.platform} · ${report.checkedAt} · ${report.summary.events} usage events`));
|
|
29
|
+
console.log();
|
|
30
|
+
const s = report.summary;
|
|
31
|
+
console.log(` ${bold("Spend")} ${usd(s.totalUsd)} ${dim(`· ${s.totalTokens.toLocaleString()} tokens across ${s.events} calls`)}`);
|
|
32
|
+
const models = Object.entries(s.byModel).sort((a, b) => b[1].usd - a[1].usd).slice(0, 5);
|
|
33
|
+
for (const [model, m] of models) {
|
|
34
|
+
console.log(dim(` ${model} ${usd(m.usd)} · ${m.tokens.toLocaleString()} tok · ${m.calls} calls`));
|
|
35
|
+
}
|
|
36
|
+
if (verbose) {
|
|
37
|
+
const projects = Object.entries(s.byProject).sort((a, b) => b[1].usd - a[1].usd).slice(0, 8);
|
|
38
|
+
for (const [project, p] of projects) {
|
|
39
|
+
console.log(dim(` ${project} ${usd(p.usd)} · ${p.calls} calls`));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
console.log();
|
|
43
|
+
if (report.lines.length === 0) {
|
|
44
|
+
console.log(dim(` No budgets configured — create ${CONFIG_FILENAME} (try: agent-spend-guard init)`));
|
|
45
|
+
}
|
|
46
|
+
for (const l of report.lines) {
|
|
47
|
+
const bar = l.pct >= 1 ? red : l.pct >= 0.8 ? yellow : green;
|
|
48
|
+
console.log(` ${verdictLabel(l.verdict)} ${bold(l.scope)} ${bar(usd(l.spentUsd))} / ${usd(l.limitUsd)} ${dim(`(${Math.round(l.pct * 100)}%)`)}`);
|
|
49
|
+
}
|
|
50
|
+
console.log();
|
|
51
|
+
if (report.verdict === "stop") {
|
|
52
|
+
console.log(red(bold("Budget exceeded — kill-switch active (exit 1). Agents should stop.")));
|
|
53
|
+
}
|
|
54
|
+
else if (report.verdict === "warn") {
|
|
55
|
+
console.log(yellow("Approaching budget — consider smaller models or a pause."));
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
console.log(green("Within budget."));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function printHelp() {
|
|
62
|
+
console.log(`${bold("agent-spend-guard")} — token budgets + kill-switch for AI coding agents
|
|
63
|
+
|
|
64
|
+
Reads real usage (Claude Code transcripts, generic event files), prices it,
|
|
65
|
+
and enforces daily / monthly / per-project USD budgets. Read-only; nothing
|
|
66
|
+
leaves your machine.
|
|
67
|
+
|
|
68
|
+
Usage
|
|
69
|
+
agent-spend-guard [command] [options]
|
|
70
|
+
|
|
71
|
+
Commands
|
|
72
|
+
status Show spend vs budgets (default)
|
|
73
|
+
check Same as status but built for hooks/CI: exit 1 on STOP
|
|
74
|
+
init Write a sample ${CONFIG_FILENAME} to --cwd
|
|
75
|
+
help Show this help
|
|
76
|
+
|
|
77
|
+
Options
|
|
78
|
+
--cwd <dir> Project directory holding ${CONFIG_FILENAME} (default: .)
|
|
79
|
+
--usage <file> Add a usage file (repeatable): .jsonl transcript or JSON event array
|
|
80
|
+
--no-user Skip scanning ~/.claude/projects transcripts
|
|
81
|
+
--json Machine-readable report
|
|
82
|
+
--strict check: exit 1 on WARN as well as STOP
|
|
83
|
+
--verbose, -v Per-project breakdown
|
|
84
|
+
|
|
85
|
+
Kill-switch wiring
|
|
86
|
+
Claude Code hook (settings.json → hooks.PreToolUse):
|
|
87
|
+
{ "type": "command", "command": "npx agent-spend-guard check --cwd ." }
|
|
88
|
+
CI gate:
|
|
89
|
+
npx agent-spend-guard check --strict
|
|
90
|
+
|
|
91
|
+
Config (${CONFIG_FILENAME})
|
|
92
|
+
{ "budgets": { "dailyUsd": 25, "monthlyUsd": 300,
|
|
93
|
+
"perProjectUsd": { "my-app": 100 }, "warnAtPct": 0.8 },
|
|
94
|
+
"prices": { "claude-sonnet": { "input": 3, "output": 15 } } }
|
|
95
|
+
|
|
96
|
+
Exit codes
|
|
97
|
+
0 ok/warn (status) · 1 stop (check; warn too with --strict) · 2 usage error
|
|
98
|
+
`);
|
|
99
|
+
}
|
|
100
|
+
function loadUsageFile(path) {
|
|
101
|
+
const text = readFileSync(path, "utf8");
|
|
102
|
+
if (path.endsWith(".jsonl"))
|
|
103
|
+
return parseClaudeCodeJsonl(text);
|
|
104
|
+
const generic = parseGenericEvents(text);
|
|
105
|
+
if (generic.length > 0)
|
|
106
|
+
return generic;
|
|
107
|
+
return parseClaudeCodeJsonl(text);
|
|
108
|
+
}
|
|
109
|
+
function main() {
|
|
110
|
+
const { values, positionals } = parseArgs({
|
|
111
|
+
args: process.argv.slice(2),
|
|
112
|
+
options: {
|
|
113
|
+
cwd: { type: "string" },
|
|
114
|
+
usage: { type: "string", multiple: true },
|
|
115
|
+
json: { type: "boolean", default: false },
|
|
116
|
+
strict: { type: "boolean", default: false },
|
|
117
|
+
verbose: { type: "boolean", default: false, short: "v" },
|
|
118
|
+
"no-user": { type: "boolean", default: false },
|
|
119
|
+
help: { type: "boolean", default: false },
|
|
120
|
+
},
|
|
121
|
+
allowPositionals: true,
|
|
122
|
+
strict: false,
|
|
123
|
+
});
|
|
124
|
+
const cmd = positionals[0] ?? "status";
|
|
125
|
+
if (values.help || cmd === "help") {
|
|
126
|
+
printHelp();
|
|
127
|
+
process.exit(0);
|
|
128
|
+
}
|
|
129
|
+
const cwd = resolve(typeof values.cwd === "string" ? values.cwd : process.cwd());
|
|
130
|
+
if (cmd === "init") {
|
|
131
|
+
const path = join(cwd, CONFIG_FILENAME);
|
|
132
|
+
if (existsSync(path)) {
|
|
133
|
+
console.error(yellow(`${CONFIG_FILENAME} already exists at ${cwd} — not overwriting.`));
|
|
134
|
+
process.exit(2);
|
|
135
|
+
}
|
|
136
|
+
writeFileSync(path, JSON.stringify(sampleConfig(), null, 2) + "\n");
|
|
137
|
+
console.log(green(`Wrote ${path}`));
|
|
138
|
+
console.log(dim("Edit the budgets, then run: agent-spend-guard status"));
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}
|
|
141
|
+
if (cmd !== "status" && cmd !== "check") {
|
|
142
|
+
console.error(red(`Unknown command: ${cmd}`));
|
|
143
|
+
printHelp();
|
|
144
|
+
process.exit(2);
|
|
145
|
+
}
|
|
146
|
+
const config = loadConfig(cwd);
|
|
147
|
+
const events = [];
|
|
148
|
+
const usageFiles = Array.isArray(values.usage) ? values.usage : [];
|
|
149
|
+
for (const f of usageFiles) {
|
|
150
|
+
if (typeof f !== "string")
|
|
151
|
+
continue;
|
|
152
|
+
try {
|
|
153
|
+
events.push(...loadUsageFile(resolve(f)));
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
console.error(red(`Cannot read usage file: ${f}`));
|
|
157
|
+
process.exit(2);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (values["no-user"] !== true) {
|
|
161
|
+
events.push(...collectClaudeCodeUsage());
|
|
162
|
+
}
|
|
163
|
+
const report = buildReport(cwd, events, config);
|
|
164
|
+
if (values.json === true) {
|
|
165
|
+
console.log(JSON.stringify(report, null, 2));
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
printReport(report, values.verbose === true);
|
|
169
|
+
}
|
|
170
|
+
let exit = 0;
|
|
171
|
+
if (cmd === "check") {
|
|
172
|
+
if (report.verdict === "stop")
|
|
173
|
+
exit = 1;
|
|
174
|
+
if (values.strict === true && report.verdict === "warn")
|
|
175
|
+
exit = 1;
|
|
176
|
+
}
|
|
177
|
+
process.exit(exit);
|
|
178
|
+
}
|
|
179
|
+
main();
|
package/dist/guard.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./index.js";
|
package/dist/guard.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { costOf } from "./pricing.js";
|
|
4
|
+
export const CONFIG_FILENAME = ".spend-guard.json";
|
|
5
|
+
export function loadConfig(cwd) {
|
|
6
|
+
const path = join(cwd, CONFIG_FILENAME);
|
|
7
|
+
if (!existsSync(path))
|
|
8
|
+
return { budgets: {} };
|
|
9
|
+
try {
|
|
10
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
11
|
+
return { budgets: raw.budgets ?? {}, prices: raw.prices };
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return { budgets: {} };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function sampleConfig() {
|
|
18
|
+
return {
|
|
19
|
+
budgets: {
|
|
20
|
+
dailyUsd: 25,
|
|
21
|
+
monthlyUsd: 300,
|
|
22
|
+
perProjectUsd: { "my-app": 100 },
|
|
23
|
+
warnAtPct: 0.8,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function dayOf(ts) {
|
|
28
|
+
return ts.slice(0, 10) || "unknown";
|
|
29
|
+
}
|
|
30
|
+
function monthOf(ts) {
|
|
31
|
+
return ts.slice(0, 7) || "unknown";
|
|
32
|
+
}
|
|
33
|
+
export function summarize(events, config) {
|
|
34
|
+
const byDay = {};
|
|
35
|
+
const byModel = {};
|
|
36
|
+
const byProject = {};
|
|
37
|
+
let totalTokens = 0;
|
|
38
|
+
let totalUsd = 0;
|
|
39
|
+
for (const e of events) {
|
|
40
|
+
const usd = costOf(e, config?.prices);
|
|
41
|
+
const tokens = e.inputTokens + e.outputTokens + (e.cacheReadTokens ?? 0) + (e.cacheWriteTokens ?? 0);
|
|
42
|
+
totalTokens += tokens;
|
|
43
|
+
totalUsd += usd;
|
|
44
|
+
const day = dayOf(e.ts);
|
|
45
|
+
byDay[day] = (byDay[day] ?? 0) + usd;
|
|
46
|
+
const model = e.model || "unknown";
|
|
47
|
+
byModel[model] ??= { tokens: 0, usd: 0, calls: 0 };
|
|
48
|
+
byModel[model].tokens += tokens;
|
|
49
|
+
byModel[model].usd += usd;
|
|
50
|
+
byModel[model].calls += 1;
|
|
51
|
+
const project = e.project ?? "(unattributed)";
|
|
52
|
+
byProject[project] ??= { tokens: 0, usd: 0, calls: 0 };
|
|
53
|
+
byProject[project].tokens += tokens;
|
|
54
|
+
byProject[project].usd += usd;
|
|
55
|
+
byProject[project].calls += 1;
|
|
56
|
+
}
|
|
57
|
+
return { events: events.length, totalTokens, totalUsd, byDay, byModel, byProject };
|
|
58
|
+
}
|
|
59
|
+
function verdictFor(spent, limit, warnAt) {
|
|
60
|
+
if (limit <= 0)
|
|
61
|
+
return "ok";
|
|
62
|
+
if (spent >= limit)
|
|
63
|
+
return "stop";
|
|
64
|
+
if (spent >= limit * warnAt)
|
|
65
|
+
return "warn";
|
|
66
|
+
return "ok";
|
|
67
|
+
}
|
|
68
|
+
const VERDICT_RANK = { ok: 0, warn: 1, stop: 2 };
|
|
69
|
+
/**
|
|
70
|
+
* Evaluate budgets against usage. `now` is injectable for tests.
|
|
71
|
+
* Missing budgets simply produce no lines — the guard never invents limits.
|
|
72
|
+
*/
|
|
73
|
+
export function evaluateBudgets(events, budgets, config, now = new Date()) {
|
|
74
|
+
const warnAt = budgets.warnAtPct ?? 0.8;
|
|
75
|
+
const lines = [];
|
|
76
|
+
const today = now.toISOString().slice(0, 10);
|
|
77
|
+
const thisMonth = now.toISOString().slice(0, 7);
|
|
78
|
+
if (budgets.dailyUsd !== undefined) {
|
|
79
|
+
const spent = events
|
|
80
|
+
.filter((e) => dayOf(e.ts) === today)
|
|
81
|
+
.reduce((s, e) => s + costOf(e, config?.prices), 0);
|
|
82
|
+
lines.push({
|
|
83
|
+
scope: `today (${today})`,
|
|
84
|
+
spentUsd: spent,
|
|
85
|
+
limitUsd: budgets.dailyUsd,
|
|
86
|
+
pct: budgets.dailyUsd > 0 ? spent / budgets.dailyUsd : 0,
|
|
87
|
+
verdict: verdictFor(spent, budgets.dailyUsd, warnAt),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (budgets.monthlyUsd !== undefined) {
|
|
91
|
+
const spent = events
|
|
92
|
+
.filter((e) => monthOf(e.ts) === thisMonth)
|
|
93
|
+
.reduce((s, e) => s + costOf(e, config?.prices), 0);
|
|
94
|
+
lines.push({
|
|
95
|
+
scope: `month (${thisMonth})`,
|
|
96
|
+
spentUsd: spent,
|
|
97
|
+
limitUsd: budgets.monthlyUsd,
|
|
98
|
+
pct: budgets.monthlyUsd > 0 ? spent / budgets.monthlyUsd : 0,
|
|
99
|
+
verdict: verdictFor(spent, budgets.monthlyUsd, warnAt),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
for (const [project, limit] of Object.entries(budgets.perProjectUsd ?? {})) {
|
|
103
|
+
const spent = events
|
|
104
|
+
.filter((e) => (e.project ?? "") === project && monthOf(e.ts) === thisMonth)
|
|
105
|
+
.reduce((s, e) => s + costOf(e, config?.prices), 0);
|
|
106
|
+
lines.push({
|
|
107
|
+
scope: `project ${project} (${thisMonth})`,
|
|
108
|
+
spentUsd: spent,
|
|
109
|
+
limitUsd: limit,
|
|
110
|
+
pct: limit > 0 ? spent / limit : 0,
|
|
111
|
+
verdict: verdictFor(spent, limit, warnAt),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const verdict = lines.reduce((worst, l) => (VERDICT_RANK[l.verdict] > VERDICT_RANK[worst] ? l.verdict : worst), "ok");
|
|
115
|
+
return { lines, verdict };
|
|
116
|
+
}
|
|
117
|
+
export function buildReport(cwd, events, config, now = new Date()) {
|
|
118
|
+
const summary = summarize(events, config);
|
|
119
|
+
const { lines, verdict } = evaluateBudgets(events, config.budgets, config, now);
|
|
120
|
+
return {
|
|
121
|
+
cwd,
|
|
122
|
+
checkedAt: now.toISOString(),
|
|
123
|
+
node: process.version,
|
|
124
|
+
platform: process.platform,
|
|
125
|
+
summary,
|
|
126
|
+
lines,
|
|
127
|
+
verdict,
|
|
128
|
+
};
|
|
129
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type Verdict = "ok" | "warn" | "stop";
|
|
2
|
+
export interface UsageEvent {
|
|
3
|
+
ts: string;
|
|
4
|
+
model: string;
|
|
5
|
+
inputTokens: number;
|
|
6
|
+
outputTokens: number;
|
|
7
|
+
cacheReadTokens?: number;
|
|
8
|
+
cacheWriteTokens?: number;
|
|
9
|
+
project?: string;
|
|
10
|
+
source?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ModelPrice { input: number; output: number; cacheRead?: number; cacheWrite?: number; }
|
|
13
|
+
export interface Budgets {
|
|
14
|
+
dailyUsd?: number;
|
|
15
|
+
monthlyUsd?: number;
|
|
16
|
+
perProjectUsd?: Record<string, number>;
|
|
17
|
+
warnAtPct?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface GuardConfig { budgets: Budgets; prices?: Record<string, ModelPrice>; }
|
|
20
|
+
export interface SpendLine { scope: string; spentUsd: number; limitUsd: number; pct: number; verdict: Verdict; }
|
|
21
|
+
export interface SpendSummary {
|
|
22
|
+
events: number;
|
|
23
|
+
totalTokens: number;
|
|
24
|
+
totalUsd: number;
|
|
25
|
+
byDay: Record<string, number>;
|
|
26
|
+
byModel: Record<string, { tokens: number; usd: number; calls: number }>;
|
|
27
|
+
byProject: Record<string, { tokens: number; usd: number; calls: number }>;
|
|
28
|
+
}
|
|
29
|
+
export interface GuardReport {
|
|
30
|
+
cwd: string;
|
|
31
|
+
checkedAt: string;
|
|
32
|
+
node: string;
|
|
33
|
+
platform: string;
|
|
34
|
+
summary: SpendSummary;
|
|
35
|
+
lines: SpendLine[];
|
|
36
|
+
verdict: Verdict;
|
|
37
|
+
}
|
|
38
|
+
export declare const CONFIG_FILENAME: string;
|
|
39
|
+
export declare const DEFAULT_PRICES: Record<string, ModelPrice>;
|
|
40
|
+
export declare function priceFor(model: string, overrides?: Record<string, ModelPrice>): ModelPrice;
|
|
41
|
+
export declare function costOf(e: UsageEvent, overrides?: Record<string, ModelPrice>): number;
|
|
42
|
+
export declare function parseClaudeCodeJsonl(text: string, project?: string): UsageEvent[];
|
|
43
|
+
export declare function parseGenericEvents(text: string): UsageEvent[];
|
|
44
|
+
export declare function collectClaudeCodeUsage(maxFilesPerProject?: number): UsageEvent[];
|
|
45
|
+
export declare function loadConfig(cwd: string): GuardConfig;
|
|
46
|
+
export declare function sampleConfig(): GuardConfig;
|
|
47
|
+
export declare function summarize(events: UsageEvent[], config?: GuardConfig): SpendSummary;
|
|
48
|
+
export declare function evaluateBudgets(events: UsageEvent[], budgets: Budgets, config?: GuardConfig, now?: Date): { lines: SpendLine[]; verdict: Verdict };
|
|
49
|
+
export declare function buildReport(cwd: string, events: UsageEvent[], config: GuardConfig, now?: Date): GuardReport;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { CONFIG_FILENAME, buildReport, evaluateBudgets, loadConfig, sampleConfig, summarize, } from "./guard.js";
|
|
2
|
+
export { DEFAULT_PRICES, costOf, priceFor } from "./pricing.js";
|
|
3
|
+
export { collectClaudeCodeUsage, parseClaudeCodeJsonl, parseGenericEvents, } from "./usage.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./index.js";
|
package/dist/pricing.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approximate list prices per MILLION tokens (USD), mid-2026. Matched by
|
|
3
|
+
* longest model-id prefix; override any entry via .spend-guard.json `prices`.
|
|
4
|
+
* Budget math needs to be roughly right, not invoice-exact.
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_PRICES = {
|
|
7
|
+
"claude-opus": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
|
8
|
+
"claude-fable": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
|
9
|
+
"claude-sonnet": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
10
|
+
"claude-haiku": { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
|
|
11
|
+
"claude-3-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
12
|
+
"gpt-4o-mini": { input: 0.15, output: 0.6 },
|
|
13
|
+
"gpt-4o": { input: 2.5, output: 10 },
|
|
14
|
+
"gpt-4.1": { input: 2, output: 8 },
|
|
15
|
+
o3: { input: 2, output: 8 },
|
|
16
|
+
"gemini-1.5-pro": { input: 1.25, output: 5 },
|
|
17
|
+
"gemini-2": { input: 1.25, output: 5 },
|
|
18
|
+
// Conservative fallback for unknown models
|
|
19
|
+
default: { input: 5, output: 25 },
|
|
20
|
+
};
|
|
21
|
+
export function priceFor(model, overrides) {
|
|
22
|
+
const table = { ...DEFAULT_PRICES, ...(overrides ?? {}) };
|
|
23
|
+
const m = model.toLowerCase();
|
|
24
|
+
let best = null;
|
|
25
|
+
for (const prefix of Object.keys(table)) {
|
|
26
|
+
if (prefix === "default")
|
|
27
|
+
continue;
|
|
28
|
+
if (m.startsWith(prefix.toLowerCase()) && (!best || prefix.length > best.length)) {
|
|
29
|
+
best = prefix;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return table[best ?? "default"] ?? table.default;
|
|
33
|
+
}
|
|
34
|
+
/** Cost of one usage event in USD. */
|
|
35
|
+
export function costOf(e, overrides) {
|
|
36
|
+
const p = priceFor(e.model, overrides);
|
|
37
|
+
const perTokenIn = p.input / 1_000_000;
|
|
38
|
+
const perTokenOut = p.output / 1_000_000;
|
|
39
|
+
const perTokenCacheRead = (p.cacheRead ?? p.input * 0.1) / 1_000_000;
|
|
40
|
+
const perTokenCacheWrite = (p.cacheWrite ?? p.input * 1.25) / 1_000_000;
|
|
41
|
+
return (e.inputTokens * perTokenIn +
|
|
42
|
+
e.outputTokens * perTokenOut +
|
|
43
|
+
(e.cacheReadTokens ?? 0) * perTokenCacheRead +
|
|
44
|
+
(e.cacheWriteTokens ?? 0) * perTokenCacheWrite);
|
|
45
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./index.js";
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/usage.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./index.js";
|
package/dist/usage.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, basename } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Parse Claude Code transcript JSONL text into usage events. Each assistant
|
|
6
|
+
* line carries message.usage {input_tokens, output_tokens, cache_*} and
|
|
7
|
+
* message.model. Unknown/malformed lines are skipped — never throws.
|
|
8
|
+
*/
|
|
9
|
+
export function parseClaudeCodeJsonl(text, project) {
|
|
10
|
+
const out = [];
|
|
11
|
+
for (const line of text.split("\n")) {
|
|
12
|
+
const t = line.trim();
|
|
13
|
+
if (!t)
|
|
14
|
+
continue;
|
|
15
|
+
let obj;
|
|
16
|
+
try {
|
|
17
|
+
obj = JSON.parse(t);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (typeof obj !== "object" || obj === null)
|
|
23
|
+
continue;
|
|
24
|
+
const rec = obj;
|
|
25
|
+
const msg = rec.message;
|
|
26
|
+
if (!msg || typeof msg !== "object")
|
|
27
|
+
continue;
|
|
28
|
+
const usage = msg.usage;
|
|
29
|
+
if (!usage || typeof usage !== "object")
|
|
30
|
+
continue;
|
|
31
|
+
const inputTokens = Number(usage.input_tokens ?? 0);
|
|
32
|
+
const outputTokens = Number(usage.output_tokens ?? 0);
|
|
33
|
+
if (!Number.isFinite(inputTokens) || !Number.isFinite(outputTokens))
|
|
34
|
+
continue;
|
|
35
|
+
if (inputTokens === 0 && outputTokens === 0)
|
|
36
|
+
continue;
|
|
37
|
+
out.push({
|
|
38
|
+
ts: typeof rec.timestamp === "string" ? rec.timestamp : new Date(0).toISOString(),
|
|
39
|
+
model: typeof msg.model === "string" ? msg.model : "unknown",
|
|
40
|
+
inputTokens,
|
|
41
|
+
outputTokens,
|
|
42
|
+
cacheReadTokens: Number(usage.cache_read_input_tokens ?? 0) || 0,
|
|
43
|
+
cacheWriteTokens: Number(usage.cache_creation_input_tokens ?? 0) || 0,
|
|
44
|
+
project,
|
|
45
|
+
source: "claude-code",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Parse a generic usage-events JSON file: an array of
|
|
52
|
+
* { ts, model, inputTokens, outputTokens, project?, source? }.
|
|
53
|
+
* Lets any agent/proxy feed the guard.
|
|
54
|
+
*/
|
|
55
|
+
export function parseGenericEvents(text) {
|
|
56
|
+
let data;
|
|
57
|
+
try {
|
|
58
|
+
data = JSON.parse(text);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
if (!Array.isArray(data))
|
|
64
|
+
return [];
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const item of data) {
|
|
67
|
+
if (typeof item !== "object" || item === null)
|
|
68
|
+
continue;
|
|
69
|
+
const r = item;
|
|
70
|
+
const inputTokens = Number(r.inputTokens ?? r.input_tokens ?? 0);
|
|
71
|
+
const outputTokens = Number(r.outputTokens ?? r.output_tokens ?? 0);
|
|
72
|
+
if (!Number.isFinite(inputTokens) || !Number.isFinite(outputTokens))
|
|
73
|
+
continue;
|
|
74
|
+
out.push({
|
|
75
|
+
ts: typeof r.ts === "string" ? r.ts : new Date(0).toISOString(),
|
|
76
|
+
model: typeof r.model === "string" ? r.model : "unknown",
|
|
77
|
+
inputTokens,
|
|
78
|
+
outputTokens,
|
|
79
|
+
cacheReadTokens: Number(r.cacheReadTokens ?? 0) || 0,
|
|
80
|
+
cacheWriteTokens: Number(r.cacheWriteTokens ?? 0) || 0,
|
|
81
|
+
project: typeof r.project === "string" ? r.project : undefined,
|
|
82
|
+
source: typeof r.source === "string" ? r.source : "generic",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/** Decode Claude Code's project-dir naming (-Users-x-repo → /Users/x/repo). */
|
|
88
|
+
function projectNameFromDir(dir) {
|
|
89
|
+
const b = basename(dir);
|
|
90
|
+
return b.startsWith("-") ? b.slice(1).replace(/-/g, "/") : b;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Collect usage events from Claude Code transcripts under
|
|
94
|
+
* ~/.claude/projects/<project>/<session>.jsonl. Read-only; caps files per
|
|
95
|
+
* project to stay fast.
|
|
96
|
+
*/
|
|
97
|
+
export function collectClaudeCodeUsage(maxFilesPerProject = 20) {
|
|
98
|
+
const root = join(homedir(), ".claude", "projects");
|
|
99
|
+
if (!existsSync(root))
|
|
100
|
+
return [];
|
|
101
|
+
const out = [];
|
|
102
|
+
let projects;
|
|
103
|
+
try {
|
|
104
|
+
projects = readdirSync(root);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
for (const proj of projects) {
|
|
110
|
+
const dir = join(root, proj);
|
|
111
|
+
let files;
|
|
112
|
+
try {
|
|
113
|
+
if (!statSync(dir).isDirectory())
|
|
114
|
+
continue;
|
|
115
|
+
files = readdirSync(dir).filter((f) => f.endsWith(".jsonl")).slice(0, maxFilesPerProject);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const project = projectNameFromDir(dir);
|
|
121
|
+
for (const f of files) {
|
|
122
|
+
try {
|
|
123
|
+
const text = readFileSync(join(dir, f), "utf8");
|
|
124
|
+
out.push(...parseClaudeCodeJsonl(text, project));
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
/* skip unreadable */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-spend-guard",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Token budgets and a kill-switch for AI coding agents — reads Claude Code/Cursor usage, prices it, and enforces daily/monthly/per-project USD limits. Read-only, zero dependencies.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"agent-spend-guard": "dist/cli.js",
|
|
9
|
+
"spend-guard": "dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"main": "dist/index.js",
|
|
12
|
+
"types": "dist/index.d.ts",
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "node scripts/build.mjs",
|
|
23
|
+
"test": "npm run build && node --test tests/litmus.test.mjs",
|
|
24
|
+
"litmus": "node --test tests/litmus.test.mjs",
|
|
25
|
+
"test:quick": "node --test tests/litmus.test.mjs",
|
|
26
|
+
"prepublishOnly": "npm run build && npm test",
|
|
27
|
+
"demo": "node dist/cli.js status --no-user"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"ai",
|
|
31
|
+
"ai-agents",
|
|
32
|
+
"finops",
|
|
33
|
+
"tokens",
|
|
34
|
+
"budget",
|
|
35
|
+
"cost",
|
|
36
|
+
"kill-switch",
|
|
37
|
+
"claude",
|
|
38
|
+
"cursor",
|
|
39
|
+
"cli"
|
|
40
|
+
],
|
|
41
|
+
"author": "Bhaskar Pandey <bhaskarauthor@gmail.com>",
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^20.14.0",
|
|
45
|
+
"typescript": "^5.5.0"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
}
|
|
50
|
+
}
|