ai-spend-agent 0.5.4 → 0.5.6
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 +9 -30
- package/dist/index.js +298 -57
- package/package.json +10 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Futura Studio LLC
|
|
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,40 +1,19 @@
|
|
|
1
1
|
# ai-spend-agent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The full [aibill](https://github.com/futurastudio/ai-spend-agent) CLI.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npx ai-spend-agent
|
|
7
|
+
# short alias
|
|
8
|
+
npx aibill
|
|
7
9
|
```
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
subscription-vs-API **plan check**, and the **dead context** you pay for but
|
|
13
|
-
never use (tools/skills/MCP servers loaded on every turn and never invoked).
|
|
11
|
+
It reads local Claude Code and Codex metadata, labels API-equivalent estimates,
|
|
12
|
+
and can optionally add official OpenAI or Anthropic provider-reported cost
|
|
13
|
+
through an environment-variable reference. No product telemetry is sent.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
ai-spend-agent connect openai # org-owner Admin key
|
|
21
|
-
ai-spend-agent connect anthropic # Admin key
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
Billing-API numbers from OpenAI/Anthropic are tagged `verified`; local-log
|
|
25
|
-
numbers are always `estimated`; the beta Cursor/Copilot connectors are
|
|
26
|
-
`estimated` until reconciled against a real invoice. Every figure carries its
|
|
27
|
-
confidence label — that's the product.
|
|
28
|
-
|
|
29
|
-
## Privacy
|
|
30
|
-
|
|
31
|
-
Local-first: nothing is uploaded, there is no telemetry, secrets are
|
|
32
|
-
redacted from all output and persisted state, and provider keys are only ever
|
|
33
|
-
referenced as `env:NAME` — never stored.
|
|
34
|
-
|
|
35
|
-
## Docs
|
|
36
|
-
|
|
37
|
-
Full README, MCP server, and connector guides:
|
|
38
|
-
**https://github.com/futurastudio/ai-spend-agent#readme**
|
|
15
|
+
See the repository
|
|
16
|
+
[README](https://github.com/futurastudio/ai-spend-agent#readme) for commands,
|
|
17
|
+
privacy boundaries, supported sources, and public-beta limitations.
|
|
39
18
|
|
|
40
19
|
MIT licensed.
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
|
-
import { mkdir, readFile, rm, stat
|
|
4
|
-
import { dirname, extname, join, resolve } from "node:path";
|
|
3
|
+
import { mkdir, readFile, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
-
import { analyzeSpend, attributeUsageRecords, detectLocalCredentials, detectLocalPlans, redactSecrets, subscriptionPlans, unsafeScanRootReason, loadDeadContext, sampleDeadContext, loadLocalAgentUsage, loadSampleUsageData, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, slugifySourceId } from "@agent-finops/core";
|
|
6
|
+
import { analyzeSpend, attributeUsageRecords, buildUsageGlance, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, writeSafeStateText, loadDeadContext, sampleDeadContext, loadLocalAgentUsage, loadSampleUsageData, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, slugifySourceId } from "@agent-finops/core";
|
|
7
7
|
import { generateActionPlanMarkdown, generateApplyArtifactMarkdown, generateDemoPackageMarkdown, generateHtmlReport, generateMarkdownReport, generatePlainEnglishSummary, generatePolicyConfigDraftMarkdown, generateReportCardCaption, generateReportCardSvg, generateVerificationPlanMarkdown, groupByDimensions } from "@agent-finops/report";
|
|
8
8
|
export async function runCli(argv = process.argv.slice(2)) {
|
|
9
9
|
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
@@ -48,6 +48,12 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
48
48
|
if (args.command === "report-card") {
|
|
49
49
|
return reportCardCommand(args);
|
|
50
50
|
}
|
|
51
|
+
if (args.command === "glance") {
|
|
52
|
+
return glanceCommand(args);
|
|
53
|
+
}
|
|
54
|
+
if (args.command === "context" || args.command === "context-health") {
|
|
55
|
+
return contextHealthCommand(args);
|
|
56
|
+
}
|
|
51
57
|
if (args.command === "apply-artifact" || args.command === "apply") {
|
|
52
58
|
return applyArtifactCommand(args);
|
|
53
59
|
}
|
|
@@ -74,7 +80,10 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
74
80
|
}
|
|
75
81
|
async function quickstartCommand(args) {
|
|
76
82
|
const { records, mode, warnings } = await loadInstantReadData(args);
|
|
77
|
-
const
|
|
83
|
+
const summaryRecords = mode === "connected"
|
|
84
|
+
? selectProviderFinancialHeadlineRecords(records)
|
|
85
|
+
: records;
|
|
86
|
+
const summary = analyzeSpend(summaryRecords);
|
|
78
87
|
// For real local-log users the by-project view is the flagship table
|
|
79
88
|
// ("which project burns my plan"); demo/connected keep by-model.
|
|
80
89
|
const groupBy = args.groupBy ?? (mode === "local-logs" ? "project" : "model");
|
|
@@ -113,8 +122,11 @@ async function quickstartCommand(args) {
|
|
|
113
122
|
: await loadDeadContext({
|
|
114
123
|
// Env overrides keep tests (and unusual installs) isolated from $HOME.
|
|
115
124
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
125
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
116
126
|
claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
|
|
127
|
+
codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
|
|
117
128
|
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
129
|
+
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
118
130
|
projectDir: resolve(args.path),
|
|
119
131
|
includeAllProjectMcp: true,
|
|
120
132
|
sinceIso: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
@@ -127,7 +139,7 @@ async function quickstartCommand(args) {
|
|
|
127
139
|
deadContext = sampleDeadContext();
|
|
128
140
|
}
|
|
129
141
|
const summaryText = generatePlainEnglishSummary(summary, {
|
|
130
|
-
records,
|
|
142
|
+
records: summaryRecords,
|
|
131
143
|
groupBy,
|
|
132
144
|
color,
|
|
133
145
|
mode,
|
|
@@ -141,6 +153,137 @@ async function quickstartCommand(args) {
|
|
|
141
153
|
const header = [` ${dataModeBanner(mode)}`, ...warnings.map((warning) => ` ! ${warning}`)].join("\n");
|
|
142
154
|
return ok(`${header}\n${summaryText}`);
|
|
143
155
|
}
|
|
156
|
+
async function glanceCommand(args) {
|
|
157
|
+
const sinceDays = args.sinceDays ?? 30;
|
|
158
|
+
if (!Number.isInteger(sinceDays) || sinceDays < 1 || sinceDays > 365) {
|
|
159
|
+
return {
|
|
160
|
+
exitCode: 1,
|
|
161
|
+
stdout: "",
|
|
162
|
+
stderr: "--since-days must be a whole number between 1 and 365"
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const logs = await loadLocalAgentUsage({
|
|
166
|
+
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
167
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
168
|
+
sinceIso: new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1_000).toISOString()
|
|
169
|
+
});
|
|
170
|
+
const calls = args.project
|
|
171
|
+
? logs.calls.filter((call) => call.project === args.project)
|
|
172
|
+
: logs.calls;
|
|
173
|
+
let detectedPlans;
|
|
174
|
+
if (args.plan) {
|
|
175
|
+
const override = planOverrideFromFlag(args.plan);
|
|
176
|
+
if (!override) {
|
|
177
|
+
return {
|
|
178
|
+
exitCode: 1,
|
|
179
|
+
stdout: "",
|
|
180
|
+
stderr: `Unknown --plan "${args.plan}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
detectedPlans = [override];
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
detectedPlans = await detectLocalPlans({
|
|
187
|
+
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
188
|
+
codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
|
|
189
|
+
}).catch(() => []);
|
|
190
|
+
}
|
|
191
|
+
const contextHealth = await loadContextHealth(calls, {
|
|
192
|
+
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
193
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
194
|
+
claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
|
|
195
|
+
codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
|
|
196
|
+
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
197
|
+
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
198
|
+
projectDir: resolve(args.path),
|
|
199
|
+
sinceIso: new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1_000).toISOString(),
|
|
200
|
+
windowDays: sinceDays
|
|
201
|
+
});
|
|
202
|
+
const snapshot = buildUsageGlance(calls, {
|
|
203
|
+
filesParsed: logs.filesParsed,
|
|
204
|
+
detectedAgents: logs.agentsDetected,
|
|
205
|
+
detectedPlans,
|
|
206
|
+
limitCalls: logs.calls,
|
|
207
|
+
contextHealth
|
|
208
|
+
});
|
|
209
|
+
return ok(JSON.stringify(snapshot));
|
|
210
|
+
}
|
|
211
|
+
async function contextHealthCommand(args) {
|
|
212
|
+
const sinceDays = args.sinceDays ?? 30;
|
|
213
|
+
if (!Number.isInteger(sinceDays) || sinceDays < 1 || sinceDays > 365) {
|
|
214
|
+
return {
|
|
215
|
+
exitCode: 1,
|
|
216
|
+
stdout: "",
|
|
217
|
+
stderr: "--since-days must be a whole number between 1 and 365"
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const sinceIso = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1_000).toISOString();
|
|
221
|
+
const logs = await loadLocalAgentUsage({
|
|
222
|
+
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
223
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
224
|
+
sinceIso
|
|
225
|
+
});
|
|
226
|
+
const calls = args.project
|
|
227
|
+
? logs.calls.filter((call) => call.project === args.project)
|
|
228
|
+
: logs.calls;
|
|
229
|
+
const health = await loadContextHealth(calls, {
|
|
230
|
+
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
231
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
232
|
+
claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
|
|
233
|
+
codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
|
|
234
|
+
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
235
|
+
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
236
|
+
projectDir: resolve(args.path),
|
|
237
|
+
sinceIso,
|
|
238
|
+
windowDays: sinceDays
|
|
239
|
+
});
|
|
240
|
+
return ok(args.json ? JSON.stringify(health) : renderContextHealth(health));
|
|
241
|
+
}
|
|
242
|
+
function renderContextHealth(health) {
|
|
243
|
+
const status = health.status.replace("_", " ").toUpperCase();
|
|
244
|
+
const activation = health.activation;
|
|
245
|
+
const dead = health.deadContext;
|
|
246
|
+
const lines = [
|
|
247
|
+
`CONTEXT HEALTH ${status}`,
|
|
248
|
+
health.headline,
|
|
249
|
+
"",
|
|
250
|
+
`Action: ${health.action}`,
|
|
251
|
+
`Confidence: ${health.confidence}`,
|
|
252
|
+
"",
|
|
253
|
+
"Activation",
|
|
254
|
+
` Discoverable: ${activation.discoverableItems} Invoked: ${activation.explicitlyInvokedItems} MCP schema-loaded: ${activation.mcpSchemaLoadedItems}`,
|
|
255
|
+
` Hook-injected: ${activation.hookInjectedItems} Other lifecycle hooks: ${activation.lifecycleHooks} Unmeasured weight: ${activation.unmeasuredItems}`,
|
|
256
|
+
` Invocation-unobservable: ${activation.invocationUnobservableItems}`,
|
|
257
|
+
"",
|
|
258
|
+
`Never invoked among observable inventory (${dead.windowDays}d): ${dead.neverInvokedItems}/${dead.loadedItems} ` +
|
|
259
|
+
`(${dead.measuredNeverInvokedItems} measured, ${dead.unmeasuredNeverInvokedItems} unmeasured)`
|
|
260
|
+
];
|
|
261
|
+
if (health.currentSession) {
|
|
262
|
+
const session = health.currentSession;
|
|
263
|
+
lines.push(`Session: ${session.agent}${session.project ? ` · ${session.project}` : ""} · ` +
|
|
264
|
+
`${session.totalTokens.toLocaleString("en-US")} tokens · ` +
|
|
265
|
+
(session.ratioToMedian === null
|
|
266
|
+
? "no same-agent baseline"
|
|
267
|
+
: `${session.ratioToMedian}× median (${session.comparisonSessions} prior)`));
|
|
268
|
+
}
|
|
269
|
+
const churn = health.contextChurn;
|
|
270
|
+
if (churn.currentSessionEvidence === "matched") {
|
|
271
|
+
lines.push(`Context churn: ${churn.compactionEvents ?? 0} compaction event${churn.compactionEvents === 1 ? "" : "s"} · ` +
|
|
272
|
+
`${churn.repeatedReadEvents ?? 0} repeat explicit read${churn.repeatedReadEvents === 1 ? "" : "s"} · ` +
|
|
273
|
+
`${churn.currentSessionScope ?? "unknown"} session`);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
lines.push(`Context churn: current transcript ${churn.currentSessionEvidence === "not_matched" ? "not matched" : "unavailable"}`);
|
|
277
|
+
}
|
|
278
|
+
if (health.evidence.length > 0) {
|
|
279
|
+
lines.push("", "Evidence");
|
|
280
|
+
for (const evidence of health.evidence) {
|
|
281
|
+
lines.push(` - ${evidence.summary} [${evidence.confidence}; ${evidence.source}]`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
lines.push("", "Data: local agent configuration + local Claude Code/Codex transcripts; hook commands were not run.", "Privacy: this CLI run uploads nothing.");
|
|
285
|
+
return lines.join("\n");
|
|
286
|
+
}
|
|
144
287
|
function quickstartNextSteps(mode, detected) {
|
|
145
288
|
// Connect/verify guidance now lives in the readout's APPLY/VERIFY sections;
|
|
146
289
|
// this footer only carries what those can't know (detected local keys) and
|
|
@@ -149,11 +292,11 @@ function quickstartNextSteps(mode, detected) {
|
|
|
149
292
|
if (detected.length > 0) {
|
|
150
293
|
const names = detected.map((credential) => `${credential.provider} (${credential.hint})`).join(", ");
|
|
151
294
|
steps.push(`Found local key${detected.length === 1 ? "" : "s"}: ${names}`);
|
|
152
|
-
steps.push(`npx
|
|
295
|
+
steps.push(`npx aibill connect ${detected[0].provider} add official provider-reported cost (ADMIN/owner key)`);
|
|
153
296
|
}
|
|
154
297
|
steps.push("npx aibill report write a shareable Markdown + HTML report");
|
|
155
|
-
steps.push("npx aibill --group-by project see which project
|
|
156
|
-
steps.push("
|
|
298
|
+
steps.push("npx aibill --group-by project see which project has the most observed activity");
|
|
299
|
+
steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://ai-spend-agent.vercel.app");
|
|
157
300
|
return steps;
|
|
158
301
|
}
|
|
159
302
|
async function readPersistedSpend(rootPath) {
|
|
@@ -246,7 +389,7 @@ async function doctorCommand(args) {
|
|
|
246
389
|
if (!hasLogs)
|
|
247
390
|
warnings.push("no real Claude Code / Codex logs found — a first run here will show DEMO sample data");
|
|
248
391
|
if (providerRefs.length === 0)
|
|
249
|
-
warnings.push("no provider admin keys detected — connect OpenAI/Anthropic
|
|
392
|
+
warnings.push("no provider admin keys detected — connect OpenAI/Anthropic to add official provider-reported cost (local logs stay API-equivalent estimates)");
|
|
250
393
|
const predictedMode = stateMode === "connected_provider"
|
|
251
394
|
? "connected provider billing"
|
|
252
395
|
: hasLogs
|
|
@@ -282,8 +425,21 @@ async function cliVersion() {
|
|
|
282
425
|
}
|
|
283
426
|
}
|
|
284
427
|
async function resetCommand(args) {
|
|
285
|
-
const rootPath =
|
|
286
|
-
|
|
428
|
+
const rootPath = await resolveSafeScanRoot(args.path);
|
|
429
|
+
let stateDir;
|
|
430
|
+
try {
|
|
431
|
+
stateDir = await resolveSafeStateDirectory(rootPath);
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
if (!isNodeError(error, "ENOENT"))
|
|
435
|
+
throw error;
|
|
436
|
+
return ok([
|
|
437
|
+
"AI Spend Analyst reset",
|
|
438
|
+
`path: ${rootPath}`,
|
|
439
|
+
"nothing to clear (no persisted spend state found)",
|
|
440
|
+
"next run will re-read your real local agent logs (or demo sample if none)."
|
|
441
|
+
].join("\n"));
|
|
442
|
+
}
|
|
287
443
|
// Clear derived spend state so a prior `scan --sample` (or stale provider
|
|
288
444
|
// sync) can never mask the next real local-log read. Leaves sources/audit.
|
|
289
445
|
const targets = ["spend.json", "mappings.json", "provider-records.json", "watch-latest.json", "watch-history.json"];
|
|
@@ -530,7 +686,10 @@ async function runWatchCycle(stateDir, args) {
|
|
|
530
686
|
}
|
|
531
687
|
}
|
|
532
688
|
}
|
|
533
|
-
const
|
|
689
|
+
const headlineRecords = mode === "connected_provider"
|
|
690
|
+
? selectProviderFinancialHeadlineRecords(records)
|
|
691
|
+
: records;
|
|
692
|
+
const summary = analyzeSpend(headlineRecords);
|
|
534
693
|
const mappings = attributeUsageRecords(records);
|
|
535
694
|
await writeLocalSpendState(stateDir, records, summary, mappings, mode);
|
|
536
695
|
const snapshot = {
|
|
@@ -549,7 +708,7 @@ async function runWatchCycle(stateDir, args) {
|
|
|
549
708
|
sourceId: "watch",
|
|
550
709
|
detail: `Watch cycle captured ${snapshot.recordCount} records totaling $${snapshot.totalUsd.toFixed(2)}.`
|
|
551
710
|
});
|
|
552
|
-
return { summary, snapshot, records, mode };
|
|
711
|
+
return { summary, snapshot, records: headlineRecords, mode };
|
|
553
712
|
}
|
|
554
713
|
function buildDeltaHeadline(previous, current) {
|
|
555
714
|
if (!previous) {
|
|
@@ -771,34 +930,68 @@ async function syncProviderCommand(args) {
|
|
|
771
930
|
enterprise: args.enterprise,
|
|
772
931
|
accountId: args.accountId
|
|
773
932
|
});
|
|
933
|
+
const priorProviderState = await readOptionalJson(join(stateDir, "provider-records.json"), { records: [] });
|
|
934
|
+
const records = [
|
|
935
|
+
...priorProviderState.records.filter((record) => record.source.provider !== result.provider),
|
|
936
|
+
...result.records
|
|
937
|
+
].sort((left, right) => left.timestamp.localeCompare(right.timestamp));
|
|
774
938
|
const registry = await readSourceRegistry(stateDir, rootPath);
|
|
775
939
|
const nextRegistry = addApprovedSource(registry, result.source);
|
|
776
|
-
const
|
|
777
|
-
const
|
|
940
|
+
const headlineRecords = selectProviderFinancialHeadlineRecords(records);
|
|
941
|
+
const summary = analyzeSpend(headlineRecords);
|
|
942
|
+
const mappings = attributeUsageRecords(records);
|
|
943
|
+
const qaByProvider = {
|
|
944
|
+
...(priorProviderState.qaByProvider ?? {}),
|
|
945
|
+
[result.provider]: result.qa
|
|
946
|
+
};
|
|
947
|
+
const coverageByProvider = {
|
|
948
|
+
...(priorProviderState.coverageByProvider ?? {}),
|
|
949
|
+
[result.provider]: result.coverage
|
|
950
|
+
};
|
|
951
|
+
const financialsByProvider = {
|
|
952
|
+
...(priorProviderState.financialsByProvider ?? {}),
|
|
953
|
+
[result.provider]: result.financials
|
|
954
|
+
};
|
|
778
955
|
await mkdir(stateDir, { recursive: true });
|
|
779
956
|
await writeJson(join(stateDir, "sources.json"), nextRegistry);
|
|
780
957
|
await writeJson(join(stateDir, "provider-records.json"), {
|
|
781
958
|
provider: result.provider,
|
|
782
959
|
fetchedAt: result.fetchedAt,
|
|
783
960
|
completeness: result.completeness,
|
|
961
|
+
coverage: result.coverage,
|
|
962
|
+
financials: result.financials,
|
|
784
963
|
sourceId: result.source.id,
|
|
785
|
-
records
|
|
786
|
-
qa: result.qa
|
|
964
|
+
records,
|
|
965
|
+
qa: result.qa,
|
|
966
|
+
qaByProvider,
|
|
967
|
+
coverageByProvider,
|
|
968
|
+
financialsByProvider
|
|
969
|
+
});
|
|
970
|
+
await writeLocalSpendState(stateDir, records, summary, mappings, "connected_provider", {
|
|
971
|
+
policy: "provider_reported_billed_cost_preferred",
|
|
972
|
+
note: "Official provider-reported billed costs are the spend headline. API-equivalent estimates remain separate evidence and are not added to that total.",
|
|
973
|
+
coverageByProvider,
|
|
974
|
+
financialsByProvider
|
|
787
975
|
});
|
|
788
|
-
await writeLocalSpendState(stateDir, result.records, summary, mappings, "connected_provider");
|
|
789
976
|
await appendAuditEvent(stateDir, {
|
|
790
977
|
timestamp: result.fetchedAt,
|
|
791
978
|
action: "source_scanned",
|
|
792
979
|
sourceId: result.source.id,
|
|
793
|
-
detail: `${args.provider} provider connector synced ${result.records.length}
|
|
980
|
+
detail: `${args.provider} provider connector synced ${result.records.length} evidence records with ${result.coverage} coverage. Auth reference only; no raw secrets stored.`
|
|
794
981
|
});
|
|
795
982
|
return ok([
|
|
796
983
|
"AI Spend Analyst Agent sync-provider",
|
|
797
984
|
`provider: ${result.provider}`,
|
|
798
985
|
`source: ${result.source.id}`,
|
|
799
986
|
`verification: ${result.source.verification}`,
|
|
800
|
-
`
|
|
801
|
-
`
|
|
987
|
+
`coverage: ${result.coverage}`,
|
|
988
|
+
`records fetched: ${result.records.length}`,
|
|
989
|
+
`headline basis: ${result.financials.headlineBasis}`,
|
|
990
|
+
`synced provider headline: $${(result.financials.headlineUsd ?? 0).toFixed(2)}`,
|
|
991
|
+
`combined headline spend: $${summary.totalUsd.toFixed(2)}`,
|
|
992
|
+
...(result.financials.apiEquivalentEstimatedUsd !== null
|
|
993
|
+
? [`API-equivalent estimate (kept separate): $${result.financials.apiEquivalentEstimatedUsd.toFixed(2)}`]
|
|
994
|
+
: []),
|
|
802
995
|
"auth: reference-only; raw secrets were not persisted or printed"
|
|
803
996
|
].join("\n"));
|
|
804
997
|
}
|
|
@@ -854,8 +1047,8 @@ async function reportCommand(args) {
|
|
|
854
1047
|
const outBase = args.out ? resolve(rootPath, args.out) : join(stateDir, "report");
|
|
855
1048
|
const markdownPath = `${outBase}.md`;
|
|
856
1049
|
const htmlPath = `${outBase}.html`;
|
|
857
|
-
await
|
|
858
|
-
await
|
|
1050
|
+
await writeLocalReportFile(markdownPath, generateMarkdownReport(reportInput), stateDir);
|
|
1051
|
+
await writeLocalReportFile(htmlPath, generateHtmlReport(reportInput), stateDir);
|
|
859
1052
|
const artifactPaths = await writeApplyArtifacts(stateDir, reportInput);
|
|
860
1053
|
return ok([
|
|
861
1054
|
"AI Spend Analyst Agent report",
|
|
@@ -899,27 +1092,39 @@ async function resolveReceiptPath(rootPath, out) {
|
|
|
899
1092
|
return extname(resolved) ? resolved : `${resolved}.svg`;
|
|
900
1093
|
}
|
|
901
1094
|
async function reportCardCommand(args) {
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
:
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1095
|
+
try {
|
|
1096
|
+
const rootPath = await resolveSafeScanRoot(args.path);
|
|
1097
|
+
const { records, mode } = await loadInstantReadData(args);
|
|
1098
|
+
const headlineRecords = mode === "connected"
|
|
1099
|
+
? selectProviderFinancialHeadlineRecords(records)
|
|
1100
|
+
: records;
|
|
1101
|
+
const summary = analyzeSpend(headlineRecords);
|
|
1102
|
+
const outPath = await resolveReceiptPath(rootPath, args.out);
|
|
1103
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
1104
|
+
await writeSafeStateText(dirname(outPath), basename(outPath), generateReportCardSvg({ summary, records: headlineRecords, mode }));
|
|
1105
|
+
const dataLine = mode === "demo"
|
|
1106
|
+
? "data: DEMO sample data — run without --sample on a machine with Claude Code/Codex logs for your own numbers."
|
|
1107
|
+
: mode === "local-logs"
|
|
1108
|
+
? "data: local Claude Code/Codex logs priced at API-equivalent rates."
|
|
1109
|
+
: "data: connected local spend state with provider-reported cost kept separate from API-equivalent estimates.";
|
|
1110
|
+
return ok([
|
|
1111
|
+
"Your AI Receipt — a shareable, redacted spend card (no client/project/user names).",
|
|
1112
|
+
`receipt: ${outPath}`,
|
|
1113
|
+
dataLine,
|
|
1114
|
+
"",
|
|
1115
|
+
"Caption to share:",
|
|
1116
|
+
generateReportCardCaption({ summary, records: headlineRecords, mode }),
|
|
1117
|
+
"",
|
|
1118
|
+
"privacy: rendered locally; only totals, modeled opportunities, and model-level investigations are included."
|
|
1119
|
+
].join("\n"));
|
|
1120
|
+
}
|
|
1121
|
+
catch (error) {
|
|
1122
|
+
return {
|
|
1123
|
+
exitCode: 1,
|
|
1124
|
+
stdout: "",
|
|
1125
|
+
stderr: `Couldn't write the report card: ${error instanceof Error ? error.message : String(error)}`
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
923
1128
|
}
|
|
924
1129
|
async function applyArtifactCommand(args) {
|
|
925
1130
|
const rootPath = resolve(args.path);
|
|
@@ -963,6 +1168,10 @@ async function buildReportInput(stateDir, rootPath) {
|
|
|
963
1168
|
const needsFreshLogs = !spendState?.summary ||
|
|
964
1169
|
!spendState.records ||
|
|
965
1170
|
spendState.records.length === 0 ||
|
|
1171
|
+
// Pre-0.5.3 state did not persist a mode. Treat it as a cache and
|
|
1172
|
+
// re-detect local logs so report/apply cannot route through the agency
|
|
1173
|
+
// artifact path with stale or demo-shaped records.
|
|
1174
|
+
spendState.mode === undefined ||
|
|
966
1175
|
spendState.mode === "local_logs" ||
|
|
967
1176
|
// Mislabeled state (local-log records stamped connected by a past bug)
|
|
968
1177
|
// must be superseded by a fresh read, not trusted.
|
|
@@ -999,8 +1208,11 @@ async function buildReportInput(stateDir, rootPath) {
|
|
|
999
1208
|
const deadContext = spendState.mode === "local_logs"
|
|
1000
1209
|
? await loadDeadContext({
|
|
1001
1210
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
1211
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
1002
1212
|
claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
|
|
1213
|
+
codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
|
|
1003
1214
|
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
1215
|
+
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
1004
1216
|
projectDir: rootPath,
|
|
1005
1217
|
includeAllProjectMcp: true,
|
|
1006
1218
|
sinceIso: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
@@ -1019,7 +1231,9 @@ async function buildReportInput(stateDir, rootPath) {
|
|
|
1019
1231
|
detectedPlans,
|
|
1020
1232
|
// Evidence ledger is built from the SAME records as the confidence
|
|
1021
1233
|
// breakdown so the two sections can never contradict each other.
|
|
1022
|
-
allRecords: spendState.
|
|
1234
|
+
allRecords: spendState.mode === "connected_provider"
|
|
1235
|
+
? selectProviderFinancialHeadlineRecords(spendState.records ?? [])
|
|
1236
|
+
: spendState.records ?? [],
|
|
1023
1237
|
dataMode: spendState.mode,
|
|
1024
1238
|
discovery,
|
|
1025
1239
|
mappings: mappings ?? [],
|
|
@@ -1035,6 +1249,7 @@ function emptyDiscovery(rootPath) {
|
|
|
1035
1249
|
rootPath,
|
|
1036
1250
|
scannedFiles: 0,
|
|
1037
1251
|
skippedDirectories: [],
|
|
1252
|
+
skippedSymlinks: [],
|
|
1038
1253
|
unreadablePaths: [],
|
|
1039
1254
|
signals: [],
|
|
1040
1255
|
secretsDetected: [],
|
|
@@ -1049,11 +1264,11 @@ async function writeApplyArtifacts(stateDir, reportInput) {
|
|
|
1049
1264
|
verificationPlan: join(stateDir, "ai-spend-verify-plan.md"),
|
|
1050
1265
|
demoPackage: join(stateDir, "demo-package.md")
|
|
1051
1266
|
};
|
|
1052
|
-
await
|
|
1053
|
-
await
|
|
1054
|
-
await
|
|
1055
|
-
await
|
|
1056
|
-
await
|
|
1267
|
+
await writeSafeStateText(stateDir, basename(paths.codingPrompt), generateApplyArtifactMarkdown(reportInput));
|
|
1268
|
+
await writeSafeStateText(stateDir, basename(paths.actionPlan), generateActionPlanMarkdown(reportInput));
|
|
1269
|
+
await writeSafeStateText(stateDir, basename(paths.policyConfigDraft), generatePolicyConfigDraftMarkdown(reportInput));
|
|
1270
|
+
await writeSafeStateText(stateDir, basename(paths.verificationPlan), generateVerificationPlanMarkdown(reportInput));
|
|
1271
|
+
await writeSafeStateText(stateDir, basename(paths.demoPackage), generateDemoPackageMarkdown(reportInput));
|
|
1057
1272
|
return paths;
|
|
1058
1273
|
}
|
|
1059
1274
|
function parseArgs(argv) {
|
|
@@ -1082,6 +1297,10 @@ function parseArgs(argv) {
|
|
|
1082
1297
|
parsed.noColor = true;
|
|
1083
1298
|
continue;
|
|
1084
1299
|
}
|
|
1300
|
+
if (arg === "--json") {
|
|
1301
|
+
parsed.json = true;
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1085
1304
|
if (arg === "--ignore-state") {
|
|
1086
1305
|
parsed.ignoreState = true;
|
|
1087
1306
|
continue;
|
|
@@ -1094,6 +1313,14 @@ function parseArgs(argv) {
|
|
|
1094
1313
|
}
|
|
1095
1314
|
continue;
|
|
1096
1315
|
}
|
|
1316
|
+
if (arg === "--since-days") {
|
|
1317
|
+
const next = rest[index + 1];
|
|
1318
|
+
if (next) {
|
|
1319
|
+
parsed.sinceDays = Number(next);
|
|
1320
|
+
index += 1;
|
|
1321
|
+
}
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1097
1324
|
if (arg === "--group-by") {
|
|
1098
1325
|
const next = rest[index + 1];
|
|
1099
1326
|
if (isGroupByDimension(next)) {
|
|
@@ -1314,8 +1541,13 @@ function sanitizeSecretishError(message, authReference) {
|
|
|
1314
1541
|
}
|
|
1315
1542
|
return sanitized;
|
|
1316
1543
|
}
|
|
1317
|
-
async function writeLocalSpendState(stateDir, records, summary, mappings, mode) {
|
|
1318
|
-
await writeJson(join(stateDir, "spend.json"), {
|
|
1544
|
+
async function writeLocalSpendState(stateDir, records, summary, mappings, mode, accounting) {
|
|
1545
|
+
await writeJson(join(stateDir, "spend.json"), {
|
|
1546
|
+
mode,
|
|
1547
|
+
records,
|
|
1548
|
+
summary,
|
|
1549
|
+
...(accounting ? { accounting } : {})
|
|
1550
|
+
});
|
|
1319
1551
|
await writeJson(join(stateDir, "mappings.json"), mappings);
|
|
1320
1552
|
}
|
|
1321
1553
|
async function readSourceRegistry(stateDir, rootPath) {
|
|
@@ -1359,7 +1591,7 @@ async function appendAuditEvent(stateDir, event) {
|
|
|
1359
1591
|
await writeJson(join(stateDir, "audit-log.json"), createScanAuditLog([...auditLog.events, event].slice(-500)));
|
|
1360
1592
|
}
|
|
1361
1593
|
async function readJson(path) {
|
|
1362
|
-
return JSON.parse(await
|
|
1594
|
+
return JSON.parse(await readSafeStateText(dirname(path), basename(path)));
|
|
1363
1595
|
}
|
|
1364
1596
|
async function readOptionalJson(path, fallback) {
|
|
1365
1597
|
try {
|
|
@@ -1370,14 +1602,20 @@ async function readOptionalJson(path, fallback) {
|
|
|
1370
1602
|
}
|
|
1371
1603
|
}
|
|
1372
1604
|
async function writeJson(path, value) {
|
|
1373
|
-
await
|
|
1605
|
+
await writeSafeStateText(dirname(path), basename(path), `${JSON.stringify(value, null, 2)}\n`);
|
|
1606
|
+
}
|
|
1607
|
+
async function writeLocalReportFile(path, contents, _stateDir) {
|
|
1608
|
+
await writeSafeStateText(dirname(path), basename(path), contents);
|
|
1609
|
+
}
|
|
1610
|
+
function isNodeError(error, code) {
|
|
1611
|
+
return error instanceof Error && error.code === code;
|
|
1374
1612
|
}
|
|
1375
1613
|
function ok(stdout) {
|
|
1376
1614
|
return { exitCode: 0, stdout, stderr: "" };
|
|
1377
1615
|
}
|
|
1378
1616
|
function helpText() {
|
|
1379
1617
|
return [
|
|
1380
|
-
"
|
|
1618
|
+
"aibill — your AI cost and usage evidence in one private view",
|
|
1381
1619
|
"",
|
|
1382
1620
|
"Run with no command for an instant, zero-key demo:",
|
|
1383
1621
|
" ai-spend-agent Show where your AI money goes (sample/auto-detected data)",
|
|
@@ -1389,7 +1627,7 @@ function helpText() {
|
|
|
1389
1627
|
" ai-spend-agent connect anthropic Self-serve in ~2 min with an Admin key",
|
|
1390
1628
|
" ai-spend-agent connect cursor Upgrade: requires a Cursor team-admin key (Business plan)",
|
|
1391
1629
|
" ai-spend-agent connect github-copilot Upgrade: requires a GitHub billing-admin token",
|
|
1392
|
-
" ai-spend-agent sync-provider ... Pull
|
|
1630
|
+
" ai-spend-agent sync-provider ... Pull provider cost/usage evidence via a local env: reference (never a raw key)",
|
|
1393
1631
|
"",
|
|
1394
1632
|
"Watch continuously (deltas + anomalies):",
|
|
1395
1633
|
" watch [--interval N] Re-run analysis on an interval and report deltas/anomalies",
|
|
@@ -1402,10 +1640,13 @@ function helpText() {
|
|
|
1402
1640
|
" --ignore-state On the default/quickstart run, ignore persisted spend.json for this run",
|
|
1403
1641
|
" scan [--path <dir>] Scan a local workspace for AI usage signals",
|
|
1404
1642
|
" scan --sample Include deterministic sample spend analysis",
|
|
1405
|
-
" quickstart [--sample] Plain-English
|
|
1643
|
+
" quickstart [--sample] Plain-English local readout (alias of the default run)",
|
|
1406
1644
|
" [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: model",
|
|
1407
1645
|
" report [--out <name>] Generate local Markdown and HTML reports",
|
|
1408
1646
|
" report-card [--out f.svg] Write your AI Receipt — a redacted, shareable SVG + caption",
|
|
1647
|
+
" glance [--project <name>] [--plan <id>] Emit the local, machine-readable Glance snapshot JSON",
|
|
1648
|
+
" context [--project <name>] [--since-days N] Show hook-aware Context Health in the terminal",
|
|
1649
|
+
" [--json] Emit the same canonical Context Health object used by MCP and Glance",
|
|
1409
1650
|
" apply Print the paste-ready coding-agent prompt + write action/policy/verification plans",
|
|
1410
1651
|
" apply-artifact Same as `apply` (long form)",
|
|
1411
1652
|
"",
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-spend-agent",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.6",
|
|
4
|
+
"description": "Local-first financial intelligence CLI for Claude Code and Codex activity, provider-reported cost, attribution, runway, provenance, and Context Health.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
8
8
|
"bin": {
|
|
9
|
-
"ai-spend-agent": "
|
|
9
|
+
"ai-spend-agent": "dist/index.js"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"dist",
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
"directory": "packages/cli"
|
|
22
22
|
},
|
|
23
23
|
"homepage": "https://github.com/futurastudio/ai-spend-agent#readme",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/futurastudio/ai-spend-agent/issues"
|
|
26
|
+
},
|
|
24
27
|
"keywords": [
|
|
25
28
|
"ai",
|
|
26
29
|
"spend",
|
|
@@ -34,7 +37,9 @@
|
|
|
34
37
|
"cursor",
|
|
35
38
|
"llm",
|
|
36
39
|
"token",
|
|
40
|
+
"token-usage",
|
|
37
41
|
"billing",
|
|
42
|
+
"ai-cost-tracker",
|
|
38
43
|
"cli",
|
|
39
44
|
"mcp",
|
|
40
45
|
"claude-code-skills",
|
|
@@ -49,8 +54,8 @@
|
|
|
49
54
|
"prepack": "npm run build"
|
|
50
55
|
},
|
|
51
56
|
"dependencies": {
|
|
52
|
-
"@agent-finops/core": "0.5.
|
|
53
|
-
"@agent-finops/report": "0.5.
|
|
57
|
+
"@agent-finops/core": "0.5.6",
|
|
58
|
+
"@agent-finops/report": "0.5.6",
|
|
54
59
|
"yocto-spinner": "^1.2.0"
|
|
55
60
|
}
|
|
56
61
|
}
|