ai-spend-agent 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -30
  3. package/dist/index.js +298 -57
  4. package/package.json +10 -5
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Futura Studio LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,40 +1,19 @@
1
1
  # ai-spend-agent
2
2
 
3
- **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,9 +1,9 @@
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
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 summary = analyzeSpend(records);
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 ai-spend-agent connect ${detected[0].provider} use it note: COST data needs an ADMIN/owner key`);
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 burns the most");
156
- steps.push("Want this watched while your laptop is off? Hosted beta waitlist: https://ai-spend-agent.vercel.app");
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 for VERIFIED billing (local logs stay ESTIMATED)");
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 = resolve(args.path);
286
- const stateDir = join(rootPath, ".ai-spend-agent");
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 summary = analyzeSpend(records);
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 summary = analyzeSpend(result.records);
777
- const mappings = attributeUsageRecords(result.records);
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: result.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} verified records. Auth reference only; no raw secrets stored.`
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
- `verified records: ${result.records.length}`,
801
- `total spend: $${summary.totalUsd.toFixed(2)}`,
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 writeFile(markdownPath, generateMarkdownReport(reportInput), "utf8");
858
- await writeFile(htmlPath, generateHtmlReport(reportInput), "utf8");
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
- 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"));
1095
+ try {
1096
+ const rootPath = await resolveSafeScanRoot(args.path);
1097
+ const { records, mode } = await loadInstantReadData(args);
1098
+ const headlineRecords = mode === "connected"
1099
+ ? selectProviderFinancialHeadlineRecords(records)
1100
+ : records;
1101
+ const summary = analyzeSpend(headlineRecords);
1102
+ const outPath = await resolveReceiptPath(rootPath, args.out);
1103
+ await mkdir(dirname(outPath), { recursive: true });
1104
+ await writeSafeStateText(dirname(outPath), basename(outPath), generateReportCardSvg({ summary, records: headlineRecords, mode }));
1105
+ const dataLine = mode === "demo"
1106
+ ? "data: DEMO sample data — run without --sample on a machine with Claude Code/Codex logs for your own numbers."
1107
+ : mode === "local-logs"
1108
+ ? "data: local Claude Code/Codex logs priced at API-equivalent rates."
1109
+ : "data: connected local spend state with provider-reported cost kept separate from API-equivalent estimates.";
1110
+ return ok([
1111
+ "Your AI Receipt — a shareable, redacted spend card (no client/project/user names).",
1112
+ `receipt: ${outPath}`,
1113
+ dataLine,
1114
+ "",
1115
+ "Caption to share:",
1116
+ generateReportCardCaption({ summary, records: headlineRecords, mode }),
1117
+ "",
1118
+ "privacy: rendered locally; only totals, modeled opportunities, and model-level investigations are included."
1119
+ ].join("\n"));
1120
+ }
1121
+ catch (error) {
1122
+ return {
1123
+ exitCode: 1,
1124
+ stdout: "",
1125
+ stderr: `Couldn't write the report card: ${error instanceof Error ? error.message : String(error)}`
1126
+ };
1127
+ }
923
1128
  }
924
1129
  async function applyArtifactCommand(args) {
925
1130
  const rootPath = resolve(args.path);
@@ -963,6 +1168,10 @@ async function buildReportInput(stateDir, rootPath) {
963
1168
  const needsFreshLogs = !spendState?.summary ||
964
1169
  !spendState.records ||
965
1170
  spendState.records.length === 0 ||
1171
+ // Pre-0.5.3 state did not persist a mode. Treat it as a cache and
1172
+ // re-detect local logs so report/apply cannot route through the agency
1173
+ // artifact path with stale or demo-shaped records.
1174
+ spendState.mode === undefined ||
966
1175
  spendState.mode === "local_logs" ||
967
1176
  // Mislabeled state (local-log records stamped connected by a past bug)
968
1177
  // must be superseded by a fresh read, not trusted.
@@ -999,8 +1208,11 @@ async function buildReportInput(stateDir, rootPath) {
999
1208
  const deadContext = spendState.mode === "local_logs"
1000
1209
  ? await loadDeadContext({
1001
1210
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
1211
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
1002
1212
  claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
1213
+ codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
1003
1214
  claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
1215
+ claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
1004
1216
  projectDir: rootPath,
1005
1217
  includeAllProjectMcp: true,
1006
1218
  sinceIso: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
@@ -1019,7 +1231,9 @@ async function buildReportInput(stateDir, rootPath) {
1019
1231
  detectedPlans,
1020
1232
  // Evidence ledger is built from the SAME records as the confidence
1021
1233
  // breakdown so the two sections can never contradict each other.
1022
- allRecords: spendState.records ?? [],
1234
+ allRecords: spendState.mode === "connected_provider"
1235
+ ? selectProviderFinancialHeadlineRecords(spendState.records ?? [])
1236
+ : spendState.records ?? [],
1023
1237
  dataMode: spendState.mode,
1024
1238
  discovery,
1025
1239
  mappings: mappings ?? [],
@@ -1035,6 +1249,7 @@ function emptyDiscovery(rootPath) {
1035
1249
  rootPath,
1036
1250
  scannedFiles: 0,
1037
1251
  skippedDirectories: [],
1252
+ skippedSymlinks: [],
1038
1253
  unreadablePaths: [],
1039
1254
  signals: [],
1040
1255
  secretsDetected: [],
@@ -1049,11 +1264,11 @@ async function writeApplyArtifacts(stateDir, reportInput) {
1049
1264
  verificationPlan: join(stateDir, "ai-spend-verify-plan.md"),
1050
1265
  demoPackage: join(stateDir, "demo-package.md")
1051
1266
  };
1052
- await writeFile(paths.codingPrompt, generateApplyArtifactMarkdown(reportInput), "utf8");
1053
- await writeFile(paths.actionPlan, generateActionPlanMarkdown(reportInput), "utf8");
1054
- await writeFile(paths.policyConfigDraft, generatePolicyConfigDraftMarkdown(reportInput), "utf8");
1055
- await writeFile(paths.verificationPlan, generateVerificationPlanMarkdown(reportInput), "utf8");
1056
- await writeFile(paths.demoPackage, generateDemoPackageMarkdown(reportInput), "utf8");
1267
+ await writeSafeStateText(stateDir, basename(paths.codingPrompt), generateApplyArtifactMarkdown(reportInput));
1268
+ await writeSafeStateText(stateDir, basename(paths.actionPlan), generateActionPlanMarkdown(reportInput));
1269
+ await writeSafeStateText(stateDir, basename(paths.policyConfigDraft), generatePolicyConfigDraftMarkdown(reportInput));
1270
+ await writeSafeStateText(stateDir, basename(paths.verificationPlan), generateVerificationPlanMarkdown(reportInput));
1271
+ await writeSafeStateText(stateDir, basename(paths.demoPackage), generateDemoPackageMarkdown(reportInput));
1057
1272
  return paths;
1058
1273
  }
1059
1274
  function parseArgs(argv) {
@@ -1082,6 +1297,10 @@ function parseArgs(argv) {
1082
1297
  parsed.noColor = true;
1083
1298
  continue;
1084
1299
  }
1300
+ if (arg === "--json") {
1301
+ parsed.json = true;
1302
+ continue;
1303
+ }
1085
1304
  if (arg === "--ignore-state") {
1086
1305
  parsed.ignoreState = true;
1087
1306
  continue;
@@ -1094,6 +1313,14 @@ function parseArgs(argv) {
1094
1313
  }
1095
1314
  continue;
1096
1315
  }
1316
+ if (arg === "--since-days") {
1317
+ const next = rest[index + 1];
1318
+ if (next) {
1319
+ parsed.sinceDays = Number(next);
1320
+ index += 1;
1321
+ }
1322
+ continue;
1323
+ }
1097
1324
  if (arg === "--group-by") {
1098
1325
  const next = rest[index + 1];
1099
1326
  if (isGroupByDimension(next)) {
@@ -1314,8 +1541,13 @@ function sanitizeSecretishError(message, authReference) {
1314
1541
  }
1315
1542
  return sanitized;
1316
1543
  }
1317
- async function writeLocalSpendState(stateDir, records, summary, mappings, mode) {
1318
- await writeJson(join(stateDir, "spend.json"), { mode, records, summary });
1544
+ async function writeLocalSpendState(stateDir, records, summary, mappings, mode, accounting) {
1545
+ await writeJson(join(stateDir, "spend.json"), {
1546
+ mode,
1547
+ records,
1548
+ summary,
1549
+ ...(accounting ? { accounting } : {})
1550
+ });
1319
1551
  await writeJson(join(stateDir, "mappings.json"), mappings);
1320
1552
  }
1321
1553
  async function readSourceRegistry(stateDir, rootPath) {
@@ -1359,7 +1591,7 @@ async function appendAuditEvent(stateDir, event) {
1359
1591
  await writeJson(join(stateDir, "audit-log.json"), createScanAuditLog([...auditLog.events, event].slice(-500)));
1360
1592
  }
1361
1593
  async function readJson(path) {
1362
- return JSON.parse(await readFile(path, "utf8"));
1594
+ return JSON.parse(await readSafeStateText(dirname(path), basename(path)));
1363
1595
  }
1364
1596
  async function readOptionalJson(path, fallback) {
1365
1597
  try {
@@ -1370,14 +1602,20 @@ async function readOptionalJson(path, fallback) {
1370
1602
  }
1371
1603
  }
1372
1604
  async function writeJson(path, value) {
1373
- await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
1605
+ await writeSafeStateText(dirname(path), basename(path), `${JSON.stringify(value, null, 2)}\n`);
1606
+ }
1607
+ async function writeLocalReportFile(path, contents, _stateDir) {
1608
+ await writeSafeStateText(dirname(path), basename(path), contents);
1609
+ }
1610
+ function isNodeError(error, code) {
1611
+ return error instanceof Error && error.code === code;
1374
1612
  }
1375
1613
  function ok(stdout) {
1376
1614
  return { exitCode: 0, stdout, stderr: "" };
1377
1615
  }
1378
1616
  function helpText() {
1379
1617
  return [
1380
- "AI Spend Analyst — your AI spend in one view in 90 seconds",
1618
+ "aibill — your AI cost and usage evidence in one private view",
1381
1619
  "",
1382
1620
  "Run with no command for an instant, zero-key demo:",
1383
1621
  " ai-spend-agent Show where your AI money goes (sample/auto-detected data)",
@@ -1389,7 +1627,7 @@ function helpText() {
1389
1627
  " ai-spend-agent connect anthropic Self-serve in ~2 min with an Admin key",
1390
1628
  " ai-spend-agent connect cursor Upgrade: requires a Cursor team-admin key (Business plan)",
1391
1629
  " ai-spend-agent connect github-copilot Upgrade: requires a GitHub billing-admin token",
1392
- " ai-spend-agent sync-provider ... Pull verified cost via a local env: reference (never a raw key)",
1630
+ " ai-spend-agent sync-provider ... Pull provider cost/usage evidence via a local env: reference (never a raw key)",
1393
1631
  "",
1394
1632
  "Watch continuously (deltas + anomalies):",
1395
1633
  " watch [--interval N] Re-run analysis on an interval and report deltas/anomalies",
@@ -1402,10 +1640,13 @@ function helpText() {
1402
1640
  " --ignore-state On the default/quickstart run, ignore persisted spend.json for this run",
1403
1641
  " scan [--path <dir>] Scan a local workspace for AI usage signals",
1404
1642
  " scan --sample Include deterministic sample spend analysis",
1405
- " quickstart [--sample] Plain-English 90-second readout (alias of the default run)",
1643
+ " quickstart [--sample] Plain-English local readout (alias of the default run)",
1406
1644
  " [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: model",
1407
1645
  " report [--out <name>] Generate local Markdown and HTML reports",
1408
1646
  " report-card [--out f.svg] Write your AI Receipt — a redacted, shareable SVG + caption",
1647
+ " glance [--project <name>] [--plan <id>] Emit the local, machine-readable Glance snapshot JSON",
1648
+ " context [--project <name>] [--since-days N] Show hook-aware Context Health in the terminal",
1649
+ " [--json] Emit the same canonical Context Health object used by MCP and Glance",
1409
1650
  " apply Print the paste-ready coding-agent prompt + write action/policy/verification plans",
1410
1651
  " apply-artifact Same as `apply` (long form)",
1411
1652
  "",
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "ai-spend-agent",
3
- "version": "0.5.4",
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.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": "./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",
@@ -34,7 +37,9 @@
34
37
  "cursor",
35
38
  "llm",
36
39
  "token",
40
+ "token-usage",
37
41
  "billing",
42
+ "ai-cost-tracker",
38
43
  "cli",
39
44
  "mcp",
40
45
  "claude-code-skills",
@@ -49,8 +54,8 @@
49
54
  "prepack": "npm run build"
50
55
  },
51
56
  "dependencies": {
52
- "@agent-finops/core": "0.5.4",
53
- "@agent-finops/report": "0.5.4",
57
+ "@agent-finops/core": "0.5.6",
58
+ "@agent-finops/report": "0.5.6",
54
59
  "yocto-spinner": "^1.2.0"
55
60
  }
56
61
  }