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 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();
@@ -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
+ }
@@ -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";
@@ -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
+ }
@@ -0,0 +1 @@
1
+ export * from "./index.js";
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -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
+ }