ai-spend-agent 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +69 -18
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -10,6 +10,13 @@ export async function runCli(argv = process.argv.slice(2)) {
10
10
  return ok(helpText());
11
11
  }
12
12
  const args = parseArgs(argv);
13
+ if (args.groupByInvalid) {
14
+ return {
15
+ exitCode: 1,
16
+ stdout: "",
17
+ stderr: `--group-by needs a dimension: ${groupByDimensions.join("|")}\nexample: npx aibill --group-by project`
18
+ };
19
+ }
13
20
  // Zero-key instant demo is the DEFAULT first run. Running `ai-spend-agent`
14
21
  // with no subcommand (or `npx ai-spend-agent`), or with only flags such as
15
22
  // `--group-by agent`, lands the wow immediately on sample / auto-detected
@@ -167,7 +174,11 @@ async function loadInstantReadData(args) {
167
174
  const persisted = args.ignoreState ? undefined : await readPersistedSpend(resolve(args.path));
168
175
  // Only real connected/synced provider state is authoritative enough to serve
169
176
  // directly. Sample or legacy persisted state must NEVER mask real local logs.
170
- if (persisted && persisted.records.length > 0 && persisted.mode === "connected_provider") {
177
+ // Defense in depth: "connected" is only believed if the records actually
178
+ // came from a provider — local-log records mislabeled connected (a past bug)
179
+ // must never be served as billing data.
180
+ const looksConnected = (persisted?.records ?? []).some((record) => record.providerCostType !== "local_agent_logs");
181
+ if (persisted && persisted.records.length > 0 && persisted.mode === "connected_provider" && looksConnected) {
171
182
  return { records: persisted.records, mode: "connected", warnings };
172
183
  }
173
184
  // Real local agent logs (Claude Code / Codex) beat any sample/legacy state.
@@ -448,12 +459,16 @@ async function watchCommand(args) {
448
459
  let iteration = 0;
449
460
  while (unbounded || iteration < cycles) {
450
461
  const previous = await readOptionalJson(join(stateDir, "watch-latest.json"), null);
451
- const { summary, snapshot, records } = await runWatchCycle(stateDir, args);
462
+ const { summary, snapshot, records, mode } = await runWatchCycle(stateDir, args);
452
463
  const deltaHeadline = buildDeltaHeadline(previous, snapshot);
464
+ // Watch's job is DELTAS: render the compact breakdown view per cycle, not
465
+ // the whole diagnose→verify readout again (the quickstart owns that).
453
466
  const plainEnglish = generatePlainEnglishSummary(summary, {
454
467
  records,
455
468
  groupBy: args.groupBy ?? "model",
456
- color: args.noColor ? false : undefined
469
+ color: args.noColor ? false : undefined,
470
+ mode: mode === "sample" ? "demo" : mode === "connected_provider" ? "connected" : "local-logs",
471
+ view: "breakdown"
457
472
  });
458
473
  const stamped = [
459
474
  `=== watch cycle @ ${snapshot.capturedAt} ===`,
@@ -482,6 +497,10 @@ async function watchCommand(args) {
482
497
  return ok(collected.join("\n\n"));
483
498
  }
484
499
  async function runWatchCycle(stateDir, args) {
500
+ // The persisted mode must describe where the records ACTUALLY came from.
501
+ // A previous version stamped everything "connected_provider", which made
502
+ // later quickstarts serve stale local-log data as "connected billing" and
503
+ // routed apply/report to the agency artifacts. Never label by assumption.
485
504
  let records;
486
505
  let mode;
487
506
  if (args.sample) {
@@ -489,13 +508,26 @@ async function runWatchCycle(stateDir, args) {
489
508
  mode = "sample";
490
509
  }
491
510
  else {
492
- records = await loadLiveRecords(stateDir);
493
- if (records.length > 0) {
511
+ const providerState = await readOptionalJson(join(stateDir, "provider-records.json"), { records: [] });
512
+ if (providerState.records.length > 0) {
513
+ records = providerState.records;
494
514
  mode = "connected_provider";
495
515
  }
496
516
  else {
497
- records = await loadSampleUsageData();
498
- mode = "sample";
517
+ // Same freshness rule as quickstart/report: re-read local logs live,
518
+ // never serve a stale snapshot.
519
+ const logs = await loadLocalAgentUsage({
520
+ claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
521
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
522
+ }).catch(() => undefined);
523
+ if (logs && logs.records.length > 0) {
524
+ records = logs.records;
525
+ mode = "local_logs";
526
+ }
527
+ else {
528
+ records = await loadSampleUsageData();
529
+ mode = "sample";
530
+ }
499
531
  }
500
532
  }
501
533
  const summary = analyzeSpend(records);
@@ -517,7 +549,7 @@ async function runWatchCycle(stateDir, args) {
517
549
  sourceId: "watch",
518
550
  detail: `Watch cycle captured ${snapshot.recordCount} records totaling $${snapshot.totalUsd.toFixed(2)}.`
519
551
  });
520
- return { summary, snapshot, records };
552
+ return { summary, snapshot, records, mode };
521
553
  }
522
554
  function buildDeltaHeadline(previous, current) {
523
555
  if (!previous) {
@@ -554,14 +586,6 @@ function buildDeltaHeadline(previous, current) {
554
586
  }
555
587
  return lines.join(" ");
556
588
  }
557
- async function loadLiveRecords(stateDir) {
558
- const providerState = await readOptionalJson(join(stateDir, "provider-records.json"), { records: [] });
559
- if (providerState.records.length > 0) {
560
- return providerState.records;
561
- }
562
- const spendState = await readOptionalJson(join(stateDir, "spend.json"), {});
563
- return spendState.records ?? [];
564
- }
565
589
  function sleep(milliseconds) {
566
590
  return new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds));
567
591
  }
@@ -726,7 +750,15 @@ async function syncProviderCommand(args) {
726
750
  const rootPath = resolve(args.path);
727
751
  const stateDir = join(rootPath, ".ai-spend-agent");
728
752
  if (!args.provider || !args.authReference || !args.startTime) {
729
- return { exitCode: 1, stdout: "", stderr: "sync-provider requires --provider, --auth-reference env:NAME, and --start-time" };
753
+ return {
754
+ exitCode: 1,
755
+ stdout: "",
756
+ stderr: [
757
+ "sync-provider requires --provider, --auth-reference env:NAME, and --start-time <unix seconds>.",
758
+ "example:",
759
+ " npx aibill sync-provider --provider anthropic --auth-reference env:ANTHROPIC_ADMIN_KEY --start-time 1750000000"
760
+ ].join("\n")
761
+ };
730
762
  }
731
763
  try {
732
764
  const result = await fetchProviderUsageRecords({
@@ -927,7 +959,14 @@ async function buildReportInput(stateDir, rootPath) {
927
959
  // re-reads the logs fresh, so report/apply must too — otherwise yesterday's
928
960
  // persisted snapshot makes the artifact's numbers disagree with the screen.
929
961
  // Persisted state stays authoritative only for connected/sample data.
930
- const needsFreshLogs = !spendState?.summary || !spendState.records || spendState.records.length === 0 || spendState.mode === "local_logs";
962
+ const persistedLooksConnected = (spendState?.records ?? []).some((record) => record.providerCostType !== "local_agent_logs");
963
+ const needsFreshLogs = !spendState?.summary ||
964
+ !spendState.records ||
965
+ spendState.records.length === 0 ||
966
+ spendState.mode === "local_logs" ||
967
+ // Mislabeled state (local-log records stamped connected by a past bug)
968
+ // must be superseded by a fresh read, not trusted.
969
+ (spendState.mode === "connected_provider" && !persistedLooksConnected);
931
970
  if (needsFreshLogs) {
932
971
  const logs = await loadLocalAgentUsage({
933
972
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
@@ -1061,6 +1100,18 @@ function parseArgs(argv) {
1061
1100
  parsed.groupBy = next;
1062
1101
  index += 1;
1063
1102
  }
1103
+ else {
1104
+ // Missing/invalid dimension: surface an error instead of silently
1105
+ // rendering the full readout the user didn't ask for. A following
1106
+ // flag is NOT the dimension — don't consume it.
1107
+ if (next && !next.startsWith("--")) {
1108
+ parsed.groupByInvalid = next;
1109
+ index += 1;
1110
+ }
1111
+ else {
1112
+ parsed.groupByInvalid = "(missing)";
1113
+ }
1114
+ }
1064
1115
  continue;
1065
1116
  }
1066
1117
  if (arg === "--path") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-spend-agent",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Your AI spend in one view, in 90 seconds — local-first CLI for OpenAI, Anthropic, Cursor, Copilot + Claude Code/Codex session logs, with a ranked savings cut list, and flags the tools your agent loads but never uses (dead context).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -49,8 +49,8 @@
49
49
  "prepack": "npm run build"
50
50
  },
51
51
  "dependencies": {
52
- "@agent-finops/core": "0.5.1",
53
- "@agent-finops/report": "0.5.1",
52
+ "@agent-finops/core": "0.5.3",
53
+ "@agent-finops/report": "0.5.3",
54
54
  "yocto-spinner": "^1.2.0"
55
55
  }
56
56
  }