ai-spend-agent 0.5.5 → 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 +294 -57
- package/package.json +8 -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);
|
|
@@ -1003,8 +1208,11 @@ async function buildReportInput(stateDir, rootPath) {
|
|
|
1003
1208
|
const deadContext = spendState.mode === "local_logs"
|
|
1004
1209
|
? await loadDeadContext({
|
|
1005
1210
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
1211
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
1006
1212
|
claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
|
|
1213
|
+
codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
|
|
1007
1214
|
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
1215
|
+
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
1008
1216
|
projectDir: rootPath,
|
|
1009
1217
|
includeAllProjectMcp: true,
|
|
1010
1218
|
sinceIso: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
@@ -1023,7 +1231,9 @@ async function buildReportInput(stateDir, rootPath) {
|
|
|
1023
1231
|
detectedPlans,
|
|
1024
1232
|
// Evidence ledger is built from the SAME records as the confidence
|
|
1025
1233
|
// breakdown so the two sections can never contradict each other.
|
|
1026
|
-
allRecords: spendState.
|
|
1234
|
+
allRecords: spendState.mode === "connected_provider"
|
|
1235
|
+
? selectProviderFinancialHeadlineRecords(spendState.records ?? [])
|
|
1236
|
+
: spendState.records ?? [],
|
|
1027
1237
|
dataMode: spendState.mode,
|
|
1028
1238
|
discovery,
|
|
1029
1239
|
mappings: mappings ?? [],
|
|
@@ -1039,6 +1249,7 @@ function emptyDiscovery(rootPath) {
|
|
|
1039
1249
|
rootPath,
|
|
1040
1250
|
scannedFiles: 0,
|
|
1041
1251
|
skippedDirectories: [],
|
|
1252
|
+
skippedSymlinks: [],
|
|
1042
1253
|
unreadablePaths: [],
|
|
1043
1254
|
signals: [],
|
|
1044
1255
|
secretsDetected: [],
|
|
@@ -1053,11 +1264,11 @@ async function writeApplyArtifacts(stateDir, reportInput) {
|
|
|
1053
1264
|
verificationPlan: join(stateDir, "ai-spend-verify-plan.md"),
|
|
1054
1265
|
demoPackage: join(stateDir, "demo-package.md")
|
|
1055
1266
|
};
|
|
1056
|
-
await
|
|
1057
|
-
await
|
|
1058
|
-
await
|
|
1059
|
-
await
|
|
1060
|
-
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));
|
|
1061
1272
|
return paths;
|
|
1062
1273
|
}
|
|
1063
1274
|
function parseArgs(argv) {
|
|
@@ -1086,6 +1297,10 @@ function parseArgs(argv) {
|
|
|
1086
1297
|
parsed.noColor = true;
|
|
1087
1298
|
continue;
|
|
1088
1299
|
}
|
|
1300
|
+
if (arg === "--json") {
|
|
1301
|
+
parsed.json = true;
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1089
1304
|
if (arg === "--ignore-state") {
|
|
1090
1305
|
parsed.ignoreState = true;
|
|
1091
1306
|
continue;
|
|
@@ -1098,6 +1313,14 @@ function parseArgs(argv) {
|
|
|
1098
1313
|
}
|
|
1099
1314
|
continue;
|
|
1100
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
|
+
}
|
|
1101
1324
|
if (arg === "--group-by") {
|
|
1102
1325
|
const next = rest[index + 1];
|
|
1103
1326
|
if (isGroupByDimension(next)) {
|
|
@@ -1318,8 +1541,13 @@ function sanitizeSecretishError(message, authReference) {
|
|
|
1318
1541
|
}
|
|
1319
1542
|
return sanitized;
|
|
1320
1543
|
}
|
|
1321
|
-
async function writeLocalSpendState(stateDir, records, summary, mappings, mode) {
|
|
1322
|
-
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
|
+
});
|
|
1323
1551
|
await writeJson(join(stateDir, "mappings.json"), mappings);
|
|
1324
1552
|
}
|
|
1325
1553
|
async function readSourceRegistry(stateDir, rootPath) {
|
|
@@ -1363,7 +1591,7 @@ async function appendAuditEvent(stateDir, event) {
|
|
|
1363
1591
|
await writeJson(join(stateDir, "audit-log.json"), createScanAuditLog([...auditLog.events, event].slice(-500)));
|
|
1364
1592
|
}
|
|
1365
1593
|
async function readJson(path) {
|
|
1366
|
-
return JSON.parse(await
|
|
1594
|
+
return JSON.parse(await readSafeStateText(dirname(path), basename(path)));
|
|
1367
1595
|
}
|
|
1368
1596
|
async function readOptionalJson(path, fallback) {
|
|
1369
1597
|
try {
|
|
@@ -1374,14 +1602,20 @@ async function readOptionalJson(path, fallback) {
|
|
|
1374
1602
|
}
|
|
1375
1603
|
}
|
|
1376
1604
|
async function writeJson(path, value) {
|
|
1377
|
-
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;
|
|
1378
1612
|
}
|
|
1379
1613
|
function ok(stdout) {
|
|
1380
1614
|
return { exitCode: 0, stdout, stderr: "" };
|
|
1381
1615
|
}
|
|
1382
1616
|
function helpText() {
|
|
1383
1617
|
return [
|
|
1384
|
-
"
|
|
1618
|
+
"aibill — your AI cost and usage evidence in one private view",
|
|
1385
1619
|
"",
|
|
1386
1620
|
"Run with no command for an instant, zero-key demo:",
|
|
1387
1621
|
" ai-spend-agent Show where your AI money goes (sample/auto-detected data)",
|
|
@@ -1393,7 +1627,7 @@ function helpText() {
|
|
|
1393
1627
|
" ai-spend-agent connect anthropic Self-serve in ~2 min with an Admin key",
|
|
1394
1628
|
" ai-spend-agent connect cursor Upgrade: requires a Cursor team-admin key (Business plan)",
|
|
1395
1629
|
" ai-spend-agent connect github-copilot Upgrade: requires a GitHub billing-admin token",
|
|
1396
|
-
" 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)",
|
|
1397
1631
|
"",
|
|
1398
1632
|
"Watch continuously (deltas + anomalies):",
|
|
1399
1633
|
" watch [--interval N] Re-run analysis on an interval and report deltas/anomalies",
|
|
@@ -1406,10 +1640,13 @@ function helpText() {
|
|
|
1406
1640
|
" --ignore-state On the default/quickstart run, ignore persisted spend.json for this run",
|
|
1407
1641
|
" scan [--path <dir>] Scan a local workspace for AI usage signals",
|
|
1408
1642
|
" scan --sample Include deterministic sample spend analysis",
|
|
1409
|
-
" quickstart [--sample] Plain-English
|
|
1643
|
+
" quickstart [--sample] Plain-English local readout (alias of the default run)",
|
|
1410
1644
|
" [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: model",
|
|
1411
1645
|
" report [--out <name>] Generate local Markdown and HTML reports",
|
|
1412
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",
|
|
1413
1650
|
" apply Print the paste-ready coding-agent prompt + write action/policy/verification plans",
|
|
1414
1651
|
" apply-artifact Same as `apply` (long form)",
|
|
1415
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",
|
|
@@ -51,8 +54,8 @@
|
|
|
51
54
|
"prepack": "npm run build"
|
|
52
55
|
},
|
|
53
56
|
"dependencies": {
|
|
54
|
-
"@agent-finops/core": "0.5.
|
|
55
|
-
"@agent-finops/report": "0.5.
|
|
57
|
+
"@agent-finops/core": "0.5.6",
|
|
58
|
+
"@agent-finops/report": "0.5.6",
|
|
56
59
|
"yocto-spinner": "^1.2.0"
|
|
57
60
|
}
|
|
58
61
|
}
|