ai-spend-agent 0.8.0 → 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
@@ -9,13 +9,33 @@ import { StatuslineInstallerError, installClaudeStatusline, uninstallClaudeStatu
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, summaryRecords)}`, ...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;
@@ -206,7 +240,7 @@ async function glanceCommand(args) {
206
240
  return {
207
241
  exitCode: 1,
208
242
  stdout: "",
209
- 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(", ")}`
210
244
  };
211
245
  }
212
246
  detectedPlans = [override];
@@ -326,7 +360,7 @@ function quickstartNextSteps(mode, detected) {
326
360
  if (detected.length > 0) {
327
361
  const names = detected.map((credential) => `${credential.provider} (${credential.hint})`).join(", ");
328
362
  steps.push(`Found local key${detected.length === 1 ? "" : "s"}: ${names}`);
329
- 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`);
330
364
  }
331
365
  steps.push(mode === "demo"
332
366
  ? "npx aibill report --sample write a clearly labeled demo Markdown + HTML report"
@@ -429,14 +463,22 @@ async function loadInstantReadData(args) {
429
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.`);
430
464
  }
431
465
  // Real local agent logs (Claude Code / Codex) beat any sample/legacy state.
432
- const logs = await loadLocalAgentUsage({
433
- // Env overrides keep tests (and unusual installs) isolated from $HOME.
434
- claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
435
- codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
436
- geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
437
- sinceIso: sinceIsoForDays(args.sinceDays ?? 30),
438
- collectCodexInvocationEvidence: true
439
- }).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
+ }
440
482
  if (logs && logs.records.length > 0) {
441
483
  // Persisted local_logs state (written by report/apply-artifact) is the
442
484
  // same data source we just re-read — superseding it silently is correct,
@@ -461,20 +503,81 @@ async function loadInstantReadData(args) {
461
503
  codexInvocationFiles: logs?.codexInvocationFiles
462
504
  };
463
505
  }
464
- // No real logs. Persisted sample/legacy state may still be shown, but only as
465
- // DEMO (never as connected), with a warning when its origin is unknown.
506
+ // No real logs. Sample and legacy persisted state must never appear unless
507
+ // this invocation explicitly opted into --sample.
466
508
  if (persisted && persisted.records.length > 0 && persisted.mode !== "connected_provider" && persisted.mode !== "local_logs") {
467
- if (persisted.mode === undefined) {
468
- 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.");
469
- }
470
- 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.");
471
512
  }
472
513
  if (persisted?.mode === "local_logs") {
473
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.");
474
515
  }
475
- // loadSampleUsageData resolves the bundled CSVs relative to the installed
476
- // package, so this works from ANY directory (true zero-config).
477
- 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}`);
478
581
  }
479
582
  /** A one-line, unmissable banner telling the user which data they're seeing. */
480
583
  function dataModeBanner(mode, records) {
@@ -538,16 +641,16 @@ async function doctorCommand(args) {
538
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");
539
642
  }
540
643
  if (!hasLocalSource)
541
- warnings.push("no supported Claude Code, Codex, or Gemini CLI session evidence found — a first run here will show DEMO sample data");
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");
542
645
  if (providerRefs.length === 0)
543
- 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)");
544
647
  const predictedMode = connectedStateTrusted
545
648
  ? "connected provider billing"
546
649
  : hasFinancialLogs
547
650
  ? "your local agent logs (estimated at API-equivalent rates)"
548
651
  : geminiPresenceFound
549
652
  ? "local Gemini CLI presence only (financial evidence unavailable; no sample substituted)"
550
- : "demo sample (illustrative)";
653
+ : "no supported evidence yet (no sample substituted)";
551
654
  const lines = [
552
655
  "aibill doctor",
553
656
  `node version: ${process.version}`,
@@ -1029,7 +1132,7 @@ async function resetCommand(args) {
1029
1132
  "aibill reset",
1030
1133
  `path: ${rootPath}`,
1031
1134
  "nothing to clear (no persisted spend state found)",
1032
- "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."
1033
1136
  ].join("\n"));
1034
1137
  }
1035
1138
  // Clear derived spend state so a prior `scan --sample` (or stale provider
@@ -1049,7 +1152,7 @@ async function resetCommand(args) {
1049
1152
  "aibill reset",
1050
1153
  `path: ${rootPath}`,
1051
1154
  removed.length > 0 ? `cleared: ${removed.join(", ")}` : "nothing to clear (no persisted spend state found)",
1052
- "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."
1053
1156
  ].join("\n"));
1054
1157
  }
1055
1158
  async function statuslineCommand(args, runtime) {
@@ -1165,7 +1268,7 @@ async function initCommand(args, runtime = {}) {
1165
1268
  return {
1166
1269
  exitCode: 1,
1167
1270
  stdout: "",
1168
- 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(", ")}`
1169
1272
  };
1170
1273
  }
1171
1274
  detectedPlanOverride = [override];
@@ -1348,7 +1451,7 @@ async function refreshStatuslineCommand(args, runtime) {
1348
1451
  return {
1349
1452
  exitCode: 1,
1350
1453
  stdout: "",
1351
- 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(", ")}`
1352
1455
  };
1353
1456
  }
1354
1457
  detectedPlanOverride = [override];
@@ -1706,7 +1809,7 @@ function initApiEquivalentWindowLines(snapshot) {
1706
1809
  agent.unsupportedUsageSnapshots === 0 &&
1707
1810
  agent.jsonlValidationCoverage === "complete");
1708
1811
  return [completeZero
1709
- ? "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"
1710
1813
  : "API-equivalent usage value: unavailable — local source coverage is incomplete; no zero total was inferred"];
1711
1814
  }
1712
1815
  if (snapshot.mode === "unresolved" && snapshot.unresolved) {
@@ -1826,7 +1929,7 @@ async function scanCommand(args) {
1826
1929
  const mappings = attributeUsageRecords(records);
1827
1930
  await writeLocalSpendState(stateDir, records, summary, mappings, "sample");
1828
1931
  lines.push(`sample records: ${records.length}`);
1829
- lines.push(`total spend: $${summary.totalUsd.toFixed(2)}`);
1932
+ lines.push(`total spend: ${formatOptionalUsd(summary.totalUsd)}`);
1830
1933
  lines.push(`attribution mappings: ${mappings.length}`);
1831
1934
  }
1832
1935
  if (discovery.signals.length > 0) {
@@ -1858,6 +1961,9 @@ async function watchCommand(args) {
1858
1961
  while (unbounded || iteration < cycles) {
1859
1962
  const previous = await readOptionalJson(join(stateDir, "watch-latest.json"), null);
1860
1963
  const { summary, snapshot, records, mode } = await runWatchCycle(stateDir, args);
1964
+ if (records.length === 0) {
1965
+ return noEvidenceResult("watch", [], args.sinceDays ?? 30);
1966
+ }
1861
1967
  const deltaHeadline = buildDeltaHeadline(previous, snapshot);
1862
1968
  // Watch's job is DELTAS: render the compact breakdown view per cycle, not
1863
1969
  // the whole diagnose→verify readout again (the quickstart owns that).
@@ -1866,6 +1972,7 @@ async function watchCommand(args) {
1866
1972
  groupBy: args.groupBy ?? "model",
1867
1973
  color: args.noColor ? false : undefined,
1868
1974
  mode: mode === "sample" ? "demo" : mode === "connected_provider" ? "connected" : "local-logs",
1975
+ width: terminalOutputWidth(),
1869
1976
  view: "breakdown"
1870
1977
  });
1871
1978
  const stamped = [
@@ -1929,8 +2036,8 @@ async function runWatchCycle(stateDir, args) {
1929
2036
  mode = "local_logs";
1930
2037
  }
1931
2038
  else {
1932
- records = await loadSampleUsageData();
1933
- mode = "sample";
2039
+ records = [];
2040
+ mode = "local_logs";
1934
2041
  }
1935
2042
  }
1936
2043
  }
@@ -1961,7 +2068,7 @@ async function runWatchCycle(stateDir, args) {
1961
2068
  sourceId: "watch",
1962
2069
  detail: snapshot.totalUsd === null
1963
2070
  ? `Watch cycle captured ${snapshot.recordCount} records with no priced financial evidence; total unavailable.`
1964
- : `Watch cycle captured ${snapshot.recordCount} records totaling $${snapshot.totalUsd.toFixed(2)}.`
2071
+ : `Watch cycle captured ${snapshot.recordCount} records totaling ${formatOptionalUsd(snapshot.totalUsd)}.`
1965
2072
  });
1966
2073
  return { summary, snapshot, records: headlineRecords, mode };
1967
2074
  }
@@ -1970,24 +2077,24 @@ function buildDeltaHeadline(previous, current) {
1970
2077
  if (current.totalUsd === null) {
1971
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.`;
1972
2079
  }
1973
- return `First watch snapshot. Baseline AI spend is $${current.totalUsd.toFixed(2)} across ${current.recordCount} charges. Future cycles will report what changed.`;
2080
+ return `First watch snapshot. Baseline AI spend is ${formatOptionalUsd(current.totalUsd)} across ${current.recordCount} charges. Future cycles will report what changed.`;
1974
2081
  }
1975
2082
  if (current.totalUsd === null) {
1976
2083
  return `Financial evidence is unavailable across ${current.recordCount} records; missing/null is not zero and no numeric delta was calculated.`;
1977
2084
  }
1978
2085
  if (previous.totalUsd === null) {
1979
- return `A priced financial baseline is now available at $${current.totalUsd.toFixed(2)} across ${current.recordCount} records. The prior snapshot was unavailable, so no numeric delta was calculated.`;
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.`;
1980
2087
  }
1981
2088
  const deltaUsd = roundMoneyCli(current.totalUsd - previous.totalUsd);
1982
2089
  const lines = [];
1983
- if (Math.abs(deltaUsd) < 0.01) {
1984
- 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)}.`);
1985
2092
  }
1986
2093
  else {
1987
2094
  const direction = deltaUsd > 0 ? "UP" : "DOWN";
1988
2095
  const percent = previous.totalUsd > 0 ? Math.round((deltaUsd / previous.totalUsd) * 100) : 100;
1989
- lines.push(`Spend is ${direction} $${Math.abs(deltaUsd).toFixed(2)} (${Math.abs(percent)}%) since the last check — ` +
1990
- `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)}.`);
1991
2098
  }
1992
2099
  // New-model and per-model spike detection versus the previous snapshot.
1993
2100
  const previousModels = new Map(previous.byModel.map((entry) => [entry.key, entry.amountUsd]));
@@ -1996,12 +2103,12 @@ function buildDeltaHeadline(previous, current) {
1996
2103
  const before = previousModels.get(entry.key);
1997
2104
  if (before === undefined) {
1998
2105
  if (entry.amountUsd >= 1) {
1999
- 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)}.`);
2000
2107
  }
2001
2108
  continue;
2002
2109
  }
2003
2110
  if (before > 0 && entry.amountUsd - before >= 5 && entry.amountUsd / before >= 1.5) {
2004
- 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)}.`);
2005
2112
  }
2006
2113
  }
2007
2114
  if (anomalies.length > 0) {
@@ -2013,7 +2120,7 @@ function sleep(milliseconds) {
2013
2120
  return new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds));
2014
2121
  }
2015
2122
  function roundMoneyCli(value) {
2016
- return Math.round(value * 100) / 100;
2123
+ return Math.round(value * 10_000) / 10_000;
2017
2124
  }
2018
2125
  async function addSourceCommand(args) {
2019
2126
  const rootPath = resolve(args.path);
@@ -2074,6 +2181,10 @@ const adminUpgradeProviders = {
2074
2181
  "github-copilot": "requires a GitHub BILLING-ADMIN token (org/enterprise)",
2075
2182
  copilot: "requires a GitHub BILLING-ADMIN token (org/enterprise)"
2076
2183
  };
2184
+ const supportedAdminProviders = new Set(["openai", "anthropic", "cursor", "github-copilot"]);
2185
+ const providerAliases = {
2186
+ copilot: "github-copilot"
2187
+ };
2077
2188
  const providerAdminEnvHint = {
2078
2189
  openai: "env:OPENAI_ADMIN_KEY",
2079
2190
  anthropic: "env:ANTHROPIC_ADMIN_KEY",
@@ -2081,10 +2192,20 @@ const providerAdminEnvHint = {
2081
2192
  "github-copilot": "env:GITHUB_TOKEN",
2082
2193
  copilot: "env:GITHUB_TOKEN"
2083
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
+ }
2084
2204
  async function connectCommand(args) {
2085
2205
  const rootPath = resolve(args.path);
2086
2206
  const stateDir = join(rootPath, ".ai-spend-agent");
2087
- const provider = args.provider ?? "unknown";
2207
+ const requestedProvider = (args.provider ?? "unknown").trim().toLowerCase();
2208
+ const provider = providerAliases[requestedProvider] ?? requestedProvider;
2088
2209
  if (!provider || provider === "unknown") {
2089
2210
  return {
2090
2211
  exitCode: 1,
@@ -2100,6 +2221,20 @@ async function connectCommand(args) {
2100
2221
  };
2101
2222
  }
2102
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
+ }
2103
2238
  const registry = await readSourceRegistry(stateDir, rootPath);
2104
2239
  const source = createProviderConnectorStub(provider, type);
2105
2240
  const nextRegistry = addApprovedSource(registry, source);
@@ -2126,7 +2261,7 @@ async function connectCommand(args) {
2126
2261
  `boundary approval: ${source.boundaryApproval}`,
2127
2262
  `validation coverage: ${source.validationCoverage}`,
2128
2263
  `financial evidence: ${source.financialEvidence}`,
2129
- "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"}`
2130
2265
  ];
2131
2266
  if (selfServeProviders.has(provider)) {
2132
2267
  lines.push("tier: self-serve — an org owner can enable this in ~2 minutes");
@@ -2140,19 +2275,19 @@ async function connectCommand(args) {
2140
2275
  lines.push(`auto-detected: a ${provider} key in ${detected.reference} (${detected.hint}) from ${describeOrigin(detected)}`);
2141
2276
  if (detected.isLikelyAdminKey) {
2142
2277
  const adminRef = providerAdminEnvHint[provider] ?? detected.reference;
2143
- lines.push(`next: npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
2278
+ lines.push(`next: ${providerSyncSetupCommand(provider, adminRef)}`);
2144
2279
  }
2145
2280
  else {
2146
2281
  const adminRef = providerAdminEnvHint[provider] ?? "env:YOUR_ADMIN_KEY";
2147
2282
  lines.push(`this looks like a regular key — for COST data set an admin key in ${adminRef}, then:`);
2148
- lines.push(` npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
2283
+ lines.push(` ${providerSyncSetupCommand(provider, adminRef)}`);
2149
2284
  }
2150
2285
  }
2151
2286
  else {
2152
2287
  const adminRef = providerAdminEnvHint[provider] ?? "env:YOUR_ADMIN_KEY";
2153
2288
  lines.push("");
2154
2289
  lines.push(`next: export an admin key reference, e.g. ${adminRef}, then run:`);
2155
- lines.push(` npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
2290
+ lines.push(` ${providerSyncSetupCommand(provider, adminRef)}`);
2156
2291
  }
2157
2292
  lines.push(`missing: ${source.fieldsMissing.join(", ")}`);
2158
2293
  return ok(lines.join("\n"));
@@ -2181,17 +2316,62 @@ function describeOrigin(credential) {
2181
2316
  async function syncProviderCommand(args) {
2182
2317
  const rootPath = resolve(args.path);
2183
2318
  const stateDir = join(rootPath, ".ai-spend-agent");
2184
- 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)) {
2185
2332
  return {
2186
2333
  exitCode: 1,
2187
2334
  stdout: "",
2188
2335
  stderr: [
2189
- "sync-provider requires --provider, --auth-reference env:NAME, and --start-time <unix seconds>.",
2190
- "example:",
2191
- " 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."
2192
2338
  ].join("\n")
2193
2339
  };
2194
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
+ }
2195
2375
  try {
2196
2376
  // A provider sync may merge only a prior connected snapshot whose exact
2197
2377
  // repository bytes have a matching external machine receipt. A cloned
@@ -2203,10 +2383,13 @@ async function syncProviderCommand(args) {
2203
2383
  ? priorSpend
2204
2384
  : undefined;
2205
2385
  const result = await fetchProviderUsageRecords({
2206
- provider: args.provider,
2207
- sourceId: `${args.provider}-provider-api`,
2386
+ provider,
2387
+ sourceId: `${provider}-provider-api`,
2208
2388
  authReference: args.authReference,
2209
- 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,
2210
2393
  endTime: args.endTime,
2211
2394
  org: args.org,
2212
2395
  enterprise: args.enterprise,
@@ -2306,18 +2489,23 @@ async function syncProviderCommand(args) {
2306
2489
  `financial evidence: ${syncedSource.financialEvidence}`,
2307
2490
  `coverage: ${result.coverage}`,
2308
2491
  `records fetched: ${result.records.length}`,
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()}`}`,
2309
2497
  `headline basis: ${syncedFinancials.headlineBasis}`,
2310
2498
  `synced provider headline: ${formatOptionalUsd(syncedFinancials.headlineUsd)}`,
2311
2499
  `combined headline spend: ${selectProviderFinancialHeadlineRecords(records).some((record) => typeof record.amountUsd === "number") ? formatOptionalUsd(summary.totalUsd) : "unavailable"}`,
2312
2500
  ...(syncedFinancials.apiEquivalentEstimatedUsd !== null
2313
- ? [`API-equivalent estimate (kept separate): $${syncedFinancials.apiEquivalentEstimatedUsd.toFixed(2)}`]
2501
+ ? [`API-equivalent estimate (kept separate): ${formatOptionalUsd(syncedFinancials.apiEquivalentEstimatedUsd)}`]
2314
2502
  : []),
2315
2503
  "auth: reference-only; raw secrets were not persisted or printed"
2316
2504
  ].join("\n"));
2317
2505
  }
2318
2506
  catch (error) {
2319
2507
  const sanitizedError = sanitizeSecretishError(error instanceof Error ? error.message : String(error), args.authReference);
2320
- await recordProviderSourceAttempt(stateDir, args.provider, new Date().toISOString(), sanitizedError).catch(() => {
2508
+ await recordProviderSourceAttempt(stateDir, provider, new Date().toISOString(), sanitizedError).catch(() => {
2321
2509
  // Source-status state is diagnostic only. Do not hide the provider's
2322
2510
  // real error if derived-state persistence is unavailable.
2323
2511
  });
@@ -2427,11 +2615,11 @@ async function reportCommand(args) {
2427
2615
  `verification plan: ${artifactPaths.verificationPlan}`,
2428
2616
  `demo package: ${artifactPaths.demoPackage}`,
2429
2617
  reportInput.dataMode === "sample"
2430
- ? `DEMO SAMPLE · illustrative cost/value evidence total: $${reportInput.summary.totalUsd.toFixed(2)} · not user data`
2618
+ ? `DEMO SAMPLE · illustrative cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)} · not user data`
2431
2619
  : reportInput.dataMode === "connected_provider" &&
2432
2620
  !(reportInput.allRecords ?? reportInput.providerRecords ?? []).some((record) => typeof record.amountUsd === "number")
2433
2621
  ? "cost/value evidence total: Unavailable · no priced financial evidence; missing/null is not zero"
2434
- : `cost/value evidence total: $${reportInput.summary.totalUsd.toFixed(2)}`,
2622
+ : `cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)}`,
2435
2623
  "privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
2436
2624
  "",
2437
2625
  "next:",
@@ -2446,7 +2634,7 @@ async function reportCommand(args) {
2446
2634
  return {
2447
2635
  exitCode: 1,
2448
2636
  stdout: "",
2449
- 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))}`
2450
2638
  };
2451
2639
  }
2452
2640
  }
@@ -2470,7 +2658,10 @@ async function reportCardCommand(args) {
2470
2658
  // would reject a harmless receipt written from the user's home directory.
2471
2659
  // Output still goes through the safe-write/symlink checks below.
2472
2660
  const rootPath = args.sample ? resolve(args.path) : await resolveSafeScanRoot(args.path);
2473
- 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
+ }
2474
2665
  const headlineRecords = mode === "connected"
2475
2666
  ? selectProviderFinancialHeadlineRecords(records)
2476
2667
  : records;
@@ -2510,7 +2701,7 @@ async function reportCardCommand(args) {
2510
2701
  return {
2511
2702
  exitCode: 1,
2512
2703
  stdout: "",
2513
- 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))}`
2514
2705
  };
2515
2706
  }
2516
2707
  }
@@ -2544,6 +2735,13 @@ async function applyArtifactCommand(args) {
2544
2735
  codingPrompt.trimEnd()
2545
2736
  ].join("\n"));
2546
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
+ : [];
2547
2745
  return ok([
2548
2746
  "aibill apply-artifact",
2549
2747
  `path: ${rootPath}`,
@@ -2552,6 +2750,7 @@ async function applyArtifactCommand(args) {
2552
2750
  `verification plan: ${artifactPaths.verificationPlan}`,
2553
2751
  `demo package: ${artifactPaths.demoPackage}`,
2554
2752
  "safety: generated artifacts only; no external systems changed",
2753
+ ...noCandidateGuidance,
2555
2754
  "",
2556
2755
  `──── copy everything below into Claude Code / Codex (also saved at ${artifactPaths.codingPrompt}) ────`,
2557
2756
  "",
@@ -2562,10 +2761,35 @@ async function applyArtifactCommand(args) {
2562
2761
  return {
2563
2762
  exitCode: 1,
2564
2763
  stdout: "",
2565
- 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")
2566
2772
  };
2567
2773
  }
2568
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
+ }
2569
2793
  async function buildExplicitSampleReportInput(rootPath) {
2570
2794
  const records = await loadSampleUsageData();
2571
2795
  return {
@@ -2703,7 +2927,8 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
2703
2927
  "Re-run `npx aibill` while the local transcripts are available; no report or Apply action was generated from repository state alone.");
2704
2928
  }
2705
2929
  throw new Error("no persisted spend state and no supported local-agent financial evidence found. " +
2706
- "Run `npx aibill` first (or `npx aibill scan --sample --path <dir>` for a demo-data report).");
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.");
2707
2932
  }
2708
2933
  const [discovery, sourceRegistry, missingSourcePrompts, confirmedMappings, persistedProviderRecordsState] = await Promise.all([
2709
2934
  readOptionalJson(join(stateDir, "discovery.json"), emptyDiscovery(rootPath)),
@@ -2851,7 +3076,8 @@ function parseArgs(argv) {
2851
3076
  const parsed = {
2852
3077
  command,
2853
3078
  sample: false,
2854
- path: process.cwd()
3079
+ path: process.cwd(),
3080
+ parseErrors: []
2855
3081
  };
2856
3082
  if (command === "statusline" && rest[0] && !rest[0].startsWith("--")) {
2857
3083
  parsed.statuslineAction = rest.shift();
@@ -2860,8 +3086,26 @@ function parseArgs(argv) {
2860
3086
  parsed.provider = rest[0];
2861
3087
  rest.shift();
2862
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
+ ]);
2863
3100
  for (let index = 0; index < rest.length; index += 1) {
2864
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
+ }
2865
3109
  if (arg === "--sample") {
2866
3110
  parsed.sample = true;
2867
3111
  continue;
@@ -2874,6 +3118,10 @@ function parseArgs(argv) {
2874
3118
  parsed.json = true;
2875
3119
  continue;
2876
3120
  }
3121
+ if (arg === "--full") {
3122
+ parsed.full = true;
3123
+ continue;
3124
+ }
2877
3125
  if (arg === "--sources") {
2878
3126
  parsed.sources = true;
2879
3127
  continue;
@@ -2957,6 +3205,10 @@ function parseArgs(argv) {
2957
3205
  parsed.sourceType = next;
2958
3206
  index += 1;
2959
3207
  }
3208
+ else if (next) {
3209
+ parsed.parseErrors.push(`--type received unsupported source type "${next}"`);
3210
+ index += 1;
3211
+ }
2960
3212
  continue;
2961
3213
  }
2962
3214
  if (arg === "--provider") {
@@ -3039,6 +3291,9 @@ function parseArgs(argv) {
3039
3291
  if (Number.isFinite(value) && value >= 0 && value <= 1) {
3040
3292
  parsed.confidence = value;
3041
3293
  }
3294
+ else {
3295
+ parsed.parseErrors.push("--confidence must be a number between 0 and 1");
3296
+ }
3042
3297
  index += 1;
3043
3298
  }
3044
3299
  continue;
@@ -3062,7 +3317,11 @@ function parseArgs(argv) {
3062
3317
  if (arg === "--start-time") {
3063
3318
  const next = rest[index + 1];
3064
3319
  if (next) {
3065
- 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");
3066
3325
  index += 1;
3067
3326
  }
3068
3327
  continue;
@@ -3070,7 +3329,11 @@ function parseArgs(argv) {
3070
3329
  if (arg === "--end-time") {
3071
3330
  const next = rest[index + 1];
3072
3331
  if (next) {
3073
- 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");
3074
3337
  index += 1;
3075
3338
  }
3076
3339
  continue;
@@ -3102,7 +3365,11 @@ function parseArgs(argv) {
3102
3365
  if (arg === "--interval") {
3103
3366
  const next = rest[index + 1];
3104
3367
  if (next) {
3105
- 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");
3106
3373
  index += 1;
3107
3374
  }
3108
3375
  continue;
@@ -3110,11 +3377,18 @@ function parseArgs(argv) {
3110
3377
  if (arg === "--cycles") {
3111
3378
  const next = rest[index + 1];
3112
3379
  if (next) {
3113
- 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");
3114
3385
  index += 1;
3115
3386
  }
3116
3387
  continue;
3117
3388
  }
3389
+ parsed.parseErrors.push(arg.startsWith("-")
3390
+ ? `unknown flag "${arg}"`
3391
+ : `unexpected argument "${arg}"`);
3118
3392
  }
3119
3393
  return parsed;
3120
3394
  }
@@ -3242,7 +3516,9 @@ function helpText() {
3242
3516
  "aibill — your AI cost and usage evidence in one private view",
3243
3517
  "",
3244
3518
  "Run with no command for an instant, zero-key local readout:",
3245
- " 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)",
3246
3522
  " npx aibill --group-by agent Drill down by source|model|client|project|agent|user|workspace|apiKey",
3247
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)",
3248
3524
  "",
@@ -3272,12 +3548,14 @@ function helpText() {
3272
3548
  " scan [--path <dir>] Scan a local workspace for AI usage signals",
3273
3549
  " scan --sample Include deterministic sample spend analysis",
3274
3550
  " quickstart [--sample] [--since-days N] Plain-English local readout (default 30 days)",
3551
+ " [--full] Render the complete audit; default is the compact receipt",
3275
3552
  " [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: project for local logs; model otherwise",
3276
3553
  " report [--sample] [--out <name>] [--since-days N] Generate local Markdown and HTML reports from the same window",
3277
3554
  " report-card [--out f.svg] Write your AI Receipt — a redacted, shareable SVG + caption",
3278
3555
  " glance [--project <name>] [--plan <id>] [--since-days N] Emit the local, machine-readable Glance snapshot JSON",
3279
3556
  " context [--project <name>] [--since-days N] Show hook-aware Context Health in the terminal",
3280
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.",
3281
3559
  " apply [--sample] [--since-days N] Print an evidence-constrained inspection/approval prompt + verification plans",
3282
3560
  " apply-artifact Same as `apply` (long form)",
3283
3561
  "",
@@ -3326,7 +3604,7 @@ export async function runMain() {
3326
3604
  if (isInstantDemo && process.stdout.isTTY && !process.env.NO_COLOR) {
3327
3605
  try {
3328
3606
  const { default: yoctoSpinner } = await import("yocto-spinner");
3329
- spinner = yoctoSpinner({ text: "Analyzing your AI spend…" }).start();
3607
+ spinner = yoctoSpinner({ text: "Reading local AI evidence…" }).start();
3330
3608
  }
3331
3609
  catch {
3332
3610
  // Spinner is optional; never block the wow on it.
@@ -17,6 +17,11 @@ const CACHE_MAX_BYTES = 64 * 1_024;
17
17
  const DEFAULT_COLUMNS = 100;
18
18
  const MAX_COLUMNS = 240;
19
19
  const STALE_AFTER_MS = 5 * 60 * 1_000;
20
+ // A freshly regenerated cache does not make an old transcript percentage current.
21
+ // Five-hour evidence expires with that window; weekly evidence must have been
22
+ // observed within the last day before it can be presented as live runway.
23
+ const FIVE_HOUR_LIMIT_FRESHNESS_MS = 5 * 60 * 60 * 1_000;
24
+ const WEEKLY_LIMIT_FRESHNESS_MS = 24 * 60 * 60 * 1_000;
20
25
  const STDIN_MAX_BYTES = 64 * 1_024;
21
26
  const STDIN_DRAIN_TIMEOUT_MS = 25;
22
27
  const guardedOutputs = new WeakSet();
@@ -276,8 +281,9 @@ function renderSubscription(snapshot, tier, now, timeZone) {
276
281
  ? [...limits].sort((left, right) => limitKindOrder(left.kind) - limitKindOrder(right.kind))
277
282
  : [...limits].sort(compareUrgency).slice(0, 1);
278
283
  const segments = ordered.map((limit) => formatLimit(limit, now, timeZone));
279
- if (limits.length === 0)
280
- segments.push("subscription detected", "runway not reported");
284
+ if (limits.length === 0) {
285
+ segments.push("subscription detected", missingRunwaySegment(snapshot, now));
286
+ }
281
287
  const value = agent.apiEquivalent.sevenDays;
282
288
  if (value.amountUsd !== null && value.financialEvidence === "estimated") {
283
289
  segments.push(`~${formatUsd(value.amountUsd)} 7d value`);
@@ -299,8 +305,9 @@ function renderMixed(snapshot, tier, now, timeZone) {
299
305
  ? [...limits].sort((left, right) => limitKindOrder(left.kind) - limitKindOrder(right.kind))
300
306
  : [...limits].sort(compareUrgency).slice(0, 1);
301
307
  const segments = selectedLimits.map((limit) => formatLimit(limit, now, timeZone));
302
- if (limits.length === 0 && tier !== "minimal")
303
- segments.push("runway not reported");
308
+ if (limits.length === 0 && tier !== "minimal") {
309
+ segments.push(missingRunwaySegment(snapshot, now));
310
+ }
304
311
  appendMeteredSevenDaySegment(snapshot, segments);
305
312
  const value = agent?.apiEquivalent.sevenDays;
306
313
  if (value?.amountUsd !== null && value?.amountUsd !== undefined &&
@@ -318,8 +325,12 @@ function renderMultiSubscriptionSegments(snapshot, tier, now, timeZone) {
318
325
  const segments = selected.map(({ agent, limit }) => tier === "full"
319
326
  ? formatAttributedLimit(agent, limit, now, timeZone)
320
327
  : formatAttributedLimitCompact(agent, limit));
321
- if (entries.length === 0 && tier !== "minimal") {
322
- segments.push("subscription detected", "runway not reported");
328
+ const staleRunway = staleRunwaySegments(snapshot, now);
329
+ if (staleRunway.length > 0 && tier !== "minimal") {
330
+ segments.push(...staleRunway);
331
+ }
332
+ else if (entries.length === 0 && tier !== "minimal") {
333
+ segments.push("subscription detected", missingRunwaySegment(snapshot, now));
323
334
  }
324
335
  const agents = orderedSubscriptionAgents(snapshot, now);
325
336
  const valueAgents = tier === "full" ? agents : agents.slice(0, 1);
@@ -422,7 +433,30 @@ function latestLimitObservation(agent, now) {
422
433
  return Math.max(0, ...activeLimits(agent, now).map((limit) => Date.parse(limit.observedAt)));
423
434
  }
424
435
  function activeLimits(agent, now) {
425
- return agent.limits.filter((limit) => Date.parse(limit.resetsAt) > now.getTime());
436
+ return agent.limits.filter((limit) => (Date.parse(limit.resetsAt) > now.getTime() && isFreshLimitEvidence(limit, now)));
437
+ }
438
+ function staleActiveLimits(agent, now) {
439
+ return agent.limits.filter((limit) => (Date.parse(limit.resetsAt) > now.getTime() && !isFreshLimitEvidence(limit, now)));
440
+ }
441
+ function isFreshLimitEvidence(limit, now) {
442
+ const observedMs = Date.parse(limit.observedAt);
443
+ const ageMs = now.getTime() - observedMs;
444
+ const maximumAgeMs = limit.kind === "five-hour"
445
+ ? FIVE_HOUR_LIMIT_FRESHNESS_MS
446
+ : WEEKLY_LIMIT_FRESHNESS_MS;
447
+ return Number.isFinite(observedMs) && ageMs >= 0 && ageMs <= maximumAgeMs;
448
+ }
449
+ function hasStaleLimitEvidence(snapshot, now) {
450
+ return (snapshot.subscription?.agents ?? [])
451
+ .some((agent) => staleActiveLimits(agent, now).length > 0);
452
+ }
453
+ function missingRunwaySegment(snapshot, now) {
454
+ return hasStaleLimitEvidence(snapshot, now) ? "runway stale" : "runway not reported";
455
+ }
456
+ function staleRunwaySegments(snapshot, now) {
457
+ return orderedSubscriptionAgents(snapshot, now)
458
+ .filter((agent) => activeLimits(agent, now).length === 0 && staleActiveLimits(agent, now).length > 0)
459
+ .map((agent) => `${subscriptionAgentLabel(agent)} runway stale`);
426
460
  }
427
461
  function compareUrgency(left, right) {
428
462
  const remainingDifference = left.remainingPercent - right.remainingPercent;
@@ -516,6 +550,8 @@ function formatAge(rawAgeMs) {
516
550
  return `${Math.floor(hours / 24)}d`;
517
551
  }
518
552
  function formatUsd(amount) {
553
+ if (amount > 0 && amount < 0.01)
554
+ return "<$0.01";
519
555
  if (amount >= 1_000_000_000)
520
556
  return `$${compactNumber(amount / 1_000_000_000)}b`;
521
557
  if (amount >= 1_000_000)
@@ -574,7 +610,9 @@ function assembleMultiSubscriptionOverageLine(snapshot, fullSegments, overage, f
574
610
  function assembleMixedLine(snapshot, fullSegments, freshness, tier, columns, now, timeZone) {
575
611
  const agent = selectSubscriptionAgent(snapshot, now);
576
612
  const urgent = agent ? [...activeLimits(agent, now)].sort(compareUrgency)[0] : undefined;
577
- const runway = urgent ? formatLimitCompact(urgent) : "runway n/r";
613
+ const runway = urgent
614
+ ? formatLimitCompact(urgent)
615
+ : hasStaleLimitEvidence(snapshot, now) ? "runway stale" : "runway n/r";
578
616
  const runwayWithReset = urgent
579
617
  ? `${runway} ↻${formatReset(urgent, now, timeZone)}`
580
618
  : runway;
@@ -625,7 +663,10 @@ function assembleMultiSubscriptionLine(snapshot, freshness, tier, columns, now,
625
663
  const pressure = agents
626
664
  .filter((agent) => agent.pressure === "extra_usage_credits_exhausted")
627
665
  .map((agent) => `${subscriptionAgentLabel(agent)} plan pressure`);
628
- const noRunway = entries.length === 0 ? ["subscription detected", "runway not reported"] : [];
666
+ const staleRunway = staleRunwaySegments(snapshot, now);
667
+ const noRunway = entries.length === 0 && staleRunway.length === 0
668
+ ? ["subscription detected", "runway not reported"]
669
+ : staleRunway;
629
670
  const urgent = compactPrimary[0];
630
671
  const urgentValue = agents[0] ? formatAttributedValue(agents[0], true) : undefined;
631
672
  const candidates = [];
@@ -641,7 +682,8 @@ function assembleMultiSubscriptionLine(snapshot, freshness, tier, columns, now,
641
682
  candidates.push(["aibill", urgent, freshness], ["aibill", urgent], [urgent]);
642
683
  }
643
684
  else {
644
- candidates.push(["aibill", "runway n/r", freshness], ["runway n/r", freshness], ["aibill", ...noRunway, ...compactValues, freshness], ["aibill", ...compactValues, freshness], ["aibill", "subscription detected", freshness], ["subscription detected"]);
685
+ const compactRunway = hasStaleLimitEvidence(snapshot, now) ? "runway stale" : "runway n/r";
686
+ candidates.push(["aibill", compactRunway, freshness], [compactRunway, freshness], ["aibill", ...noRunway, ...compactValues, freshness], ["aibill", ...compactValues, freshness], ["aibill", "subscription detected", freshness], ["subscription detected"]);
645
687
  }
646
688
  return firstFittingLine(candidates, columns);
647
689
  }
@@ -652,7 +694,10 @@ function assembleMultiSubscriptionMixedLine(snapshot, fullSegments, freshness, t
652
694
  const compactLimits = entries.map(({ agent, limit }) => formatAttributedLimitCompact(agent, limit));
653
695
  const compactPrimary = primary.map(({ agent, limit }) => formatAttributedLimitCompact(agent, limit));
654
696
  const compactValues = agents.flatMap((agent) => formatAttributedValue(agent, true) ?? []);
655
- const noRunway = entries.length === 0 ? ["subscription detected", "runway not reported"] : [];
697
+ const staleRunway = staleRunwaySegments(snapshot, now);
698
+ const noRunway = entries.length === 0 && staleRunway.length === 0
699
+ ? ["subscription detected", "runway not reported"]
700
+ : staleRunway;
656
701
  const urgent = compactPrimary[0];
657
702
  const urgentValue = agents[0] ? formatAttributedValue(agents[0], true) : undefined;
658
703
  const meteredBilled = snapshot.metered?.providerBilled.sevenDays;
@@ -665,6 +710,7 @@ function assembleMultiSubscriptionMixedLine(snapshot, fullSegments, freshness, t
665
710
  ? `metered ~${formatUsd(meteredEstimated.amountUsd)}/7d`
666
711
  : "metered n/r";
667
712
  const shortFreshness = compactFreshness(freshness);
713
+ const compactRunway = hasStaleLimitEvidence(snapshot, now) ? "runway stale" : "runway n/r";
668
714
  const candidates = [];
669
715
  if (tier === "full")
670
716
  candidates.push(["aibill", ...fullSegments, freshness]);
@@ -677,7 +723,7 @@ function assembleMultiSubscriptionMixedLine(snapshot, fullSegments, freshness, t
677
723
  candidates.push(["aibill", "mix", urgent, metered, shortFreshness], ["mix", urgent, metered], ["aibill", urgent, shortFreshness], ["aibill", urgent], [urgent]);
678
724
  }
679
725
  else {
680
- candidates.push(["aibill", "mix", ...noRunway, metered, freshness], ["aibill", ...noRunway, freshness], ["aibill", "mix", "runway n/r", metered, shortFreshness], ["mix", "runway n/r", metered, shortFreshness], ["mix", "runway n/r", shortFreshness]);
726
+ candidates.push(["aibill", "mix", ...noRunway, metered, freshness], ["aibill", ...noRunway, freshness], ["aibill", "mix", compactRunway, metered, shortFreshness], ["mix", compactRunway, metered, shortFreshness], ["mix", compactRunway, shortFreshness]);
681
727
  }
682
728
  candidates.push(["mix", metered, shortFreshness], ["mix", metered], ["mix"]);
683
729
  return firstFittingLine(candidates, columns);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-spend-agent",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Local-first financial accountability CLI: Claude Code/Codex attribution, provenance, and next actions, plus experimental Gemini CLI cost evidence.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -54,8 +54,8 @@
54
54
  "prepack": "npm run build"
55
55
  },
56
56
  "dependencies": {
57
- "@agent-finops/core": "0.8.0",
58
- "@agent-finops/report": "0.8.0",
57
+ "@agent-finops/core": "0.8.1",
58
+ "@agent-finops/report": "0.8.1",
59
59
  "yocto-spinner": "^1.2.0"
60
60
  }
61
61
  }