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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -30
  3. package/dist/index.js +302 -61
  4. 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
- **Your AI spend in one view, in 90 seconds — local-first, no signup.**
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
- If you use **Claude Code or Codex**, that one command reads the session logs
10
- already on your machine and shows your real usage: total dollars *estimated at
11
- API-equivalent rates*, where the money goes, a ranked "where to cut" list, a
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
- No logs? You get an instant, clearly-labeled demo on sample data.
16
-
17
- ## Connect real billing (optional, ~2 min)
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, writeFile } from "node:fs/promises";
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 summary = analyzeSpend(records);
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 ai-spend-agent connect ${detected[0].provider} use it note: COST data needs an ADMIN/owner key`);
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 burns the most");
156
- steps.push("Want this watched while your laptop is off? Hosted beta waitlist: https://ai-spend-agent.vercel.app");
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 for VERIFIED billing (local logs stay ESTIMATED)");
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 = resolve(args.path);
286
- const stateDir = join(rootPath, ".ai-spend-agent");
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 summary = analyzeSpend(records);
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 summary = analyzeSpend(result.records);
777
- const mappings = attributeUsageRecords(result.records);
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: result.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} verified records. Auth reference only; no raw secrets stored.`
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
- `verified records: ${result.records.length}`,
801
- `total spend: $${summary.totalUsd.toFixed(2)}`,
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 writeFile(markdownPath, generateMarkdownReport(reportInput), "utf8");
858
- await writeFile(htmlPath, generateHtmlReport(reportInput), "utf8");
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
- const rootPath = resolve(args.path);
903
- const { records, mode } = await loadInstantReadData(args);
904
- const summary = analyzeSpend(records);
905
- const outPath = await resolveReceiptPath(rootPath, args.out);
906
- await mkdir(dirname(outPath), { recursive: true });
907
- await writeFile(outPath, generateReportCardSvg({ summary, records }), "utf8");
908
- const dataLine = mode === "demo"
909
- ? "data: DEMO sample data — run without --sample on a machine with Claude Code/Codex logs for your own numbers."
910
- : mode === "local-logs"
911
- ? "data: local Claude Code/Codex logs priced at API-equivalent rates."
912
- : "data: connected local spend state.";
913
- return ok([
914
- "Your AI Receipt — a shareable, redacted spend card (no client/project/user names).",
915
- `receipt: ${outPath}`,
916
- dataLine,
917
- "",
918
- "Caption to share:",
919
- generateReportCardCaption({ summary, records }),
920
- "",
921
- "privacy: rendered locally; only totals, savings, and model-level cuts are included."
922
- ].join("\n"));
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.records ?? [],
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 writeFile(paths.codingPrompt, generateApplyArtifactMarkdown(reportInput), "utf8");
1057
- await writeFile(paths.actionPlan, generateActionPlanMarkdown(reportInput), "utf8");
1058
- await writeFile(paths.policyConfigDraft, generatePolicyConfigDraftMarkdown(reportInput), "utf8");
1059
- await writeFile(paths.verificationPlan, generateVerificationPlanMarkdown(reportInput), "utf8");
1060
- await writeFile(paths.demoPackage, generateDemoPackageMarkdown(reportInput), "utf8");
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"), { mode, records, summary });
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 readFile(path, "utf8"));
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 writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
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
- "AI Spend Analyst — your AI spend in one view in 90 seconds",
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 where your AI money goes (sample/auto-detected data)",
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
- "Connect your real spend (cost data is ADMIN/owner-gated):",
1392
- " ai-spend-agent connect openai Self-serve in ~2 min with an org-owner Admin key",
1393
- " ai-spend-agent connect anthropic Self-serve in ~2 min with an Admin key",
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 verified cost via a local env: reference (never a raw key)",
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 90-second readout (alias of the default run)",
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.5",
4
- "description": "Your AI spend in one view, in 90 seconds local-first CLI for OpenAI, Anthropic, Cursor, Copilot + Claude Code/Codex session logs, with a ranked savings cut list, and flags the tools your agent loads but never uses (dead context).",
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": "./dist/index.js"
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.5",
55
- "@agent-finops/report": "0.5.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
  }