ai-spend-agent 0.5.5 → 0.5.7
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 +302 -61
- 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,11 +1,14 @@
|
|
|
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
|
+
if (argv.includes("--version") || argv.includes("-v")) {
|
|
10
|
+
return ok(await cliVersion());
|
|
11
|
+
}
|
|
9
12
|
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
10
13
|
return ok(helpText());
|
|
11
14
|
}
|
|
@@ -48,6 +51,12 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
48
51
|
if (args.command === "report-card") {
|
|
49
52
|
return reportCardCommand(args);
|
|
50
53
|
}
|
|
54
|
+
if (args.command === "glance") {
|
|
55
|
+
return glanceCommand(args);
|
|
56
|
+
}
|
|
57
|
+
if (args.command === "context" || args.command === "context-health") {
|
|
58
|
+
return contextHealthCommand(args);
|
|
59
|
+
}
|
|
51
60
|
if (args.command === "apply-artifact" || args.command === "apply") {
|
|
52
61
|
return applyArtifactCommand(args);
|
|
53
62
|
}
|
|
@@ -74,7 +83,10 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
74
83
|
}
|
|
75
84
|
async function quickstartCommand(args) {
|
|
76
85
|
const { records, mode, warnings } = await loadInstantReadData(args);
|
|
77
|
-
const
|
|
86
|
+
const summaryRecords = mode === "connected"
|
|
87
|
+
? selectProviderFinancialHeadlineRecords(records)
|
|
88
|
+
: records;
|
|
89
|
+
const summary = analyzeSpend(summaryRecords);
|
|
78
90
|
// For real local-log users the by-project view is the flagship table
|
|
79
91
|
// ("which project burns my plan"); demo/connected keep by-model.
|
|
80
92
|
const groupBy = args.groupBy ?? (mode === "local-logs" ? "project" : "model");
|
|
@@ -113,8 +125,11 @@ async function quickstartCommand(args) {
|
|
|
113
125
|
: await loadDeadContext({
|
|
114
126
|
// Env overrides keep tests (and unusual installs) isolated from $HOME.
|
|
115
127
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
128
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
116
129
|
claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
|
|
130
|
+
codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
|
|
117
131
|
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
132
|
+
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
118
133
|
projectDir: resolve(args.path),
|
|
119
134
|
includeAllProjectMcp: true,
|
|
120
135
|
sinceIso: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
@@ -127,7 +142,7 @@ async function quickstartCommand(args) {
|
|
|
127
142
|
deadContext = sampleDeadContext();
|
|
128
143
|
}
|
|
129
144
|
const summaryText = generatePlainEnglishSummary(summary, {
|
|
130
|
-
records,
|
|
145
|
+
records: summaryRecords,
|
|
131
146
|
groupBy,
|
|
132
147
|
color,
|
|
133
148
|
mode,
|
|
@@ -141,6 +156,137 @@ async function quickstartCommand(args) {
|
|
|
141
156
|
const header = [` ${dataModeBanner(mode)}`, ...warnings.map((warning) => ` ! ${warning}`)].join("\n");
|
|
142
157
|
return ok(`${header}\n${summaryText}`);
|
|
143
158
|
}
|
|
159
|
+
async function glanceCommand(args) {
|
|
160
|
+
const sinceDays = args.sinceDays ?? 30;
|
|
161
|
+
if (!Number.isInteger(sinceDays) || sinceDays < 1 || sinceDays > 365) {
|
|
162
|
+
return {
|
|
163
|
+
exitCode: 1,
|
|
164
|
+
stdout: "",
|
|
165
|
+
stderr: "--since-days must be a whole number between 1 and 365"
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const logs = await loadLocalAgentUsage({
|
|
169
|
+
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
170
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
171
|
+
sinceIso: new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1_000).toISOString()
|
|
172
|
+
});
|
|
173
|
+
const calls = args.project
|
|
174
|
+
? logs.calls.filter((call) => call.project === args.project)
|
|
175
|
+
: logs.calls;
|
|
176
|
+
let detectedPlans;
|
|
177
|
+
if (args.plan) {
|
|
178
|
+
const override = planOverrideFromFlag(args.plan);
|
|
179
|
+
if (!override) {
|
|
180
|
+
return {
|
|
181
|
+
exitCode: 1,
|
|
182
|
+
stdout: "",
|
|
183
|
+
stderr: `Unknown --plan "${args.plan}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
detectedPlans = [override];
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
detectedPlans = await detectLocalPlans({
|
|
190
|
+
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
191
|
+
codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
|
|
192
|
+
}).catch(() => []);
|
|
193
|
+
}
|
|
194
|
+
const contextHealth = await loadContextHealth(calls, {
|
|
195
|
+
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
196
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
197
|
+
claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
|
|
198
|
+
codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
|
|
199
|
+
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
200
|
+
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
201
|
+
projectDir: resolve(args.path),
|
|
202
|
+
sinceIso: new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1_000).toISOString(),
|
|
203
|
+
windowDays: sinceDays
|
|
204
|
+
});
|
|
205
|
+
const snapshot = buildUsageGlance(calls, {
|
|
206
|
+
filesParsed: logs.filesParsed,
|
|
207
|
+
detectedAgents: logs.agentsDetected,
|
|
208
|
+
detectedPlans,
|
|
209
|
+
limitCalls: logs.calls,
|
|
210
|
+
contextHealth
|
|
211
|
+
});
|
|
212
|
+
return ok(JSON.stringify(snapshot));
|
|
213
|
+
}
|
|
214
|
+
async function contextHealthCommand(args) {
|
|
215
|
+
const sinceDays = args.sinceDays ?? 30;
|
|
216
|
+
if (!Number.isInteger(sinceDays) || sinceDays < 1 || sinceDays > 365) {
|
|
217
|
+
return {
|
|
218
|
+
exitCode: 1,
|
|
219
|
+
stdout: "",
|
|
220
|
+
stderr: "--since-days must be a whole number between 1 and 365"
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const sinceIso = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1_000).toISOString();
|
|
224
|
+
const logs = await loadLocalAgentUsage({
|
|
225
|
+
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
226
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
227
|
+
sinceIso
|
|
228
|
+
});
|
|
229
|
+
const calls = args.project
|
|
230
|
+
? logs.calls.filter((call) => call.project === args.project)
|
|
231
|
+
: logs.calls;
|
|
232
|
+
const health = await loadContextHealth(calls, {
|
|
233
|
+
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
234
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
235
|
+
claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
|
|
236
|
+
codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
|
|
237
|
+
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
238
|
+
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
239
|
+
projectDir: resolve(args.path),
|
|
240
|
+
sinceIso,
|
|
241
|
+
windowDays: sinceDays
|
|
242
|
+
});
|
|
243
|
+
return ok(args.json ? JSON.stringify(health) : renderContextHealth(health));
|
|
244
|
+
}
|
|
245
|
+
function renderContextHealth(health) {
|
|
246
|
+
const status = health.status.replace("_", " ").toUpperCase();
|
|
247
|
+
const activation = health.activation;
|
|
248
|
+
const dead = health.deadContext;
|
|
249
|
+
const lines = [
|
|
250
|
+
`CONTEXT HEALTH ${status}`,
|
|
251
|
+
health.headline,
|
|
252
|
+
"",
|
|
253
|
+
`Action: ${health.action}`,
|
|
254
|
+
`Confidence: ${health.confidence}`,
|
|
255
|
+
"",
|
|
256
|
+
"Activation",
|
|
257
|
+
` Discoverable: ${activation.discoverableItems} Invoked: ${activation.explicitlyInvokedItems} MCP schema-loaded: ${activation.mcpSchemaLoadedItems}`,
|
|
258
|
+
` Hook-injected: ${activation.hookInjectedItems} Other lifecycle hooks: ${activation.lifecycleHooks} Unmeasured weight: ${activation.unmeasuredItems}`,
|
|
259
|
+
` Invocation-unobservable: ${activation.invocationUnobservableItems}`,
|
|
260
|
+
"",
|
|
261
|
+
`Never invoked among observable inventory (${dead.windowDays}d): ${dead.neverInvokedItems}/${dead.loadedItems} ` +
|
|
262
|
+
`(${dead.measuredNeverInvokedItems} measured, ${dead.unmeasuredNeverInvokedItems} unmeasured)`
|
|
263
|
+
];
|
|
264
|
+
if (health.currentSession) {
|
|
265
|
+
const session = health.currentSession;
|
|
266
|
+
lines.push(`Session: ${session.agent}${session.project ? ` · ${session.project}` : ""} · ` +
|
|
267
|
+
`${session.totalTokens.toLocaleString("en-US")} tokens · ` +
|
|
268
|
+
(session.ratioToMedian === null
|
|
269
|
+
? "no same-agent baseline"
|
|
270
|
+
: `${session.ratioToMedian}× median (${session.comparisonSessions} prior)`));
|
|
271
|
+
}
|
|
272
|
+
const churn = health.contextChurn;
|
|
273
|
+
if (churn.currentSessionEvidence === "matched") {
|
|
274
|
+
lines.push(`Context churn: ${churn.compactionEvents ?? 0} compaction event${churn.compactionEvents === 1 ? "" : "s"} · ` +
|
|
275
|
+
`${churn.repeatedReadEvents ?? 0} repeat explicit read${churn.repeatedReadEvents === 1 ? "" : "s"} · ` +
|
|
276
|
+
`${churn.currentSessionScope ?? "unknown"} session`);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
lines.push(`Context churn: current transcript ${churn.currentSessionEvidence === "not_matched" ? "not matched" : "unavailable"}`);
|
|
280
|
+
}
|
|
281
|
+
if (health.evidence.length > 0) {
|
|
282
|
+
lines.push("", "Evidence");
|
|
283
|
+
for (const evidence of health.evidence) {
|
|
284
|
+
lines.push(` - ${evidence.summary} [${evidence.confidence}; ${evidence.source}]`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
lines.push("", "Data: local agent configuration + local Claude Code/Codex transcripts; hook commands were not run.", "Privacy: this CLI run uploads nothing.");
|
|
288
|
+
return lines.join("\n");
|
|
289
|
+
}
|
|
144
290
|
function quickstartNextSteps(mode, detected) {
|
|
145
291
|
// Connect/verify guidance now lives in the readout's APPLY/VERIFY sections;
|
|
146
292
|
// this footer only carries what those can't know (detected local keys) and
|
|
@@ -149,11 +295,11 @@ function quickstartNextSteps(mode, detected) {
|
|
|
149
295
|
if (detected.length > 0) {
|
|
150
296
|
const names = detected.map((credential) => `${credential.provider} (${credential.hint})`).join(", ");
|
|
151
297
|
steps.push(`Found local key${detected.length === 1 ? "" : "s"}: ${names}`);
|
|
152
|
-
steps.push(`npx
|
|
298
|
+
steps.push(`npx aibill connect ${detected[0].provider} add official provider-reported cost (ADMIN/owner key)`);
|
|
153
299
|
}
|
|
154
300
|
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("
|
|
301
|
+
steps.push("npx aibill --group-by project see which project has the most observed activity");
|
|
302
|
+
steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://ai-spend-agent.vercel.app");
|
|
157
303
|
return steps;
|
|
158
304
|
}
|
|
159
305
|
async function readPersistedSpend(rootPath) {
|
|
@@ -246,7 +392,7 @@ async function doctorCommand(args) {
|
|
|
246
392
|
if (!hasLogs)
|
|
247
393
|
warnings.push("no real Claude Code / Codex logs found — a first run here will show DEMO sample data");
|
|
248
394
|
if (providerRefs.length === 0)
|
|
249
|
-
warnings.push("no provider admin keys detected — connect OpenAI/Anthropic
|
|
395
|
+
warnings.push("no provider admin keys detected — connect OpenAI/Anthropic to add official provider-reported cost (local logs stay API-equivalent estimates)");
|
|
250
396
|
const predictedMode = stateMode === "connected_provider"
|
|
251
397
|
? "connected provider billing"
|
|
252
398
|
: hasLogs
|
|
@@ -282,8 +428,21 @@ async function cliVersion() {
|
|
|
282
428
|
}
|
|
283
429
|
}
|
|
284
430
|
async function resetCommand(args) {
|
|
285
|
-
const rootPath =
|
|
286
|
-
|
|
431
|
+
const rootPath = await resolveSafeScanRoot(args.path);
|
|
432
|
+
let stateDir;
|
|
433
|
+
try {
|
|
434
|
+
stateDir = await resolveSafeStateDirectory(rootPath);
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
if (!isNodeError(error, "ENOENT"))
|
|
438
|
+
throw error;
|
|
439
|
+
return ok([
|
|
440
|
+
"AI Spend Analyst reset",
|
|
441
|
+
`path: ${rootPath}`,
|
|
442
|
+
"nothing to clear (no persisted spend state found)",
|
|
443
|
+
"next run will re-read your real local agent logs (or demo sample if none)."
|
|
444
|
+
].join("\n"));
|
|
445
|
+
}
|
|
287
446
|
// Clear derived spend state so a prior `scan --sample` (or stale provider
|
|
288
447
|
// sync) can never mask the next real local-log read. Leaves sources/audit.
|
|
289
448
|
const targets = ["spend.json", "mappings.json", "provider-records.json", "watch-latest.json", "watch-history.json"];
|
|
@@ -530,7 +689,10 @@ async function runWatchCycle(stateDir, args) {
|
|
|
530
689
|
}
|
|
531
690
|
}
|
|
532
691
|
}
|
|
533
|
-
const
|
|
692
|
+
const headlineRecords = mode === "connected_provider"
|
|
693
|
+
? selectProviderFinancialHeadlineRecords(records)
|
|
694
|
+
: records;
|
|
695
|
+
const summary = analyzeSpend(headlineRecords);
|
|
534
696
|
const mappings = attributeUsageRecords(records);
|
|
535
697
|
await writeLocalSpendState(stateDir, records, summary, mappings, mode);
|
|
536
698
|
const snapshot = {
|
|
@@ -549,7 +711,7 @@ async function runWatchCycle(stateDir, args) {
|
|
|
549
711
|
sourceId: "watch",
|
|
550
712
|
detail: `Watch cycle captured ${snapshot.recordCount} records totaling $${snapshot.totalUsd.toFixed(2)}.`
|
|
551
713
|
});
|
|
552
|
-
return { summary, snapshot, records, mode };
|
|
714
|
+
return { summary, snapshot, records: headlineRecords, mode };
|
|
553
715
|
}
|
|
554
716
|
function buildDeltaHeadline(previous, current) {
|
|
555
717
|
if (!previous) {
|
|
@@ -771,34 +933,68 @@ async function syncProviderCommand(args) {
|
|
|
771
933
|
enterprise: args.enterprise,
|
|
772
934
|
accountId: args.accountId
|
|
773
935
|
});
|
|
936
|
+
const priorProviderState = await readOptionalJson(join(stateDir, "provider-records.json"), { records: [] });
|
|
937
|
+
const records = [
|
|
938
|
+
...priorProviderState.records.filter((record) => record.source.provider !== result.provider),
|
|
939
|
+
...result.records
|
|
940
|
+
].sort((left, right) => left.timestamp.localeCompare(right.timestamp));
|
|
774
941
|
const registry = await readSourceRegistry(stateDir, rootPath);
|
|
775
942
|
const nextRegistry = addApprovedSource(registry, result.source);
|
|
776
|
-
const
|
|
777
|
-
const
|
|
943
|
+
const headlineRecords = selectProviderFinancialHeadlineRecords(records);
|
|
944
|
+
const summary = analyzeSpend(headlineRecords);
|
|
945
|
+
const mappings = attributeUsageRecords(records);
|
|
946
|
+
const qaByProvider = {
|
|
947
|
+
...(priorProviderState.qaByProvider ?? {}),
|
|
948
|
+
[result.provider]: result.qa
|
|
949
|
+
};
|
|
950
|
+
const coverageByProvider = {
|
|
951
|
+
...(priorProviderState.coverageByProvider ?? {}),
|
|
952
|
+
[result.provider]: result.coverage
|
|
953
|
+
};
|
|
954
|
+
const financialsByProvider = {
|
|
955
|
+
...(priorProviderState.financialsByProvider ?? {}),
|
|
956
|
+
[result.provider]: result.financials
|
|
957
|
+
};
|
|
778
958
|
await mkdir(stateDir, { recursive: true });
|
|
779
959
|
await writeJson(join(stateDir, "sources.json"), nextRegistry);
|
|
780
960
|
await writeJson(join(stateDir, "provider-records.json"), {
|
|
781
961
|
provider: result.provider,
|
|
782
962
|
fetchedAt: result.fetchedAt,
|
|
783
963
|
completeness: result.completeness,
|
|
964
|
+
coverage: result.coverage,
|
|
965
|
+
financials: result.financials,
|
|
784
966
|
sourceId: result.source.id,
|
|
785
|
-
records
|
|
786
|
-
qa: result.qa
|
|
967
|
+
records,
|
|
968
|
+
qa: result.qa,
|
|
969
|
+
qaByProvider,
|
|
970
|
+
coverageByProvider,
|
|
971
|
+
financialsByProvider
|
|
972
|
+
});
|
|
973
|
+
await writeLocalSpendState(stateDir, records, summary, mappings, "connected_provider", {
|
|
974
|
+
policy: "provider_reported_billed_cost_preferred",
|
|
975
|
+
note: "Official provider-reported billed costs are the spend headline. API-equivalent estimates remain separate evidence and are not added to that total.",
|
|
976
|
+
coverageByProvider,
|
|
977
|
+
financialsByProvider
|
|
787
978
|
});
|
|
788
|
-
await writeLocalSpendState(stateDir, result.records, summary, mappings, "connected_provider");
|
|
789
979
|
await appendAuditEvent(stateDir, {
|
|
790
980
|
timestamp: result.fetchedAt,
|
|
791
981
|
action: "source_scanned",
|
|
792
982
|
sourceId: result.source.id,
|
|
793
|
-
detail: `${args.provider} provider connector synced ${result.records.length}
|
|
983
|
+
detail: `${args.provider} provider connector synced ${result.records.length} evidence records with ${result.coverage} coverage. Auth reference only; no raw secrets stored.`
|
|
794
984
|
});
|
|
795
985
|
return ok([
|
|
796
986
|
"AI Spend Analyst Agent sync-provider",
|
|
797
987
|
`provider: ${result.provider}`,
|
|
798
988
|
`source: ${result.source.id}`,
|
|
799
989
|
`verification: ${result.source.verification}`,
|
|
800
|
-
`
|
|
801
|
-
`
|
|
990
|
+
`coverage: ${result.coverage}`,
|
|
991
|
+
`records fetched: ${result.records.length}`,
|
|
992
|
+
`headline basis: ${result.financials.headlineBasis}`,
|
|
993
|
+
`synced provider headline: $${(result.financials.headlineUsd ?? 0).toFixed(2)}`,
|
|
994
|
+
`combined headline spend: $${summary.totalUsd.toFixed(2)}`,
|
|
995
|
+
...(result.financials.apiEquivalentEstimatedUsd !== null
|
|
996
|
+
? [`API-equivalent estimate (kept separate): $${result.financials.apiEquivalentEstimatedUsd.toFixed(2)}`]
|
|
997
|
+
: []),
|
|
802
998
|
"auth: reference-only; raw secrets were not persisted or printed"
|
|
803
999
|
].join("\n"));
|
|
804
1000
|
}
|
|
@@ -854,8 +1050,8 @@ async function reportCommand(args) {
|
|
|
854
1050
|
const outBase = args.out ? resolve(rootPath, args.out) : join(stateDir, "report");
|
|
855
1051
|
const markdownPath = `${outBase}.md`;
|
|
856
1052
|
const htmlPath = `${outBase}.html`;
|
|
857
|
-
await
|
|
858
|
-
await
|
|
1053
|
+
await writeLocalReportFile(markdownPath, generateMarkdownReport(reportInput), stateDir);
|
|
1054
|
+
await writeLocalReportFile(htmlPath, generateHtmlReport(reportInput), stateDir);
|
|
859
1055
|
const artifactPaths = await writeApplyArtifacts(stateDir, reportInput);
|
|
860
1056
|
return ok([
|
|
861
1057
|
"AI Spend Analyst Agent report",
|
|
@@ -899,27 +1095,39 @@ async function resolveReceiptPath(rootPath, out) {
|
|
|
899
1095
|
return extname(resolved) ? resolved : `${resolved}.svg`;
|
|
900
1096
|
}
|
|
901
1097
|
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
|
-
|
|
1098
|
+
try {
|
|
1099
|
+
const rootPath = await resolveSafeScanRoot(args.path);
|
|
1100
|
+
const { records, mode } = await loadInstantReadData(args);
|
|
1101
|
+
const headlineRecords = mode === "connected"
|
|
1102
|
+
? selectProviderFinancialHeadlineRecords(records)
|
|
1103
|
+
: records;
|
|
1104
|
+
const summary = analyzeSpend(headlineRecords);
|
|
1105
|
+
const outPath = await resolveReceiptPath(rootPath, args.out);
|
|
1106
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
1107
|
+
await writeSafeStateText(dirname(outPath), basename(outPath), generateReportCardSvg({ summary, records: headlineRecords, mode }));
|
|
1108
|
+
const dataLine = mode === "demo"
|
|
1109
|
+
? "data: DEMO sample data — run without --sample on a machine with Claude Code/Codex logs for your own numbers."
|
|
1110
|
+
: mode === "local-logs"
|
|
1111
|
+
? "data: local Claude Code/Codex logs priced at API-equivalent rates."
|
|
1112
|
+
: "data: connected local spend state with provider-reported cost kept separate from API-equivalent estimates.";
|
|
1113
|
+
return ok([
|
|
1114
|
+
"Your AI Receipt — a shareable, redacted spend card (no client/project/user names).",
|
|
1115
|
+
`receipt: ${outPath}`,
|
|
1116
|
+
dataLine,
|
|
1117
|
+
"",
|
|
1118
|
+
"Caption to share:",
|
|
1119
|
+
generateReportCardCaption({ summary, records: headlineRecords, mode }),
|
|
1120
|
+
"",
|
|
1121
|
+
"privacy: rendered locally; only totals, modeled opportunities, and model-level investigations are included."
|
|
1122
|
+
].join("\n"));
|
|
1123
|
+
}
|
|
1124
|
+
catch (error) {
|
|
1125
|
+
return {
|
|
1126
|
+
exitCode: 1,
|
|
1127
|
+
stdout: "",
|
|
1128
|
+
stderr: `Couldn't write the report card: ${error instanceof Error ? error.message : String(error)}`
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
923
1131
|
}
|
|
924
1132
|
async function applyArtifactCommand(args) {
|
|
925
1133
|
const rootPath = resolve(args.path);
|
|
@@ -1003,8 +1211,11 @@ async function buildReportInput(stateDir, rootPath) {
|
|
|
1003
1211
|
const deadContext = spendState.mode === "local_logs"
|
|
1004
1212
|
? await loadDeadContext({
|
|
1005
1213
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
1214
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
1006
1215
|
claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
|
|
1216
|
+
codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
|
|
1007
1217
|
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
1218
|
+
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
1008
1219
|
projectDir: rootPath,
|
|
1009
1220
|
includeAllProjectMcp: true,
|
|
1010
1221
|
sinceIso: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
@@ -1023,7 +1234,9 @@ async function buildReportInput(stateDir, rootPath) {
|
|
|
1023
1234
|
detectedPlans,
|
|
1024
1235
|
// Evidence ledger is built from the SAME records as the confidence
|
|
1025
1236
|
// breakdown so the two sections can never contradict each other.
|
|
1026
|
-
allRecords: spendState.
|
|
1237
|
+
allRecords: spendState.mode === "connected_provider"
|
|
1238
|
+
? selectProviderFinancialHeadlineRecords(spendState.records ?? [])
|
|
1239
|
+
: spendState.records ?? [],
|
|
1027
1240
|
dataMode: spendState.mode,
|
|
1028
1241
|
discovery,
|
|
1029
1242
|
mappings: mappings ?? [],
|
|
@@ -1039,6 +1252,7 @@ function emptyDiscovery(rootPath) {
|
|
|
1039
1252
|
rootPath,
|
|
1040
1253
|
scannedFiles: 0,
|
|
1041
1254
|
skippedDirectories: [],
|
|
1255
|
+
skippedSymlinks: [],
|
|
1042
1256
|
unreadablePaths: [],
|
|
1043
1257
|
signals: [],
|
|
1044
1258
|
secretsDetected: [],
|
|
@@ -1053,11 +1267,11 @@ async function writeApplyArtifacts(stateDir, reportInput) {
|
|
|
1053
1267
|
verificationPlan: join(stateDir, "ai-spend-verify-plan.md"),
|
|
1054
1268
|
demoPackage: join(stateDir, "demo-package.md")
|
|
1055
1269
|
};
|
|
1056
|
-
await
|
|
1057
|
-
await
|
|
1058
|
-
await
|
|
1059
|
-
await
|
|
1060
|
-
await
|
|
1270
|
+
await writeSafeStateText(stateDir, basename(paths.codingPrompt), generateApplyArtifactMarkdown(reportInput));
|
|
1271
|
+
await writeSafeStateText(stateDir, basename(paths.actionPlan), generateActionPlanMarkdown(reportInput));
|
|
1272
|
+
await writeSafeStateText(stateDir, basename(paths.policyConfigDraft), generatePolicyConfigDraftMarkdown(reportInput));
|
|
1273
|
+
await writeSafeStateText(stateDir, basename(paths.verificationPlan), generateVerificationPlanMarkdown(reportInput));
|
|
1274
|
+
await writeSafeStateText(stateDir, basename(paths.demoPackage), generateDemoPackageMarkdown(reportInput));
|
|
1061
1275
|
return paths;
|
|
1062
1276
|
}
|
|
1063
1277
|
function parseArgs(argv) {
|
|
@@ -1086,6 +1300,10 @@ function parseArgs(argv) {
|
|
|
1086
1300
|
parsed.noColor = true;
|
|
1087
1301
|
continue;
|
|
1088
1302
|
}
|
|
1303
|
+
if (arg === "--json") {
|
|
1304
|
+
parsed.json = true;
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1089
1307
|
if (arg === "--ignore-state") {
|
|
1090
1308
|
parsed.ignoreState = true;
|
|
1091
1309
|
continue;
|
|
@@ -1098,6 +1316,14 @@ function parseArgs(argv) {
|
|
|
1098
1316
|
}
|
|
1099
1317
|
continue;
|
|
1100
1318
|
}
|
|
1319
|
+
if (arg === "--since-days") {
|
|
1320
|
+
const next = rest[index + 1];
|
|
1321
|
+
if (next) {
|
|
1322
|
+
parsed.sinceDays = Number(next);
|
|
1323
|
+
index += 1;
|
|
1324
|
+
}
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
1101
1327
|
if (arg === "--group-by") {
|
|
1102
1328
|
const next = rest[index + 1];
|
|
1103
1329
|
if (isGroupByDimension(next)) {
|
|
@@ -1318,8 +1544,13 @@ function sanitizeSecretishError(message, authReference) {
|
|
|
1318
1544
|
}
|
|
1319
1545
|
return sanitized;
|
|
1320
1546
|
}
|
|
1321
|
-
async function writeLocalSpendState(stateDir, records, summary, mappings, mode) {
|
|
1322
|
-
await writeJson(join(stateDir, "spend.json"), {
|
|
1547
|
+
async function writeLocalSpendState(stateDir, records, summary, mappings, mode, accounting) {
|
|
1548
|
+
await writeJson(join(stateDir, "spend.json"), {
|
|
1549
|
+
mode,
|
|
1550
|
+
records,
|
|
1551
|
+
summary,
|
|
1552
|
+
...(accounting ? { accounting } : {})
|
|
1553
|
+
});
|
|
1323
1554
|
await writeJson(join(stateDir, "mappings.json"), mappings);
|
|
1324
1555
|
}
|
|
1325
1556
|
async function readSourceRegistry(stateDir, rootPath) {
|
|
@@ -1363,7 +1594,7 @@ async function appendAuditEvent(stateDir, event) {
|
|
|
1363
1594
|
await writeJson(join(stateDir, "audit-log.json"), createScanAuditLog([...auditLog.events, event].slice(-500)));
|
|
1364
1595
|
}
|
|
1365
1596
|
async function readJson(path) {
|
|
1366
|
-
return JSON.parse(await
|
|
1597
|
+
return JSON.parse(await readSafeStateText(dirname(path), basename(path)));
|
|
1367
1598
|
}
|
|
1368
1599
|
async function readOptionalJson(path, fallback) {
|
|
1369
1600
|
try {
|
|
@@ -1374,42 +1605,52 @@ async function readOptionalJson(path, fallback) {
|
|
|
1374
1605
|
}
|
|
1375
1606
|
}
|
|
1376
1607
|
async function writeJson(path, value) {
|
|
1377
|
-
await
|
|
1608
|
+
await writeSafeStateText(dirname(path), basename(path), `${JSON.stringify(value, null, 2)}\n`);
|
|
1609
|
+
}
|
|
1610
|
+
async function writeLocalReportFile(path, contents, _stateDir) {
|
|
1611
|
+
await writeSafeStateText(dirname(path), basename(path), contents);
|
|
1612
|
+
}
|
|
1613
|
+
function isNodeError(error, code) {
|
|
1614
|
+
return error instanceof Error && error.code === code;
|
|
1378
1615
|
}
|
|
1379
1616
|
function ok(stdout) {
|
|
1380
1617
|
return { exitCode: 0, stdout, stderr: "" };
|
|
1381
1618
|
}
|
|
1382
1619
|
function helpText() {
|
|
1383
1620
|
return [
|
|
1384
|
-
"
|
|
1621
|
+
"aibill — your AI cost and usage evidence in one private view",
|
|
1385
1622
|
"",
|
|
1386
1623
|
"Run with no command for an instant, zero-key demo:",
|
|
1387
|
-
" ai-spend-agent Show
|
|
1624
|
+
" ai-spend-agent Show available AI cost/value evidence (sample or local data)",
|
|
1388
1625
|
" ai-spend-agent --group-by agent Drill down by source|model|client|project|agent|user|workspace|apiKey",
|
|
1389
1626
|
" ai-spend-agent --plan <id> Declare your plan when auto-detection can't (claude-max-5x|claude-max-20x|claude-pro|chatgpt-plus|chatgpt-pro)",
|
|
1390
1627
|
"",
|
|
1391
|
-
"
|
|
1392
|
-
" ai-spend-agent connect openai
|
|
1393
|
-
" ai-spend-agent connect anthropic
|
|
1628
|
+
"Add official provider-reported cost (ADMIN/owner-gated):",
|
|
1629
|
+
" ai-spend-agent connect openai Requires an org-owner Admin key",
|
|
1630
|
+
" ai-spend-agent connect anthropic Requires an Admin key",
|
|
1394
1631
|
" ai-spend-agent connect cursor Upgrade: requires a Cursor team-admin key (Business plan)",
|
|
1395
1632
|
" ai-spend-agent connect github-copilot Upgrade: requires a GitHub billing-admin token",
|
|
1396
|
-
" ai-spend-agent sync-provider ... Pull
|
|
1633
|
+
" ai-spend-agent sync-provider ... Pull provider cost/usage evidence via a local env: reference (never a raw key)",
|
|
1397
1634
|
"",
|
|
1398
1635
|
"Watch continuously (deltas + anomalies):",
|
|
1399
1636
|
" watch [--interval N] Re-run analysis on an interval and report deltas/anomalies",
|
|
1400
1637
|
" [--cycles N] [--group-by ...] --cycles 0 runs forever; default 1 (cron-friendly)",
|
|
1401
1638
|
"",
|
|
1402
1639
|
"Other commands:",
|
|
1640
|
+
" --version, -v Print the package version without reading local data",
|
|
1403
1641
|
" init [--path <dir>] Initialize local state",
|
|
1404
1642
|
" doctor Launch-grade diagnostics: data mode, logs found, provider keys, warnings",
|
|
1405
1643
|
" reset [--path <dir>] Clear persisted spend state (so sample state can't mask real logs)",
|
|
1406
1644
|
" --ignore-state On the default/quickstart run, ignore persisted spend.json for this run",
|
|
1407
1645
|
" scan [--path <dir>] Scan a local workspace for AI usage signals",
|
|
1408
1646
|
" scan --sample Include deterministic sample spend analysis",
|
|
1409
|
-
" quickstart [--sample] Plain-English
|
|
1647
|
+
" quickstart [--sample] Plain-English local readout (alias of the default run)",
|
|
1410
1648
|
" [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: model",
|
|
1411
1649
|
" report [--out <name>] Generate local Markdown and HTML reports",
|
|
1412
1650
|
" report-card [--out f.svg] Write your AI Receipt — a redacted, shareable SVG + caption",
|
|
1651
|
+
" glance [--project <name>] [--plan <id>] Emit the local, machine-readable Glance snapshot JSON",
|
|
1652
|
+
" context [--project <name>] [--since-days N] Show hook-aware Context Health in the terminal",
|
|
1653
|
+
" [--json] Emit the same canonical Context Health object used by MCP and Glance",
|
|
1413
1654
|
" apply Print the paste-ready coding-agent prompt + write action/policy/verification plans",
|
|
1414
1655
|
" apply-artifact Same as `apply` (long form)",
|
|
1415
1656
|
"",
|
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.7",
|
|
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.7",
|
|
58
|
+
"@agent-finops/report": "0.5.7",
|
|
56
59
|
"yocto-spinner": "^1.2.0"
|
|
57
60
|
}
|
|
58
61
|
}
|