ai-spend-agent 0.7.3 → 0.8.1

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/dist/index.js CHANGED
@@ -4,18 +4,38 @@ import { mkdir, readFile, rm, stat } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { basename, dirname, extname, join, resolve } from "node:path";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
- import { analyzeSpend, attributeUsageRecords, buildUsageGlance, buildActivitySnapshot, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, invalidateConnectedSpendTrustReceipt, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, writeSafeStateText, verifyConnectedSpendTrustReceipt, verifyConnectedSourceRegistryTrustReceipt, writeConnectedSpendTrustReceipt, loadDeadContext, sampleDeadContext, latestObservedWorkingDirectory, downgradeSampleUsageEvidence, isBundledSampleUsage, loadLocalAgentUsage, loadLocalAgentFinancialUsage, localAgentFormatDescriptors, localAgentFormatLabel, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, normalizeSourceRegistry, downgradeUntrustedSourceRegistryClaims, buildSourceStatuses, slugifySourceId, financialEvidenceForRecords, formatSourceStatuses, readActivitySnapshot, recordActivitySnapshotRefreshFailure, sourceStatusDefinitions, writeActivitySnapshot } from "@agent-finops/core";
7
+ import { analyzeSpend, attributeUsageRecords, buildUsageGlance, buildActivitySnapshot, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, invalidateConnectedSpendTrustReceipt, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, summarizeProviderFinancials, providerFinancialCompleteness, writeSafeStateText, verifyConnectedSpendTrustReceipt, verifyConnectedSourceRegistryTrustReceipt, writeConnectedSpendTrustReceipt, loadDeadContext, sampleDeadContext, latestObservedWorkingDirectory, downgradeSampleUsageEvidence, isBundledSampleUsage, loadLocalAgentUsage, loadLocalAgentFinancialUsage, localAgentFormatDescriptors, localAgentFormatLabel, localAgentFormatSupports, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createProviderConnection, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, normalizeSourceRegistry, downgradeUntrustedSourceRegistryClaims, buildSourceStatuses, applyProviderContractGate, applyProviderContractGateToSourceRegistry, slugifySourceId, financialEvidenceForRecords, formatSourceStatuses, readActivitySnapshot, recordActivitySnapshotRefreshFailure, sourceStatusDefinitions, writeActivitySnapshot } from "@agent-finops/core";
8
8
  import { StatuslineInstallerError, installClaudeStatusline, uninstallClaudeStatusline } from "./statuslineInstaller.js";
9
9
  import { readStatuslineCache, renderStatusline } from "./statuslineRuntime.js";
10
10
  import { generateActionPlanMarkdown, generateApplyArtifactMarkdown, generateDemoPackageMarkdown, generateHtmlReport, generateMarkdownReport, generatePlainEnglishSummary, generatePolicyConfigDraftMarkdown, generateReportCardCaption, generateReportCardSvg, generateVerificationPlanMarkdown, groupByDimensions } from "@agent-finops/report";
11
11
  export async function runCli(argv = process.argv.slice(2), runtime = {}) {
12
- if (argv.includes("--version") || argv.includes("-v")) {
12
+ if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-v")) {
13
13
  return ok(await cliVersion());
14
14
  }
15
15
  if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
16
16
  return ok(helpText());
17
17
  }
18
18
  const args = parseArgs(argv);
19
+ if (args.parseErrors.length > 0) {
20
+ return {
21
+ exitCode: 1,
22
+ stdout: "",
23
+ stderr: [
24
+ ...args.parseErrors.map((error) => `Invalid arguments: ${sanitizeSecretishError(error)}`),
25
+ "Run `npx aibill --help` to see supported commands and flags."
26
+ ].join("\n")
27
+ };
28
+ }
29
+ if (args.json && args.command !== "context" && args.command !== "context-health" && args.command !== "glance") {
30
+ return {
31
+ exitCode: 1,
32
+ stdout: "",
33
+ stderr: [
34
+ "--json is not available for the main receipt yet; no text receipt was substituted.",
35
+ "Use `npx aibill context --json` for the canonical Context Health object or `npx aibill glance` for the canonical Glance snapshot."
36
+ ].join("\n")
37
+ };
38
+ }
19
39
  if (args.groupByInvalid) {
20
40
  return {
21
41
  exitCode: 1,
@@ -23,10 +43,8 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
23
43
  stderr: `--group-by needs a dimension: ${groupByDimensions.join("|")}\nexample: npx aibill --group-by project`
24
44
  };
25
45
  }
26
- // Zero-key instant demo is the DEFAULT first run. Running `ai-spend-agent`
27
- // with no subcommand (or `npx ai-spend-agent`), or with only flags such as
28
- // `--group-by agent`, lands the wow immediately on sample / auto-detected
29
- // local data — no credential required.
46
+ // Running with no subcommand reads only evidence available on this machine.
47
+ // Illustrative records are reachable only through an explicit --sample.
30
48
  if (!args.command || args.command.startsWith("--") || args.command === "quickstart" || args.command === "demo") {
31
49
  return quickstartCommand(args);
32
50
  }
@@ -84,14 +102,24 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
84
102
  return {
85
103
  exitCode: 1,
86
104
  stdout: "",
87
- stderr: `Unknown command: ${args.command}\n${helpText()}`
105
+ stderr: `Unknown command: ${sanitizeSecretishError(args.command)}\n${helpText()}`
88
106
  };
89
107
  }
90
108
  async function quickstartCommand(args) {
91
109
  const sinceDays = args.sinceDays ?? 30;
92
110
  if (!validSinceDays(sinceDays))
93
111
  return invalidSinceDaysResult();
112
+ if (args.plan && !planOverrideFromFlag(args.plan)) {
113
+ return {
114
+ exitCode: 1,
115
+ stdout: "",
116
+ stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
117
+ };
118
+ }
94
119
  const { records, mode, warnings, providerCoverage, codexInvocationFiles } = await loadInstantReadData(args);
120
+ if (records.length === 0) {
121
+ return noEvidenceResult("receipt", warnings, sinceDays);
122
+ }
95
123
  const summaryRecords = mode === "connected"
96
124
  ? selectProviderFinancialHeadlineRecords(records)
97
125
  : records;
@@ -100,6 +128,7 @@ async function quickstartCommand(args) {
100
128
  // ("which project burns my plan"); demo/connected keep by-model.
101
129
  const groupBy = args.groupBy ?? (mode === "local-logs" ? "project" : "model");
102
130
  const color = args.noColor ? false : undefined;
131
+ const outputWidth = terminalOutputWidth();
103
132
  // Persona: --plan override wins; otherwise read the plans the coding agents
104
133
  // themselves persisted locally (read-only, whitelisted fields, no network).
105
134
  let detectedPlans;
@@ -114,7 +143,7 @@ async function quickstartCommand(args) {
114
143
  return {
115
144
  exitCode: 1,
116
145
  stdout: "",
117
- stderr: `Unknown --plan "${args.plan}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
146
+ stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
118
147
  };
119
148
  }
120
149
  detectedPlans = [override];
@@ -173,12 +202,17 @@ async function quickstartCommand(args) {
173
202
  nextSteps,
174
203
  deadContext,
175
204
  detectedPlans,
205
+ width: outputWidth,
176
206
  // An explicit --group-by is a drill-down question: answer with just the
177
207
  // table + window instead of repeating the whole readout.
178
- view: args.groupBy ? "breakdown" : "full"
208
+ view: args.groupBy ? "breakdown" : args.full ? "full" : "compact"
179
209
  });
180
- const header = [` ${dataModeBanner(mode)}`, ...warnings.map((warning) => ` ! ${warning}`)].join("\n");
181
- return ok(`${header}\n${summaryText}`);
210
+ const detailedView = Boolean(args.groupBy || args.full);
211
+ const header = [
212
+ ...(detailedView ? wrapCliHeader(dataModeBanner(mode, summaryRecords), " ", outputWidth) : []),
213
+ ...warnings.flatMap((warning) => wrapCliHeader(warning, " ! ", outputWidth))
214
+ ].join("\n");
215
+ return ok(header ? `${header}\n${summaryText}` : summaryText);
182
216
  }
183
217
  async function glanceCommand(args) {
184
218
  const sinceDays = args.sinceDays ?? 30;
@@ -187,12 +221,14 @@ async function glanceCommand(args) {
187
221
  const logs = await loadLocalAgentUsage({
188
222
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
189
223
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
224
+ geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
190
225
  sinceIso: sinceIsoForDays(sinceDays),
191
226
  collectCodexInvocationEvidence: true
192
227
  });
228
+ const glanceCalls = logs.calls.filter((call) => (localAgentFormatSupports(call.agent, "glance")));
193
229
  const calls = args.project
194
- ? logs.calls.filter((call) => call.project === args.project)
195
- : logs.calls;
230
+ ? glanceCalls.filter((call) => call.project === args.project)
231
+ : glanceCalls;
196
232
  const latestWorkingDirectory = latestObservedWorkingDirectory(calls);
197
233
  const contextProjectDir = args.pathExplicit
198
234
  ? resolve(args.path)
@@ -204,7 +240,7 @@ async function glanceCommand(args) {
204
240
  return {
205
241
  exitCode: 1,
206
242
  stdout: "",
207
- stderr: `Unknown --plan "${args.plan}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
243
+ stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
208
244
  };
209
245
  }
210
246
  detectedPlans = [override];
@@ -228,10 +264,12 @@ async function glanceCommand(args) {
228
264
  codexInvocationFiles: logs.codexInvocationFiles
229
265
  });
230
266
  const snapshot = buildUsageGlance(calls, {
231
- filesParsed: logs.filesParsed,
232
- detectedAgents: logs.agentsDetected,
267
+ filesParsed: logs.sourceScans
268
+ .filter((scan) => localAgentFormatSupports(scan.agent, "glance"))
269
+ .reduce((total, scan) => total + scan.filesParsed, 0),
270
+ detectedAgents: logs.agentsDetected.filter((agent) => (localAgentFormatSupports(agent, "glance"))),
233
271
  detectedPlans,
234
- limitCalls: logs.calls,
272
+ limitCalls: glanceCalls,
235
273
  contextHealth
236
274
  });
237
275
  return ok(JSON.stringify(snapshot));
@@ -244,12 +282,14 @@ async function contextHealthCommand(args) {
244
282
  const logs = await loadLocalAgentUsage({
245
283
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
246
284
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
285
+ geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
247
286
  sinceIso,
248
287
  collectCodexInvocationEvidence: true
249
288
  });
289
+ const contextCalls = logs.calls.filter((call) => (localAgentFormatSupports(call.agent, "contextHealth")));
250
290
  const calls = args.project
251
- ? logs.calls.filter((call) => call.project === args.project)
252
- : logs.calls;
291
+ ? contextCalls.filter((call) => call.project === args.project)
292
+ : contextCalls;
253
293
  const health = await loadContextHealth(calls, {
254
294
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
255
295
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
@@ -320,13 +360,13 @@ function quickstartNextSteps(mode, detected) {
320
360
  if (detected.length > 0) {
321
361
  const names = detected.map((credential) => `${credential.provider} (${credential.hint})`).join(", ");
322
362
  steps.push(`Found local key${detected.length === 1 ? "" : "s"}: ${names}`);
323
- steps.push(`npx aibill connect ${detected[0].provider} add official provider-reported cost (ADMIN/owner key)`);
363
+ steps.push(`npx aibill connect ${detected[0].provider} set up the admin connector, then sync provider-reported cost`);
324
364
  }
325
365
  steps.push(mode === "demo"
326
366
  ? "npx aibill report --sample write a clearly labeled demo Markdown + HTML report"
327
367
  : "npx aibill report write a shareable Markdown + HTML report");
328
368
  steps.push("npx aibill --group-by project see which project has the most observed activity");
329
- steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://ai-spend-agent.vercel.app");
369
+ steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://asktilden.com");
330
370
  return steps;
331
371
  }
332
372
  async function readPersistedSpend(rootPath, options = {}) {
@@ -343,9 +383,12 @@ async function readPersistedSpend(rootPath, options = {}) {
343
383
  // tag. A copied/tampered sample must never become connected billing merely
344
384
  // because `mode` was changed in JSON.
345
385
  const mode = isBundledSampleUsage(parsedRecords) ? "sample" : storedMode;
346
- const records = mode === "sample" || mode === undefined
386
+ const parsedModeRecords = mode === "sample" || mode === undefined
347
387
  ? downgradeSampleUsageEvidence(parsedRecords)
348
388
  : parsedRecords;
389
+ const records = mode === "connected_provider"
390
+ ? applyProviderContractGate(parsedModeRecords)
391
+ : parsedModeRecords;
349
392
  if (spend.checkedAt !== undefined && !validIsoString(spend.checkedAt)) {
350
393
  throw new Error("persisted spend checkedAt must be an ISO timestamp");
351
394
  }
@@ -410,7 +453,7 @@ async function loadInstantReadData(args) {
410
453
  looksConnected &&
411
454
  persisted.connectedTrust?.trusted === true) {
412
455
  return {
413
- records: persisted.records,
456
+ records: applyProviderContractGate(persisted.records),
414
457
  mode: "connected",
415
458
  warnings,
416
459
  ...(persisted.providerCoverage ? { providerCoverage: persisted.providerCoverage } : {})
@@ -420,13 +463,22 @@ async function loadInstantReadData(args) {
420
463
  warnings.push(`${persisted.connectedTrust.message} CLI: run \`npx aibill connect <provider>\` or repeat the prior \`npx aibill sync-provider ...\` command. The repository-provided connected totals were ignored.`);
421
464
  }
422
465
  // Real local agent logs (Claude Code / Codex) beat any sample/legacy state.
423
- const logs = await loadLocalAgentUsage({
424
- // Env overrides keep tests (and unusual installs) isolated from $HOME.
425
- claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
426
- codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
427
- sinceIso: sinceIsoForDays(args.sinceDays ?? 30),
428
- collectCodexInvocationEvidence: true
429
- }).catch(() => undefined);
466
+ let logs;
467
+ try {
468
+ logs = await loadLocalAgentUsage({
469
+ // Env overrides keep tests (and unusual installs) isolated from $HOME.
470
+ claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
471
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
472
+ geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
473
+ sinceIso: sinceIsoForDays(args.sinceDays ?? 30),
474
+ collectCodexInvocationEvidence: true
475
+ });
476
+ }
477
+ catch {
478
+ // A failed read is not the same as an honest empty scan. Keep the compact
479
+ // result useful without leaking a local path or raw parser exception.
480
+ warnings.push("Some local agent evidence could not be read; coverage is incomplete.");
481
+ }
430
482
  if (logs && logs.records.length > 0) {
431
483
  // Persisted local_logs state (written by report/apply-artifact) is the
432
484
  // same data source we just re-read — superseding it silently is correct,
@@ -441,25 +493,99 @@ async function loadInstantReadData(args) {
441
493
  codexInvocationFiles: logs.codexInvocationFiles
442
494
  };
443
495
  }
444
- // No real logs. Persisted sample/legacy state may still be shown, but only as
445
- // DEMO (never as connected), with a warning when its origin is unknown.
496
+ const geminiPresence = logs?.sourceScans.find((scan) => scan.agent === "gemini-cli");
497
+ if (geminiPresence && ((geminiPresence.detectionSignals ?? 0) > 0 || geminiPresence.filesDiscovered > 0)) {
498
+ warnings.push("Gemini CLI was detected, but no supported chats JSON/JSONL financial evidence was found. No financial rows were created; logs.json is presence-only evidence. Need this coverage? +1 or contribute a synthetic fixture: https://github.com/futurastudio/ai-spend-agent/issues/new?template=provider_or_agent.yml");
499
+ return {
500
+ records: [],
501
+ mode: "local-logs",
502
+ warnings,
503
+ codexInvocationFiles: logs?.codexInvocationFiles
504
+ };
505
+ }
506
+ // No real logs. Sample and legacy persisted state must never appear unless
507
+ // this invocation explicitly opted into --sample.
446
508
  if (persisted && persisted.records.length > 0 && persisted.mode !== "connected_provider" && persisted.mode !== "local_logs") {
447
- if (persisted.mode === undefined) {
448
- warnings.push("Persisted state in .ai-spend-agent/spend.json is from an older format with no data-mode tag — treating it as demo. Run `npx aibill reset`, then re-scan to refresh.");
449
- }
450
- return { records: persisted.records, mode: "demo", warnings };
509
+ warnings.push(persisted.mode === "sample"
510
+ ? "Sample state exists but was not displayed because this run did not include --sample."
511
+ : "Legacy state with no trustworthy data-mode label was ignored. Run `npx aibill reset`, then collect fresh evidence.");
451
512
  }
452
513
  if (persisted?.mode === "local_logs") {
453
514
  warnings.push("Ignored persisted local-log cache because no current Claude Code/Codex source records were found. Re-run the local agent activity first; repository state alone cannot authorize an Apply action.");
454
515
  }
455
- // loadSampleUsageData resolves the bundled CSVs relative to the installed
456
- // package, so this works from ANY directory (true zero-config).
457
- return { records: await loadSampleUsageData(), mode: "demo", warnings };
516
+ return { records: [], mode: "local-logs", warnings };
517
+ }
518
+ function noEvidenceResult(surface, warnings, sinceDays) {
519
+ const surfaceLine = surface === "watch"
520
+ ? "Watch has no financial baseline yet; no zero total or sample activity was recorded."
521
+ : surface === "report-card"
522
+ ? "No receipt was written because there is no supported financial evidence to summarize."
523
+ : `No supported AI usage evidence was found in the last ${sinceDays} days.`;
524
+ return {
525
+ exitCode: surface === "receipt" ? 0 : 1,
526
+ stdout: surface === "receipt"
527
+ ? [
528
+ "aibill · local only",
529
+ "",
530
+ surfaceLine,
531
+ "Looked for: Claude Code, Codex, and Gemini CLI local history.",
532
+ "Nothing was uploaded. No sample data was substituted.",
533
+ ...warnings.map((warning) => `! ${warning}`),
534
+ "",
535
+ "Next",
536
+ " npx aibill doctor --sources see the exact evidence gap and setup paths"
537
+ ].join("\n")
538
+ : "",
539
+ stderr: surface === "receipt"
540
+ ? ""
541
+ : [
542
+ surfaceLine,
543
+ "Run `npx aibill doctor --sources` to see the exact evidence gap.",
544
+ "Local evidence: use Claude Code, Codex, or Gemini CLI normally, then retry.",
545
+ "Provider billing: connect openai, anthropic, cursor, or github-copilot with an admin credential reference.",
546
+ "For illustrative output only, rerun this command with --sample."
547
+ ].join("\n")
548
+ };
549
+ }
550
+ function terminalOutputWidth() {
551
+ const envColumns = Number(process.env.COLUMNS);
552
+ const ttyColumns = process.stdout.columns;
553
+ const requested = Number.isFinite(envColumns) && envColumns > 0
554
+ ? envColumns
555
+ : Number.isFinite(ttyColumns) && (ttyColumns ?? 0) > 0
556
+ ? ttyColumns
557
+ : 72;
558
+ return Math.max(40, Math.min(120, Math.floor(requested)));
559
+ }
560
+ function wrapCliHeader(text, prefix, width) {
561
+ const available = Math.max(12, width - prefix.length);
562
+ const words = text.trim().split(/\s+/u);
563
+ const lines = [];
564
+ let current = "";
565
+ for (const word of words) {
566
+ if (!current) {
567
+ current = word;
568
+ continue;
569
+ }
570
+ if (`${current} ${word}`.length <= available) {
571
+ current += ` ${word}`;
572
+ continue;
573
+ }
574
+ lines.push(current);
575
+ current = word;
576
+ }
577
+ if (current)
578
+ lines.push(current);
579
+ const continuationPrefix = " ".repeat(prefix.length);
580
+ return lines.map((line, index) => `${index === 0 ? prefix : continuationPrefix}${line}`);
458
581
  }
459
582
  /** A one-line, unmissable banner telling the user which data they're seeing. */
460
- function dataModeBanner(mode) {
461
- if (mode === "local-logs")
462
- return "DATA MODE: your local agent logs (estimated at API-equivalent rates)";
583
+ function dataModeBanner(mode, records) {
584
+ if (mode === "local-logs") {
585
+ return records.some((record) => typeof record.amountUsd === "number")
586
+ ? "DATA MODE: your local agent logs (estimated at API-equivalent rates)"
587
+ : "DATA MODE: local agent evidence (financial value unavailable; no demo sample substituted)";
588
+ }
463
589
  if (mode === "connected")
464
590
  return "DATA MODE: connected provider billing";
465
591
  return "DATA MODE: demo sample (illustrative — not your real spend)";
@@ -479,12 +605,18 @@ async function doctorCommand(args) {
479
605
  : "no state";
480
606
  const logs = await loadLocalAgentUsage({
481
607
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
482
- codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
608
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
609
+ geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR
483
610
  }).catch(() => undefined);
484
611
  const detected = logs?.agentsDetected ?? [];
485
612
  const claudeFound = detected.includes("claude-code");
486
613
  const codexFound = detected.includes("codex");
487
- const hasLogs = claudeFound || codexFound;
614
+ const geminiScan = logs?.sourceScans.find((scan) => scan.agent === "gemini-cli");
615
+ const geminiFinancialFound = detected.includes("gemini-cli");
616
+ const geminiPresenceFound = Boolean(geminiScan && ((geminiScan.detectionSignals ?? 0) > 0 || geminiScan.filesDiscovered > 0));
617
+ const geminiFound = geminiFinancialFound || geminiPresenceFound;
618
+ const hasFinancialLogs = claudeFound || codexFound || geminiFinancialFound;
619
+ const hasLocalSource = hasFinancialLogs || geminiPresenceFound;
488
620
  const detection = await detectLocalCredentials({
489
621
  cwd: rootPath,
490
622
  home: process.env.AI_SPEND_CLAUDE_HOME_DIR
@@ -505,15 +637,20 @@ async function doctorCommand(args) {
505
637
  if (persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === false) {
506
638
  warnings.push(`${persisted.connectedTrust.message} Run \`npx aibill connect <provider>\` or repeat the prior \`npx aibill sync-provider ...\` command.`);
507
639
  }
508
- if (!hasLogs)
509
- warnings.push("no real Claude Code / Codex logs found — a first run here will show DEMO sample data");
640
+ if (geminiPresenceFound && !geminiFinancialFound) {
641
+ warnings.push("Gemini CLI detected, but no supported chats JSON/JSONL financial evidence was found. No financial rows were created; logs.json is presence-only evidence. Need this coverage? +1 or contribute a synthetic fixture: https://github.com/futurastudio/ai-spend-agent/issues/new?template=provider_or_agent.yml");
642
+ }
643
+ if (!hasLocalSource)
644
+ warnings.push("no supported Claude Code, Codex, or Gemini CLI session evidence found — the default receipt will remain empty; use --sample only for the labeled demo");
510
645
  if (providerRefs.length === 0)
511
- warnings.push("no provider admin keys detected — connect OpenAI/Anthropic to add official provider-reported cost (local logs stay API-equivalent estimates)");
646
+ warnings.push("no provider admin keys detected — set up and sync an OpenAI/Anthropic admin connector for provider-reported cost (local logs stay API-equivalent estimates)");
512
647
  const predictedMode = connectedStateTrusted
513
648
  ? "connected provider billing"
514
- : hasLogs
649
+ : hasFinancialLogs
515
650
  ? "your local agent logs (estimated at API-equivalent rates)"
516
- : "demo sample (illustrative)";
651
+ : geminiPresenceFound
652
+ ? "local Gemini CLI presence only (financial evidence unavailable; no sample substituted)"
653
+ : "no supported evidence yet (no sample substituted)";
517
654
  const lines = [
518
655
  "aibill doctor",
519
656
  `node version: ${process.version}`,
@@ -524,6 +661,11 @@ async function doctorCommand(args) {
524
661
  `state mode: ${stateMode}`,
525
662
  `Claude Code logs: ${claudeFound ? "found" : "not found"}`,
526
663
  `Codex logs: ${codexFound ? "found" : "not found"}`,
664
+ `Gemini CLI sessions: ${geminiFinancialFound
665
+ ? "found"
666
+ : geminiPresenceFound
667
+ ? "detected, but no supported chats financial rows found"
668
+ : "not found"}`,
527
669
  `provider env references: ${providerRefs.length > 0 ? providerRefs.join(", ") : "none detected"}`,
528
670
  `subscription plans: ${planLine}`,
529
671
  "redaction policy: secrets are never printed or persisted",
@@ -549,7 +691,8 @@ async function doctorSourcesCommand(args) {
549
691
  try {
550
692
  localLogs = await loadLocalAgentUsage({
551
693
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
552
- codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
694
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
695
+ geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR
553
696
  });
554
697
  }
555
698
  catch (error) {
@@ -659,14 +802,18 @@ function localFinancialEvidenceNote(records, evidence, scan) {
659
802
  if (scan.directoryStatus === "unreadable") {
660
803
  return "The local transcript path could not be read; absence of usage cannot be confirmed.";
661
804
  }
805
+ if (scan.agent === "gemini-cli" && (scan.detectionSignals ?? 0) > 0 &&
806
+ scan.filesDiscovered === 0) {
807
+ return "Gemini CLI detected, but no supported chats JSON/JSONL financial evidence was found. logs.json is presence-only evidence; zero financial rows were created. Need this coverage? +1 or contribute a synthetic fixture: https://github.com/futurastudio/ai-spend-agent/issues/new?template=provider_or_agent.yml";
808
+ }
662
809
  if (scan.filesDiscovered === 0) {
663
- return "The local transcript directory was readable, but no JSONL files were found.";
810
+ return "The local transcript directory was readable, but no supported session files were found.";
664
811
  }
665
812
  if (scan.unreadableFiles > 0) {
666
813
  return `${scan.filesDiscovered} transcript file(s) were found, but ${scan.unreadableFiles} could not be read; absence of usage cannot be confirmed.`;
667
814
  }
668
815
  if (scan.malformedLines > 0) {
669
- return `${scan.filesDiscovered} transcript file(s) were found, but no valid usage rows were parsed; ${scan.malformedLines} malformed JSONL line(s) were skipped.`;
816
+ return `${scan.filesDiscovered} transcript file(s) were found, but no valid usage rows were parsed; ${scan.malformedLines} malformed session record(s) were skipped.`;
670
817
  }
671
818
  return `${scan.filesDiscovered} transcript file(s) were found, but no supported usage rows were observed.`;
672
819
  }
@@ -697,9 +844,13 @@ function localFinancialEvidenceNote(records, evidence, scan) {
697
844
  function localAgentDiagnosticSummary(diagnostics) {
698
845
  const relevant = diagnostics.filter((diagnostic) => diagnostic.code !== "directory_missing");
699
846
  const unsupported = relevant.filter((diagnostic) => diagnostic.code === "unsupported_token_shape");
700
- const malformed = relevant.filter((diagnostic) => diagnostic.code === "malformed_jsonl");
847
+ const malformed = relevant.filter((diagnostic) => (diagnostic.code === "malformed_jsonl" || diagnostic.code === "malformed_session_file"));
701
848
  const messages = [...new Set(relevant
702
- .filter((diagnostic) => !["unsupported_token_shape", "malformed_jsonl"].includes(diagnostic.code))
849
+ .filter((diagnostic) => ![
850
+ "unsupported_token_shape",
851
+ "malformed_jsonl",
852
+ "malformed_session_file"
853
+ ].includes(diagnostic.code))
703
854
  .map((diagnostic) => diagnostic.message))];
704
855
  const unsupportedCount = unsupported
705
856
  .reduce((total, diagnostic) => total + diagnostic.count, 0);
@@ -709,7 +860,7 @@ function localAgentDiagnosticSummary(diagnostics) {
709
860
  const malformedCount = malformed
710
861
  .reduce((total, diagnostic) => total + diagnostic.count, 0);
711
862
  if (malformedCount > 0) {
712
- messages.push(`${malformedCount} malformed JSONL line(s) were skipped in ${localAgentFormatLabel(malformed[0].agent)} transcripts.`);
863
+ messages.push(`${malformedCount} malformed session record(s) were skipped in ${localAgentFormatLabel(malformed[0].agent)} transcripts.`);
713
864
  }
714
865
  return messages.length > 0 ? messages.join(" ") : undefined;
715
866
  }
@@ -981,7 +1132,7 @@ async function resetCommand(args) {
981
1132
  "aibill reset",
982
1133
  `path: ${rootPath}`,
983
1134
  "nothing to clear (no persisted spend state found)",
984
- "next run will re-read your real local agent logs (or demo sample if none)."
1135
+ "next run will re-read your real local agent logs; no sample is substituted without --sample."
985
1136
  ].join("\n"));
986
1137
  }
987
1138
  // Clear derived spend state so a prior `scan --sample` (or stale provider
@@ -1001,7 +1152,7 @@ async function resetCommand(args) {
1001
1152
  "aibill reset",
1002
1153
  `path: ${rootPath}`,
1003
1154
  removed.length > 0 ? `cleared: ${removed.join(", ")}` : "nothing to clear (no persisted spend state found)",
1004
- "next run will re-read your real local agent logs (or demo sample if none)."
1155
+ "next run will re-read your real local agent logs; no sample is substituted without --sample."
1005
1156
  ].join("\n"));
1006
1157
  }
1007
1158
  async function statuslineCommand(args, runtime) {
@@ -1117,7 +1268,7 @@ async function initCommand(args, runtime = {}) {
1117
1268
  return {
1118
1269
  exitCode: 1,
1119
1270
  stdout: "",
1120
- stderr: `Unknown --plan "${args.plan}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
1271
+ stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
1121
1272
  };
1122
1273
  }
1123
1274
  detectedPlanOverride = [override];
@@ -1210,6 +1361,7 @@ async function collectAndPublishActivitySnapshot(input) {
1210
1361
  logs = await loadLocalAgentFinancialUsage({
1211
1362
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
1212
1363
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
1364
+ geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
1213
1365
  sinceIso: sinceIsoForDays(30, asOf)
1214
1366
  });
1215
1367
  }
@@ -1222,13 +1374,14 @@ async function collectAndPublishActivitySnapshot(input) {
1222
1374
  const persisted = persistedResult.persisted;
1223
1375
  const trustedProviderRecords = persisted?.mode === "connected_provider" &&
1224
1376
  persisted.connectedTrust?.trusted === true
1225
- ? selectProviderFinancialHeadlineRecords(persisted.records)
1377
+ ? selectProviderFinancialHeadlineRecords(applyProviderContractGate(persisted.records))
1226
1378
  : [];
1227
1379
  let activitySnapshot;
1228
1380
  let cacheStatus;
1381
+ const statuslineSourceScans = logs?.sourceScans.filter((scan) => (localAgentFormatSupports(scan.agent, "statuslineSnapshot"))) ?? [];
1229
1382
  const structuredSourceFailure = logs !== undefined &&
1230
- logs.sourceScans.some((scan) => scan.directoryStatus === "unreadable") &&
1231
- !logs.sourceScans.some((scan) => scan.directoryStatus === "readable");
1383
+ statuslineSourceScans.some((scan) => scan.directoryStatus === "unreadable") &&
1384
+ !statuslineSourceScans.some((scan) => scan.directoryStatus === "readable");
1232
1385
  if (logs && !structuredSourceFailure) {
1233
1386
  let refreshErrorCode = "invalid_evidence";
1234
1387
  try {
@@ -1298,7 +1451,7 @@ async function refreshStatuslineCommand(args, runtime) {
1298
1451
  return {
1299
1452
  exitCode: 1,
1300
1453
  stdout: "",
1301
- stderr: `Unknown --plan "${args.plan}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
1454
+ stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
1302
1455
  };
1303
1456
  }
1304
1457
  detectedPlanOverride = [override];
@@ -1516,13 +1669,18 @@ function activitySnapshotProvider(provider) {
1516
1669
  function formatInitReceipt(input) {
1517
1670
  const records = input.logs?.records ?? [];
1518
1671
  const pricedRecords = records.filter((record) => typeof record.amountUsd === "number");
1672
+ const registryOnlyLines = initRegistryOnlyFinancialLines(records);
1519
1673
  const sourceFailures = (input.logs?.sourceScans ?? []).some((scan) => scan.directoryStatus === "unreadable") ||
1520
1674
  (input.logs?.diagnostics ?? []).some((diagnostic) => diagnostic.severity === "error");
1521
- const receiptLines = input.scanError || sourceFailures && records.length === 0
1675
+ const snapshotLines = input.scanError || sourceFailures && records.length === 0
1522
1676
  ? ["API-equivalent usage value: unavailable — the local scan could not prove an empty result"]
1523
1677
  : input.activitySnapshot
1524
1678
  ? initApiEquivalentWindowLines(input.activitySnapshot)
1525
1679
  : ["API-equivalent usage value: unavailable — no snapshot was produced"];
1680
+ const receiptLines = registryOnlyLines.length > 0 &&
1681
+ input.activitySnapshot?.mode === "empty" && !input.scanError
1682
+ ? registryOnlyLines
1683
+ : [...snapshotLines, ...registryOnlyLines];
1526
1684
  const planLine = input.detectedPlans.length > 0
1527
1685
  ? input.detectedPlans.map((plan) => {
1528
1686
  const known = plan.planId ?? "unrecognized plan";
@@ -1536,7 +1694,10 @@ function formatInitReceipt(input) {
1536
1694
  const validation = scan.jsonlValidationCoverage === "financial_events_only"
1537
1695
  ? "; financial-event JSONL validation only"
1538
1696
  : "";
1539
- return ` ${scan.agent}: ${scan.directoryStatus}; ${scan.filesParsed}/${scan.filesDiscovered} files parsed; ${priced}/${agentRecords.length} rows priced${skipped > 0 ? `; ${skipped} old files skipped` : ""}${validation}`;
1697
+ const detection = (scan.detectionSignals ?? 0) > 0
1698
+ ? `; ${scan.detectionSignals} presence-only signal(s)`
1699
+ : "";
1700
+ return ` ${scan.agent}: ${scan.directoryStatus}; ${scan.filesParsed}/${scan.filesDiscovered} files parsed; ${priced}/${agentRecords.length} rows priced${detection}${skipped > 0 ? `; ${skipped} old files skipped` : ""}${validation}`;
1540
1701
  });
1541
1702
  const diagnosticLines = (input.logs?.diagnostics ?? [])
1542
1703
  .filter((diagnostic) => diagnostic.code !== "directory_missing")
@@ -1545,7 +1706,7 @@ function formatInitReceipt(input) {
1545
1706
  return [
1546
1707
  "aibill init",
1547
1708
  `state project: ${sanitizeSecretishError(basename(input.rootPath))}`,
1548
- "local usage scope: all Claude Code + Codex activity on this machine (last 30 days)",
1709
+ "local usage scope: supported Claude Code, Codex, and Gemini CLI financial evidence on this machine (last 30 days)",
1549
1710
  "provider scope: trusted connected billing from this state project only (shown separately)",
1550
1711
  "",
1551
1712
  "FIRST RECEIPT · API-equivalent usage value · last 30 days",
@@ -1565,6 +1726,25 @@ function formatInitReceipt(input) {
1565
1726
  "next: npx aibill doctor --sources"
1566
1727
  ].filter((line) => line !== "").join("\n");
1567
1728
  }
1729
+ function initRegistryOnlyFinancialLines(records) {
1730
+ const lines = [];
1731
+ for (const descriptor of localAgentFormatDescriptors) {
1732
+ if (descriptor.capabilities.statuslineSnapshot)
1733
+ continue;
1734
+ const sourceRecords = records.filter((record) => record.agentId === descriptor.id);
1735
+ if (sourceRecords.length === 0)
1736
+ continue;
1737
+ const priced = sourceRecords.filter((record) => typeof record.amountUsd === "number");
1738
+ if (priced.length === 0) {
1739
+ lines.push(`${descriptor.id} API-equivalent value: unavailable — ${sourceRecords.length} observed row(s) lacked complete token components or a supported model price`);
1740
+ continue;
1741
+ }
1742
+ const amount = analyzeSpend(priced).totalUsd;
1743
+ const unpriced = sourceRecords.length - priced.length;
1744
+ lines.push(`${descriptor.id} experimental value: ~${formatOptionalUsd(amount)} 30d (API-equivalent; fixture-verified; not billed spend${unpriced > 0 ? `; ${unpriced} row(s) unpriced` : ""})`);
1745
+ }
1746
+ return lines;
1747
+ }
1568
1748
  function formatInitProviderEvidence(input) {
1569
1749
  const windowStart = input.asOf.getTime() - 30 * 24 * 60 * 60 * 1_000;
1570
1750
  const inWindow = input.trustedProviderRecords.filter((record) => {
@@ -1629,7 +1809,7 @@ function initApiEquivalentWindowLines(snapshot) {
1629
1809
  agent.unsupportedUsageSnapshots === 0 &&
1630
1810
  agent.jsonlValidationCoverage === "complete");
1631
1811
  return [completeZero
1632
- ? "API-equivalent value: ~$0.00 1d · ~$0.00 7d · ~$0.00 30d (estimated value; not billed spend)"
1812
+ ? "API-equivalent usage value: unavailable — no priced evidence was observed in readable local sources"
1633
1813
  : "API-equivalent usage value: unavailable — local source coverage is incomplete; no zero total was inferred"];
1634
1814
  }
1635
1815
  if (snapshot.mode === "unresolved" && snapshot.unresolved) {
@@ -1749,7 +1929,7 @@ async function scanCommand(args) {
1749
1929
  const mappings = attributeUsageRecords(records);
1750
1930
  await writeLocalSpendState(stateDir, records, summary, mappings, "sample");
1751
1931
  lines.push(`sample records: ${records.length}`);
1752
- lines.push(`total spend: $${summary.totalUsd.toFixed(2)}`);
1932
+ lines.push(`total spend: ${formatOptionalUsd(summary.totalUsd)}`);
1753
1933
  lines.push(`attribution mappings: ${mappings.length}`);
1754
1934
  }
1755
1935
  if (discovery.signals.length > 0) {
@@ -1781,6 +1961,9 @@ async function watchCommand(args) {
1781
1961
  while (unbounded || iteration < cycles) {
1782
1962
  const previous = await readOptionalJson(join(stateDir, "watch-latest.json"), null);
1783
1963
  const { summary, snapshot, records, mode } = await runWatchCycle(stateDir, args);
1964
+ if (records.length === 0) {
1965
+ return noEvidenceResult("watch", [], args.sinceDays ?? 30);
1966
+ }
1784
1967
  const deltaHeadline = buildDeltaHeadline(previous, snapshot);
1785
1968
  // Watch's job is DELTAS: render the compact breakdown view per cycle, not
1786
1969
  // the whole diagnose→verify readout again (the quickstart owns that).
@@ -1789,6 +1972,7 @@ async function watchCommand(args) {
1789
1972
  groupBy: args.groupBy ?? "model",
1790
1973
  color: args.noColor ? false : undefined,
1791
1974
  mode: mode === "sample" ? "demo" : mode === "connected_provider" ? "connected" : "local-logs",
1975
+ width: terminalOutputWidth(),
1792
1976
  view: "breakdown"
1793
1977
  });
1794
1978
  const stamped = [
@@ -1836,7 +2020,7 @@ async function runWatchCycle(stateDir, args) {
1836
2020
  // Watch may observe an already trusted provider snapshot, but it may not
1837
2021
  // mint trust from repository-authored provider-records.json or rewrite
1838
2022
  // connected state. Only an explicit provider sync can do that.
1839
- records = persisted.records;
2023
+ records = applyProviderContractGate(persisted.records);
1840
2024
  mode = "connected_provider";
1841
2025
  }
1842
2026
  else {
@@ -1844,31 +2028,35 @@ async function runWatchCycle(stateDir, args) {
1844
2028
  // never serve a stale snapshot.
1845
2029
  const logs = await loadLocalAgentUsage({
1846
2030
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
1847
- codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
2031
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
2032
+ geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR
1848
2033
  }).catch(() => undefined);
1849
2034
  if (logs && logs.records.length > 0) {
1850
2035
  records = logs.records;
1851
2036
  mode = "local_logs";
1852
2037
  }
1853
2038
  else {
1854
- records = await loadSampleUsageData();
1855
- mode = "sample";
2039
+ records = [];
2040
+ mode = "local_logs";
1856
2041
  }
1857
2042
  }
1858
2043
  }
1859
2044
  const headlineRecords = mode === "connected_provider"
1860
- ? selectProviderFinancialHeadlineRecords(records)
2045
+ ? selectProviderFinancialHeadlineRecords(applyProviderContractGate(records))
1861
2046
  : records;
1862
2047
  const summary = analyzeSpend(headlineRecords);
2048
+ const financialAmountAvailable = headlineRecords.some((record) => typeof record.amountUsd === "number");
1863
2049
  const mappings = attributeUsageRecords(records);
1864
2050
  if (mode !== "connected_provider") {
1865
2051
  await writeLocalSpendState(stateDir, records, summary, mappings, mode);
1866
2052
  }
1867
2053
  const snapshot = {
1868
2054
  capturedAt: new Date().toISOString(),
1869
- totalUsd: summary.totalUsd,
2055
+ totalUsd: financialAmountAvailable ? summary.totalUsd : null,
1870
2056
  recordCount: summary.recordCount,
1871
- byModel: summary.byModel.map((entry) => ({ key: entry.key, amountUsd: entry.amountUsd }))
2057
+ byModel: financialAmountAvailable
2058
+ ? summary.byModel.map((entry) => ({ key: entry.key, amountUsd: entry.amountUsd }))
2059
+ : []
1872
2060
  };
1873
2061
  // Append to the rolling history and persist the latest snapshot for the next run.
1874
2062
  const history = await readOptionalJson(join(stateDir, "watch-history.json"), []);
@@ -1878,24 +2066,35 @@ async function runWatchCycle(stateDir, args) {
1878
2066
  timestamp: snapshot.capturedAt,
1879
2067
  action: "scan_completed",
1880
2068
  sourceId: "watch",
1881
- detail: `Watch cycle captured ${snapshot.recordCount} records totaling $${snapshot.totalUsd.toFixed(2)}.`
2069
+ detail: snapshot.totalUsd === null
2070
+ ? `Watch cycle captured ${snapshot.recordCount} records with no priced financial evidence; total unavailable.`
2071
+ : `Watch cycle captured ${snapshot.recordCount} records totaling ${formatOptionalUsd(snapshot.totalUsd)}.`
1882
2072
  });
1883
2073
  return { summary, snapshot, records: headlineRecords, mode };
1884
2074
  }
1885
2075
  function buildDeltaHeadline(previous, current) {
1886
2076
  if (!previous) {
1887
- return `First watch snapshot. Baseline AI spend is $${current.totalUsd.toFixed(2)} across ${current.recordCount} charges. Future cycles will report what changed.`;
2077
+ if (current.totalUsd === null) {
2078
+ return `First watch snapshot. Financial baseline is unavailable across ${current.recordCount} records; missing/null is not zero. Future priced cycles will establish a numeric baseline.`;
2079
+ }
2080
+ return `First watch snapshot. Baseline AI spend is ${formatOptionalUsd(current.totalUsd)} across ${current.recordCount} charges. Future cycles will report what changed.`;
2081
+ }
2082
+ if (current.totalUsd === null) {
2083
+ return `Financial evidence is unavailable across ${current.recordCount} records; missing/null is not zero and no numeric delta was calculated.`;
2084
+ }
2085
+ if (previous.totalUsd === null) {
2086
+ return `A priced financial baseline is now available at ${formatOptionalUsd(current.totalUsd)} across ${current.recordCount} records. The prior snapshot was unavailable, so no numeric delta was calculated.`;
1888
2087
  }
1889
2088
  const deltaUsd = roundMoneyCli(current.totalUsd - previous.totalUsd);
1890
2089
  const lines = [];
1891
- if (Math.abs(deltaUsd) < 0.01) {
1892
- lines.push(`No change since the last check: AI spend is holding at $${current.totalUsd.toFixed(2)}.`);
2090
+ if (deltaUsd === 0) {
2091
+ lines.push(`No change since the last check: AI spend is holding at ${formatOptionalUsd(current.totalUsd)}.`);
1893
2092
  }
1894
2093
  else {
1895
2094
  const direction = deltaUsd > 0 ? "UP" : "DOWN";
1896
2095
  const percent = previous.totalUsd > 0 ? Math.round((deltaUsd / previous.totalUsd) * 100) : 100;
1897
- lines.push(`Spend is ${direction} $${Math.abs(deltaUsd).toFixed(2)} (${Math.abs(percent)}%) since the last check — ` +
1898
- `from $${previous.totalUsd.toFixed(2)} to $${current.totalUsd.toFixed(2)}.`);
2096
+ lines.push(`Spend is ${direction} ${formatOptionalUsd(Math.abs(deltaUsd))} (${Math.abs(percent)}%) since the last check — ` +
2097
+ `from ${formatOptionalUsd(previous.totalUsd)} to ${formatOptionalUsd(current.totalUsd)}.`);
1899
2098
  }
1900
2099
  // New-model and per-model spike detection versus the previous snapshot.
1901
2100
  const previousModels = new Map(previous.byModel.map((entry) => [entry.key, entry.amountUsd]));
@@ -1904,12 +2103,12 @@ function buildDeltaHeadline(previous, current) {
1904
2103
  const before = previousModels.get(entry.key);
1905
2104
  if (before === undefined) {
1906
2105
  if (entry.amountUsd >= 1) {
1907
- anomalies.push(`New model "${entry.key}" appeared, already at $${entry.amountUsd.toFixed(2)}.`);
2106
+ anomalies.push(`New model "${entry.key}" appeared, already at ${formatOptionalUsd(entry.amountUsd)}.`);
1908
2107
  }
1909
2108
  continue;
1910
2109
  }
1911
2110
  if (before > 0 && entry.amountUsd - before >= 5 && entry.amountUsd / before >= 1.5) {
1912
- anomalies.push(`"${entry.key}" jumped from $${before.toFixed(2)} to $${entry.amountUsd.toFixed(2)}.`);
2111
+ anomalies.push(`"${entry.key}" jumped from ${formatOptionalUsd(before)} to ${formatOptionalUsd(entry.amountUsd)}.`);
1913
2112
  }
1914
2113
  }
1915
2114
  if (anomalies.length > 0) {
@@ -1921,7 +2120,7 @@ function sleep(milliseconds) {
1921
2120
  return new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds));
1922
2121
  }
1923
2122
  function roundMoneyCli(value) {
1924
- return Math.round(value * 100) / 100;
2123
+ return Math.round(value * 10_000) / 10_000;
1925
2124
  }
1926
2125
  async function addSourceCommand(args) {
1927
2126
  const rootPath = resolve(args.path);
@@ -1982,6 +2181,10 @@ const adminUpgradeProviders = {
1982
2181
  "github-copilot": "requires a GitHub BILLING-ADMIN token (org/enterprise)",
1983
2182
  copilot: "requires a GitHub BILLING-ADMIN token (org/enterprise)"
1984
2183
  };
2184
+ const supportedAdminProviders = new Set(["openai", "anthropic", "cursor", "github-copilot"]);
2185
+ const providerAliases = {
2186
+ copilot: "github-copilot"
2187
+ };
1985
2188
  const providerAdminEnvHint = {
1986
2189
  openai: "env:OPENAI_ADMIN_KEY",
1987
2190
  anthropic: "env:ANTHROPIC_ADMIN_KEY",
@@ -1989,10 +2192,20 @@ const providerAdminEnvHint = {
1989
2192
  "github-copilot": "env:GITHUB_TOKEN",
1990
2193
  copilot: "env:GITHUB_TOKEN"
1991
2194
  };
2195
+ function providerSyncSetupCommand(provider, adminRef) {
2196
+ if (provider === "cursor") {
2197
+ return `npx aibill sync-provider --provider cursor --auth-reference ${adminRef} --account-id <team-label>`;
2198
+ }
2199
+ if (provider === "github-copilot") {
2200
+ return `npx aibill sync-provider --provider github-copilot --auth-reference ${adminRef} --org <organization>`;
2201
+ }
2202
+ return `npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`;
2203
+ }
1992
2204
  async function connectCommand(args) {
1993
2205
  const rootPath = resolve(args.path);
1994
2206
  const stateDir = join(rootPath, ".ai-spend-agent");
1995
- const provider = args.provider ?? "unknown";
2207
+ const requestedProvider = (args.provider ?? "unknown").trim().toLowerCase();
2208
+ const provider = providerAliases[requestedProvider] ?? requestedProvider;
1996
2209
  if (!provider || provider === "unknown") {
1997
2210
  return {
1998
2211
  exitCode: 1,
@@ -2008,6 +2221,20 @@ async function connectCommand(args) {
2008
2221
  };
2009
2222
  }
2010
2223
  const type = args.sourceType ?? "provider_api";
2224
+ if (!supportedAdminProviders.has(provider) || type !== "provider_api") {
2225
+ const reason = !supportedAdminProviders.has(provider)
2226
+ ? `connect does not implement provider "${sanitizeSecretishError(requestedProvider)}".`
2227
+ : `connect supports provider_api only; received "${sanitizeSecretishError(type)}".`;
2228
+ return {
2229
+ exitCode: 1,
2230
+ stdout: "",
2231
+ stderr: [
2232
+ reason,
2233
+ "Supported admin connectors: openai, anthropic, cursor, github-copilot.",
2234
+ "For an approved file/export boundary, use `npx aibill add-source` instead."
2235
+ ].join("\n")
2236
+ };
2237
+ }
2011
2238
  const registry = await readSourceRegistry(stateDir, rootPath);
2012
2239
  const source = createProviderConnectorStub(provider, type);
2013
2240
  const nextRegistry = addApprovedSource(registry, source);
@@ -2034,7 +2261,7 @@ async function connectCommand(args) {
2034
2261
  `boundary approval: ${source.boundaryApproval}`,
2035
2262
  `validation coverage: ${source.validationCoverage}`,
2036
2263
  `financial evidence: ${source.financialEvidence}`,
2037
- "secrets: no raw secrets stored; we only reference a local env var such as env:OPENAI_ADMIN_KEY"
2264
+ `secrets: no raw secrets stored; we only reference a local env var such as ${providerAdminEnvHint[provider] ?? "env:YOUR_ADMIN_KEY"}`
2038
2265
  ];
2039
2266
  if (selfServeProviders.has(provider)) {
2040
2267
  lines.push("tier: self-serve — an org owner can enable this in ~2 minutes");
@@ -2048,19 +2275,19 @@ async function connectCommand(args) {
2048
2275
  lines.push(`auto-detected: a ${provider} key in ${detected.reference} (${detected.hint}) from ${describeOrigin(detected)}`);
2049
2276
  if (detected.isLikelyAdminKey) {
2050
2277
  const adminRef = providerAdminEnvHint[provider] ?? detected.reference;
2051
- lines.push(`next: npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
2278
+ lines.push(`next: ${providerSyncSetupCommand(provider, adminRef)}`);
2052
2279
  }
2053
2280
  else {
2054
2281
  const adminRef = providerAdminEnvHint[provider] ?? "env:YOUR_ADMIN_KEY";
2055
2282
  lines.push(`this looks like a regular key — for COST data set an admin key in ${adminRef}, then:`);
2056
- lines.push(` npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
2283
+ lines.push(` ${providerSyncSetupCommand(provider, adminRef)}`);
2057
2284
  }
2058
2285
  }
2059
2286
  else {
2060
2287
  const adminRef = providerAdminEnvHint[provider] ?? "env:YOUR_ADMIN_KEY";
2061
2288
  lines.push("");
2062
2289
  lines.push(`next: export an admin key reference, e.g. ${adminRef}, then run:`);
2063
- lines.push(` npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
2290
+ lines.push(` ${providerSyncSetupCommand(provider, adminRef)}`);
2064
2291
  }
2065
2292
  lines.push(`missing: ${source.fieldsMissing.join(", ")}`);
2066
2293
  return ok(lines.join("\n"));
@@ -2089,17 +2316,62 @@ function describeOrigin(credential) {
2089
2316
  async function syncProviderCommand(args) {
2090
2317
  const rootPath = resolve(args.path);
2091
2318
  const stateDir = join(rootPath, ".ai-spend-agent");
2092
- if (!args.provider || !args.authReference || !args.startTime) {
2319
+ const requestedProvider = (args.provider ?? "").trim().toLowerCase();
2320
+ const provider = providerAliases[requestedProvider] ?? requestedProvider;
2321
+ if (!provider) {
2322
+ return {
2323
+ exitCode: 1,
2324
+ stdout: "",
2325
+ stderr: [
2326
+ "sync-provider requires --provider.",
2327
+ "Supported admin connectors: openai, anthropic, cursor, github-copilot."
2328
+ ].join("\n")
2329
+ };
2330
+ }
2331
+ if (!supportedAdminProviders.has(provider)) {
2093
2332
  return {
2094
2333
  exitCode: 1,
2095
2334
  stdout: "",
2096
2335
  stderr: [
2097
- "sync-provider requires --provider, --auth-reference env:NAME, and --start-time <unix seconds>.",
2098
- "example:",
2099
- " npx aibill sync-provider --provider anthropic --auth-reference env:ANTHROPIC_ADMIN_KEY --start-time 1750000000"
2336
+ `sync-provider does not implement provider "${sanitizeSecretishError(requestedProvider)}".`,
2337
+ "Supported admin connectors: openai, anthropic, cursor, github-copilot."
2100
2338
  ].join("\n")
2101
2339
  };
2102
2340
  }
2341
+ if (!args.authReference) {
2342
+ return {
2343
+ exitCode: 1,
2344
+ stdout: "",
2345
+ stderr: `sync-provider ${provider} requires --auth-reference env:NAME; raw secrets are not accepted.`
2346
+ };
2347
+ }
2348
+ const usesRequestedTimeBounds = provider === "openai" || provider === "anthropic";
2349
+ if (usesRequestedTimeBounds && args.startTime === undefined) {
2350
+ return {
2351
+ exitCode: 1,
2352
+ stdout: "",
2353
+ stderr: [
2354
+ `sync-provider ${provider} requires --start-time <unix seconds>; --end-time is optional.`,
2355
+ `example: npx aibill sync-provider --provider ${provider} --auth-reference ${providerAdminEnvHint[provider]} --start-time 1750000000`
2356
+ ].join("\n")
2357
+ };
2358
+ }
2359
+ if (!usesRequestedTimeBounds && (args.startTime !== undefined || args.endTime !== undefined)) {
2360
+ return {
2361
+ exitCode: 1,
2362
+ stdout: "",
2363
+ stderr: provider === "cursor"
2364
+ ? "Cursor Admin spend is returned for the provider's current team subscription cycle; --start-time/--end-time are not accepted because the connector cannot enforce them."
2365
+ : "GitHub Copilot returns its latest metrics-report window plus current seat data; --start-time/--end-time are not accepted because the connector cannot enforce them."
2366
+ };
2367
+ }
2368
+ if (provider === "github-copilot" && Boolean(args.org) === Boolean(args.enterprise)) {
2369
+ return {
2370
+ exitCode: 1,
2371
+ stdout: "",
2372
+ stderr: "GitHub Copilot sync requires exactly one of --org <organization> or --enterprise <enterprise>."
2373
+ };
2374
+ }
2103
2375
  try {
2104
2376
  // A provider sync may merge only a prior connected snapshot whose exact
2105
2377
  // repository bytes have a matching external machine receipt. A cloned
@@ -2111,21 +2383,36 @@ async function syncProviderCommand(args) {
2111
2383
  ? priorSpend
2112
2384
  : undefined;
2113
2385
  const result = await fetchProviderUsageRecords({
2114
- provider: args.provider,
2115
- sourceId: `${args.provider}-provider-api`,
2386
+ provider,
2387
+ sourceId: `${provider}-provider-api`,
2116
2388
  authReference: args.authReference,
2117
- startTime: args.startTime,
2389
+ // Core keeps a single connector input contract. Non-time-bounded
2390
+ // adapters ignore this sentinel and the CLI rejects user-supplied bounds
2391
+ // above so no requested interval can be silently discarded.
2392
+ startTime: usesRequestedTimeBounds ? args.startTime : 0,
2118
2393
  endTime: args.endTime,
2119
2394
  org: args.org,
2120
2395
  enterprise: args.enterprise,
2121
2396
  accountId: args.accountId
2122
2397
  });
2123
- const records = [
2398
+ const syncedRecords = applyProviderContractGate(result.records);
2399
+ const syncedFinancials = summarizeProviderFinancials(syncedRecords);
2400
+ const syncedCompleteness = providerFinancialCompleteness(syncedRecords, result.coverage);
2401
+ const syncedSource = createProviderConnection({
2402
+ provider: result.provider,
2403
+ sourceId: result.source.id,
2404
+ authReference: args.authReference,
2405
+ verifiedRecordCount: syncedRecords.length,
2406
+ totalUsd: syncedFinancials.headlineUsd,
2407
+ completeness: syncedCompleteness,
2408
+ fetchedAt: new Date(result.fetchedAt)
2409
+ });
2410
+ const records = applyProviderContractGate([
2124
2411
  ...(trustedPrior?.records ?? []).filter((record) => record.source.provider !== result.provider),
2125
- ...result.records
2126
- ].sort((left, right) => left.timestamp.localeCompare(right.timestamp));
2412
+ ...syncedRecords
2413
+ ]).sort((left, right) => left.timestamp.localeCompare(right.timestamp));
2127
2414
  const registry = await readSourceRegistry(stateDir, rootPath);
2128
- const nextRegistry = addApprovedSource(registry, result.source);
2415
+ const nextRegistry = addApprovedSource(registry, syncedSource);
2129
2416
  const headlineRecords = selectProviderFinancialHeadlineRecords(records);
2130
2417
  const summary = analyzeSpend(headlineRecords);
2131
2418
  const mappings = attributeUsageRecords(records);
@@ -2139,7 +2426,7 @@ async function syncProviderCommand(args) {
2139
2426
  };
2140
2427
  const financialsByProvider = {
2141
2428
  ...trustedAccountingMap(trustedPrior?.accounting, "financialsByProvider"),
2142
- [result.provider]: result.financials
2429
+ [result.provider]: syncedFinancials
2143
2430
  };
2144
2431
  const checkedAtByProvider = {
2145
2432
  ...trustedAccountingMap(trustedPrior?.accounting, "checkedAtByProvider"),
@@ -2160,10 +2447,10 @@ async function syncProviderCommand(args) {
2160
2447
  await writeJson(join(stateDir, "provider-records.json"), {
2161
2448
  provider: result.provider,
2162
2449
  fetchedAt: result.fetchedAt,
2163
- completeness: result.completeness,
2450
+ completeness: syncedCompleteness,
2164
2451
  coverage: result.coverage,
2165
- financials: result.financials,
2166
- sourceId: result.source.id,
2452
+ financials: syncedFinancials,
2453
+ sourceId: syncedSource.id,
2167
2454
  records,
2168
2455
  qa: result.qa,
2169
2456
  qaByProvider,
@@ -2189,31 +2476,36 @@ async function syncProviderCommand(args) {
2189
2476
  await appendAuditEvent(stateDir, {
2190
2477
  timestamp: result.fetchedAt,
2191
2478
  action: "source_scanned",
2192
- sourceId: result.source.id,
2479
+ sourceId: syncedSource.id,
2193
2480
  detail: `${args.provider} provider connector synced ${result.records.length} evidence records with ${result.coverage} coverage. Auth reference only; no raw secrets stored.`
2194
2481
  });
2195
2482
  await writeConnectedSpendTrustReceipt(rootPath, await readSafeStateText(stateDir, "spend.json"), { sourceRegistryContents: await readSafeStateText(stateDir, "sources.json") });
2196
2483
  return ok([
2197
2484
  "aibill sync-provider",
2198
2485
  `provider: ${result.provider}`,
2199
- `source: ${result.source.id}`,
2200
- `boundary approval: ${result.source.boundaryApproval}`,
2201
- `validation coverage: ${result.source.validationCoverage}`,
2202
- `financial evidence: ${result.source.financialEvidence}`,
2486
+ `source: ${syncedSource.id}`,
2487
+ `boundary approval: ${syncedSource.boundaryApproval}`,
2488
+ `validation coverage: ${syncedSource.validationCoverage}`,
2489
+ `financial evidence: ${syncedSource.financialEvidence}`,
2203
2490
  `coverage: ${result.coverage}`,
2204
2491
  `records fetched: ${result.records.length}`,
2205
- `headline basis: ${result.financials.headlineBasis}`,
2206
- `synced provider headline: ${formatOptionalUsd(result.financials.headlineUsd)}`,
2492
+ provider === "cursor"
2493
+ ? "source window: current team subscription cycle returned by Cursor"
2494
+ : provider === "github-copilot"
2495
+ ? "source window: latest Copilot metrics-report window plus current seat data"
2496
+ : `source window: requested from ${new Date(args.startTime * 1_000).toISOString()}${args.endTime === undefined ? " through provider current time" : ` through ${new Date(args.endTime * 1_000).toISOString()}`}`,
2497
+ `headline basis: ${syncedFinancials.headlineBasis}`,
2498
+ `synced provider headline: ${formatOptionalUsd(syncedFinancials.headlineUsd)}`,
2207
2499
  `combined headline spend: ${selectProviderFinancialHeadlineRecords(records).some((record) => typeof record.amountUsd === "number") ? formatOptionalUsd(summary.totalUsd) : "unavailable"}`,
2208
- ...(result.financials.apiEquivalentEstimatedUsd !== null
2209
- ? [`API-equivalent estimate (kept separate): $${result.financials.apiEquivalentEstimatedUsd.toFixed(2)}`]
2500
+ ...(syncedFinancials.apiEquivalentEstimatedUsd !== null
2501
+ ? [`API-equivalent estimate (kept separate): ${formatOptionalUsd(syncedFinancials.apiEquivalentEstimatedUsd)}`]
2210
2502
  : []),
2211
2503
  "auth: reference-only; raw secrets were not persisted or printed"
2212
2504
  ].join("\n"));
2213
2505
  }
2214
2506
  catch (error) {
2215
2507
  const sanitizedError = sanitizeSecretishError(error instanceof Error ? error.message : String(error), args.authReference);
2216
- await recordProviderSourceAttempt(stateDir, args.provider, new Date().toISOString(), sanitizedError).catch(() => {
2508
+ await recordProviderSourceAttempt(stateDir, provider, new Date().toISOString(), sanitizedError).catch(() => {
2217
2509
  // Source-status state is diagnostic only. Do not hide the provider's
2218
2510
  // real error if derived-state persistence is unavailable.
2219
2511
  });
@@ -2323,8 +2615,11 @@ async function reportCommand(args) {
2323
2615
  `verification plan: ${artifactPaths.verificationPlan}`,
2324
2616
  `demo package: ${artifactPaths.demoPackage}`,
2325
2617
  reportInput.dataMode === "sample"
2326
- ? `DEMO SAMPLE · illustrative cost/value evidence total: $${reportInput.summary.totalUsd.toFixed(2)} · not user data`
2327
- : `cost/value evidence total: $${reportInput.summary.totalUsd.toFixed(2)}`,
2618
+ ? `DEMO SAMPLE · illustrative cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)} · not user data`
2619
+ : reportInput.dataMode === "connected_provider" &&
2620
+ !(reportInput.allRecords ?? reportInput.providerRecords ?? []).some((record) => typeof record.amountUsd === "number")
2621
+ ? "cost/value evidence total: Unavailable · no priced financial evidence; missing/null is not zero"
2622
+ : `cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)}`,
2328
2623
  "privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
2329
2624
  "",
2330
2625
  "next:",
@@ -2339,7 +2634,7 @@ async function reportCommand(args) {
2339
2634
  return {
2340
2635
  exitCode: 1,
2341
2636
  stdout: "",
2342
- stderr: `Couldn't build a report: ${error instanceof Error ? error.message : String(error)}`
2637
+ stderr: `Couldn't build a report: ${sanitizeSecretishError(error instanceof Error ? error.message : String(error))}`
2343
2638
  };
2344
2639
  }
2345
2640
  }
@@ -2363,7 +2658,10 @@ async function reportCardCommand(args) {
2363
2658
  // would reject a harmless receipt written from the user's home directory.
2364
2659
  // Output still goes through the safe-write/symlink checks below.
2365
2660
  const rootPath = args.sample ? resolve(args.path) : await resolveSafeScanRoot(args.path);
2366
- const { records, mode, providerCoverage } = await loadInstantReadData(args);
2661
+ const { records, mode, providerCoverage, warnings } = await loadInstantReadData(args);
2662
+ if (records.length === 0) {
2663
+ return noEvidenceResult("report-card", warnings, args.sinceDays ?? 30);
2664
+ }
2367
2665
  const headlineRecords = mode === "connected"
2368
2666
  ? selectProviderFinancialHeadlineRecords(records)
2369
2667
  : records;
@@ -2403,7 +2701,7 @@ async function reportCardCommand(args) {
2403
2701
  return {
2404
2702
  exitCode: 1,
2405
2703
  stdout: "",
2406
- stderr: `Couldn't write the report card: ${error instanceof Error ? error.message : String(error)}`
2704
+ stderr: `Couldn't write the report card: ${sanitizeSecretishError(error instanceof Error ? error.message : String(error))}`
2407
2705
  };
2408
2706
  }
2409
2707
  }
@@ -2437,6 +2735,13 @@ async function applyArtifactCommand(args) {
2437
2735
  codingPrompt.trimEnd()
2438
2736
  ].join("\n"));
2439
2737
  }
2738
+ const noCandidateGuidance = codingPrompt.includes("NO SCOPED CHANGE CANDIDATE")
2739
+ ? [
2740
+ "",
2741
+ "No scoped change is supported yet. Collect the missing source evidence instead of guessing:",
2742
+ ...applyEvidenceAcquisitionLines(reportInput)
2743
+ ]
2744
+ : [];
2440
2745
  return ok([
2441
2746
  "aibill apply-artifact",
2442
2747
  `path: ${rootPath}`,
@@ -2445,6 +2750,7 @@ async function applyArtifactCommand(args) {
2445
2750
  `verification plan: ${artifactPaths.verificationPlan}`,
2446
2751
  `demo package: ${artifactPaths.demoPackage}`,
2447
2752
  "safety: generated artifacts only; no external systems changed",
2753
+ ...noCandidateGuidance,
2448
2754
  "",
2449
2755
  `──── copy everything below into Claude Code / Codex (also saved at ${artifactPaths.codingPrompt}) ────`,
2450
2756
  "",
@@ -2455,10 +2761,35 @@ async function applyArtifactCommand(args) {
2455
2761
  return {
2456
2762
  exitCode: 1,
2457
2763
  stdout: "",
2458
- stderr: `Couldn't build apply artifacts: ${error instanceof Error ? error.message : String(error)}`
2764
+ stderr: [
2765
+ `Couldn't build apply artifacts: ${sanitizeSecretishError(error instanceof Error ? error.message : String(error))}`,
2766
+ "Collect evidence, then retry Apply:",
2767
+ "- Claude Code / Codex / Gemini CLI: use the agent normally so supported local history exists.",
2768
+ "- OpenAI / Anthropic / Cursor / GitHub Copilot: run `npx aibill connect <provider>` with an admin credential reference.",
2769
+ "- Diagnose the exact gap: `npx aibill doctor --sources`.",
2770
+ "- Demo only: `npx aibill apply --sample` (non-executable)."
2771
+ ].join("\n")
2459
2772
  };
2460
2773
  }
2461
2774
  }
2775
+ function applyEvidenceAcquisitionLines(input) {
2776
+ const records = input.allRecords ?? input.providerRecords ?? [];
2777
+ const providers = new Set(records.map((record) => record.source.provider));
2778
+ const lines = [];
2779
+ if (providers.has("anthropic") || providers.has("openai") || input.dataMode === "local_logs") {
2780
+ lines.push("- Local coding agents: run `npx aibill context --json` after comparable Claude Code/Codex sessions to inspect action-capable context evidence.");
2781
+ }
2782
+ if (providers.has("gemini") || providers.has("gemini-cli")) {
2783
+ lines.push("- Gemini CLI: current chat evidence is financial-only; unsupported context/action evidence remains missing.");
2784
+ }
2785
+ if (input.dataMode === "connected_provider") {
2786
+ lines.push("- Provider billing: sync explicit call/invocation-level workload evidence; aggregate owner or spend rows do not authorize a change.");
2787
+ }
2788
+ if (lines.length === 0) {
2789
+ lines.push("- Run `npx aibill doctor --sources` to see which local or provider evidence is missing.");
2790
+ }
2791
+ return lines;
2792
+ }
2462
2793
  async function buildExplicitSampleReportInput(rootPath) {
2463
2794
  const records = await loadSampleUsageData();
2464
2795
  return {
@@ -2510,9 +2841,12 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
2510
2841
  // A bundled sample remains sample even if a conflicting mode was written.
2511
2842
  // This guards report and Apply separately from the quickstart read path.
2512
2843
  const mode = isBundledSampleUsage(parsedRecords) ? "sample" : storedMode;
2513
- const records = mode === "sample" || mode === undefined
2844
+ const parsedModeRecords = mode === "sample" || mode === undefined
2514
2845
  ? downgradeSampleUsageEvidence(parsedRecords)
2515
2846
  : parsedRecords;
2847
+ const records = mode === "connected_provider"
2848
+ ? applyProviderContractGate(parsedModeRecords)
2849
+ : parsedModeRecords;
2516
2850
  unavailablePersistedLocalLogs = mode === "local_logs";
2517
2851
  const headlineRecords = mode === "connected_provider"
2518
2852
  ? selectProviderFinancialHeadlineRecords(records)
@@ -2560,6 +2894,7 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
2560
2894
  const logs = await loadLocalAgentUsage({
2561
2895
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
2562
2896
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
2897
+ geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
2563
2898
  sinceIso,
2564
2899
  collectCodexInvocationEvidence: true
2565
2900
  }).catch(() => undefined);
@@ -2588,11 +2923,12 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
2588
2923
  throw new Error(`${untrustedConnectedStateMessage} No connected totals or Apply actions were generated.`);
2589
2924
  }
2590
2925
  if (unavailablePersistedLocalLogs) {
2591
- throw new Error("Persisted local-log state is an untrusted cache and its source Claude Code/Codex records are unavailable. " +
2926
+ throw new Error("Persisted local-log state is an untrusted cache and its source local-agent records are unavailable. " +
2592
2927
  "Re-run `npx aibill` while the local transcripts are available; no report or Apply action was generated from repository state alone.");
2593
2928
  }
2594
- throw new Error("no persisted spend state and no local Claude Code/Codex logs found. " +
2595
- "Run `npx aibill` first (or `npx aibill scan --sample --path <dir>` for a demo-data report).");
2929
+ throw new Error("no persisted spend state and no supported local-agent financial evidence found. " +
2930
+ "Use Claude Code, Codex, or Gemini CLI normally; connect an admin provider for billed cost; " +
2931
+ "or run `npx aibill doctor --sources` to inspect the exact gap.");
2596
2932
  }
2597
2933
  const [discovery, sourceRegistry, missingSourcePrompts, confirmedMappings, persistedProviderRecordsState] = await Promise.all([
2598
2934
  readOptionalJson(join(stateDir, "discovery.json"), emptyDiscovery(rootPath)),
@@ -2651,7 +2987,7 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
2651
2987
  // If live transcript calls are unavailable, omit it instead of fabricating a
2652
2988
  // session-level recommendation from day-aggregate spend records.
2653
2989
  const contextHealth = spendState.mode === "local_logs" && freshLocalCalls
2654
- ? await loadContextHealth(freshLocalCalls, {
2990
+ ? await loadContextHealth(freshLocalCalls.filter((call) => (localAgentFormatSupports(call.agent, "contextHealth"))), {
2655
2991
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
2656
2992
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
2657
2993
  claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
@@ -2740,7 +3076,8 @@ function parseArgs(argv) {
2740
3076
  const parsed = {
2741
3077
  command,
2742
3078
  sample: false,
2743
- path: process.cwd()
3079
+ path: process.cwd(),
3080
+ parseErrors: []
2744
3081
  };
2745
3082
  if (command === "statusline" && rest[0] && !rest[0].startsWith("--")) {
2746
3083
  parsed.statuslineAction = rest.shift();
@@ -2749,8 +3086,26 @@ function parseArgs(argv) {
2749
3086
  parsed.provider = rest[0];
2750
3087
  rest.shift();
2751
3088
  }
3089
+ const valueFlags = new Set([
3090
+ "--plan", "--since-days", "--path", "--out",
3091
+ "--source-path", "--type", "--provider", "--source-id", "--team",
3092
+ "--person", "--client", "--project", "--agent", "--workflow",
3093
+ "--evidence", "--confidence", "--label", "--auth-reference",
3094
+ "--start-time", "--end-time", "--org", "--enterprise", "--account-id",
3095
+ "--interval", "--cycles"
3096
+ ]);
3097
+ const numericValueFlags = new Set([
3098
+ "--since-days", "--confidence", "--start-time", "--end-time", "--interval", "--cycles"
3099
+ ]);
2752
3100
  for (let index = 0; index < rest.length; index += 1) {
2753
3101
  const arg = rest[index];
3102
+ const nextValue = rest[index + 1];
3103
+ const nextLooksLikeFlag = nextValue?.startsWith("--") ||
3104
+ (!numericValueFlags.has(arg) && nextValue?.startsWith("-"));
3105
+ if (valueFlags.has(arg) && (nextValue === undefined || nextLooksLikeFlag)) {
3106
+ parsed.parseErrors.push(`${arg} requires a value`);
3107
+ continue;
3108
+ }
2754
3109
  if (arg === "--sample") {
2755
3110
  parsed.sample = true;
2756
3111
  continue;
@@ -2763,6 +3118,10 @@ function parseArgs(argv) {
2763
3118
  parsed.json = true;
2764
3119
  continue;
2765
3120
  }
3121
+ if (arg === "--full") {
3122
+ parsed.full = true;
3123
+ continue;
3124
+ }
2766
3125
  if (arg === "--sources") {
2767
3126
  parsed.sources = true;
2768
3127
  continue;
@@ -2846,6 +3205,10 @@ function parseArgs(argv) {
2846
3205
  parsed.sourceType = next;
2847
3206
  index += 1;
2848
3207
  }
3208
+ else if (next) {
3209
+ parsed.parseErrors.push(`--type received unsupported source type "${next}"`);
3210
+ index += 1;
3211
+ }
2849
3212
  continue;
2850
3213
  }
2851
3214
  if (arg === "--provider") {
@@ -2928,6 +3291,9 @@ function parseArgs(argv) {
2928
3291
  if (Number.isFinite(value) && value >= 0 && value <= 1) {
2929
3292
  parsed.confidence = value;
2930
3293
  }
3294
+ else {
3295
+ parsed.parseErrors.push("--confidence must be a number between 0 and 1");
3296
+ }
2931
3297
  index += 1;
2932
3298
  }
2933
3299
  continue;
@@ -2951,7 +3317,11 @@ function parseArgs(argv) {
2951
3317
  if (arg === "--start-time") {
2952
3318
  const next = rest[index + 1];
2953
3319
  if (next) {
2954
- parsed.startTime = Number(next);
3320
+ const value = Number(next);
3321
+ if (Number.isFinite(value))
3322
+ parsed.startTime = value;
3323
+ else
3324
+ parsed.parseErrors.push("--start-time must be a finite Unix timestamp");
2955
3325
  index += 1;
2956
3326
  }
2957
3327
  continue;
@@ -2959,7 +3329,11 @@ function parseArgs(argv) {
2959
3329
  if (arg === "--end-time") {
2960
3330
  const next = rest[index + 1];
2961
3331
  if (next) {
2962
- parsed.endTime = Number(next);
3332
+ const value = Number(next);
3333
+ if (Number.isFinite(value))
3334
+ parsed.endTime = value;
3335
+ else
3336
+ parsed.parseErrors.push("--end-time must be a finite Unix timestamp");
2963
3337
  index += 1;
2964
3338
  }
2965
3339
  continue;
@@ -2991,7 +3365,11 @@ function parseArgs(argv) {
2991
3365
  if (arg === "--interval") {
2992
3366
  const next = rest[index + 1];
2993
3367
  if (next) {
2994
- parsed.interval = Number(next);
3368
+ const value = Number(next);
3369
+ if (Number.isFinite(value) && value > 0)
3370
+ parsed.interval = value;
3371
+ else
3372
+ parsed.parseErrors.push("--interval must be a positive number of seconds");
2995
3373
  index += 1;
2996
3374
  }
2997
3375
  continue;
@@ -2999,11 +3377,18 @@ function parseArgs(argv) {
2999
3377
  if (arg === "--cycles") {
3000
3378
  const next = rest[index + 1];
3001
3379
  if (next) {
3002
- parsed.cycles = Number(next);
3380
+ const value = Number(next);
3381
+ if (Number.isInteger(value) && value >= 0)
3382
+ parsed.cycles = value;
3383
+ else
3384
+ parsed.parseErrors.push("--cycles must be a whole number of 0 or greater");
3003
3385
  index += 1;
3004
3386
  }
3005
3387
  continue;
3006
3388
  }
3389
+ parsed.parseErrors.push(arg.startsWith("-")
3390
+ ? `unknown flag "${arg}"`
3391
+ : `unexpected argument "${arg}"`);
3007
3392
  }
3008
3393
  return parsed;
3009
3394
  }
@@ -3057,7 +3442,7 @@ async function readSourceRegistry(stateDir, rootPath) {
3057
3442
  if (parsedSpend.mode === "connected_provider") {
3058
3443
  const trust = await verifyConnectedSourceRegistryTrustReceipt(rootPath, exactSpendContents, exactSourceRegistryContents);
3059
3444
  if (trust.trusted)
3060
- return registry;
3445
+ return applyProviderContractGateToSourceRegistry(registry);
3061
3446
  }
3062
3447
  }
3063
3448
  catch {
@@ -3065,7 +3450,7 @@ async function readSourceRegistry(stateDir, rootPath) {
3065
3450
  // controlled validation/evidence claims are never promoted without the
3066
3451
  // matching external provider-sync receipt.
3067
3452
  }
3068
- return downgradeUntrustedSourceRegistryClaims(registry);
3453
+ return applyProviderContractGateToSourceRegistry(downgradeUntrustedSourceRegistryClaims(registry));
3069
3454
  }
3070
3455
  catch {
3071
3456
  return createLocalFolderSourceRegistry(rootPath);
@@ -3131,7 +3516,9 @@ function helpText() {
3131
3516
  "aibill — your AI cost and usage evidence in one private view",
3132
3517
  "",
3133
3518
  "Run with no command for an instant, zero-key local readout:",
3134
- " npx aibill Show available AI cost/value evidence (sample or local data)",
3519
+ " npx aibill Show a compact receipt from available local/connected evidence",
3520
+ " npx aibill --full Show the complete diagnose → recommend → apply → verify audit",
3521
+ " npx aibill --sample Show the clearly labeled illustrative demo (never implicit)",
3135
3522
  " npx aibill --group-by agent Drill down by source|model|client|project|agent|user|workspace|apiKey",
3136
3523
  " npx aibill --plan <id> Declare your plan when auto-detection can't (claude-max-5x|claude-max-20x|claude-pro|chatgpt-plus|chatgpt-pro)",
3137
3524
  "",
@@ -3161,12 +3548,14 @@ function helpText() {
3161
3548
  " scan [--path <dir>] Scan a local workspace for AI usage signals",
3162
3549
  " scan --sample Include deterministic sample spend analysis",
3163
3550
  " quickstart [--sample] [--since-days N] Plain-English local readout (default 30 days)",
3551
+ " [--full] Render the complete audit; default is the compact receipt",
3164
3552
  " [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: project for local logs; model otherwise",
3165
3553
  " report [--sample] [--out <name>] [--since-days N] Generate local Markdown and HTML reports from the same window",
3166
3554
  " report-card [--out f.svg] Write your AI Receipt — a redacted, shareable SVG + caption",
3167
3555
  " glance [--project <name>] [--plan <id>] [--since-days N] Emit the local, machine-readable Glance snapshot JSON",
3168
3556
  " context [--project <name>] [--since-days N] Show hook-aware Context Health in the terminal",
3169
3557
  " [--json] Emit the same canonical Context Health object used by MCP and Glance",
3558
+ " Main receipt JSON is not published yet; unsupported --json requests fail instead of returning text.",
3170
3559
  " apply [--sample] [--since-days N] Print an evidence-constrained inspection/approval prompt + verification plans",
3171
3560
  " apply-artifact Same as `apply` (long form)",
3172
3561
  "",
@@ -3215,7 +3604,7 @@ export async function runMain() {
3215
3604
  if (isInstantDemo && process.stdout.isTTY && !process.env.NO_COLOR) {
3216
3605
  try {
3217
3606
  const { default: yoctoSpinner } = await import("yocto-spinner");
3218
- spinner = yoctoSpinner({ text: "Analyzing your AI spend…" }).start();
3607
+ spinner = yoctoSpinner({ text: "Reading local AI evidence…" }).start();
3219
3608
  }
3220
3609
  catch {
3221
3610
  // Spinner is optional; never block the wow on it.