ai-spend-agent 0.1.5 → 0.2.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.
Files changed (2) hide show
  1. package/dist/index.js +93 -9
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { realpathSync } from "node:fs";
3
3
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { dirname, extname, join, resolve } from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
- import { analyzeSpend, attributeUsageRecords, detectLocalCredentials, redactSecrets, unsafeScanRootReason, loadDeadContext, sampleDeadContext, loadLocalAgentUsage, loadSampleUsageData, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, slugifySourceId } from "@agent-finops/core";
6
+ import { analyzeSpend, attributeUsageRecords, detectLocalCredentials, detectLocalPlans, redactSecrets, subscriptionPlans, unsafeScanRootReason, loadDeadContext, sampleDeadContext, loadLocalAgentUsage, loadSampleUsageData, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, slugifySourceId } from "@agent-finops/core";
7
7
  import { generateActionPlanMarkdown, generateApplyArtifactMarkdown, generateDemoPackageMarkdown, generateHtmlReport, generateMarkdownReport, generatePlainEnglishSummary, generatePolicyConfigDraftMarkdown, generateReportCardCaption, generateReportCardSvg, generateVerificationPlanMarkdown, groupByDimensions } from "@agent-finops/report";
8
8
  export async function runCli(argv = process.argv.slice(2)) {
9
9
  if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
@@ -70,6 +70,27 @@ async function quickstartCommand(args) {
70
70
  const summary = analyzeSpend(records);
71
71
  const groupBy = args.groupBy ?? "model";
72
72
  const color = args.noColor ? false : undefined;
73
+ // Persona: --plan override wins; otherwise read the plans the coding agents
74
+ // themselves persisted locally (read-only, whitelisted fields, no network).
75
+ let detectedPlans;
76
+ if (args.plan) {
77
+ const override = planOverrideFromFlag(args.plan);
78
+ if (!override) {
79
+ return {
80
+ exitCode: 1,
81
+ stdout: "",
82
+ stderr: `Unknown --plan "${args.plan}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
83
+ };
84
+ }
85
+ detectedPlans = [override];
86
+ }
87
+ else {
88
+ detectedPlans = await detectLocalPlans({
89
+ // Env overrides keep tests (and unusual installs) isolated from $HOME.
90
+ claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
91
+ codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
92
+ }).catch(() => []);
93
+ }
73
94
  // Surface auto-detected credentials so the user knows their next 2-min step,
74
95
  // without ever printing a raw secret.
75
96
  const detection = await detectLocalCredentials({ cwd: resolve(args.path) });
@@ -102,7 +123,8 @@ async function quickstartCommand(args) {
102
123
  color,
103
124
  mode,
104
125
  nextSteps,
105
- deadContext
126
+ deadContext,
127
+ detectedPlans
106
128
  });
107
129
  const header = [` ${dataModeBanner(mode)}`, ...warnings.map((warning) => ` ! ${warning}`)].join("\n");
108
130
  return ok(`${header}\n${summaryText}`);
@@ -190,6 +212,13 @@ async function doctorCommand(args) {
190
212
  const hasLogs = claudeFound || codexFound;
191
213
  const detection = await detectLocalCredentials({ cwd: rootPath }).catch(() => ({ credentials: [] }));
192
214
  const providerRefs = detection.credentials.map((credential) => `${credential.provider} (${credential.hint})`);
215
+ const plans = await detectLocalPlans({
216
+ claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
217
+ codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
218
+ }).catch(() => []);
219
+ const planLine = plans.length > 0
220
+ ? plans.map((plan) => `${plan.planLabel} (${plan.agent}, ${plan.billing === "api_key" ? "pay per token" : plan.billing})`).join(", ")
221
+ : "none detected (use --plan to declare one)";
193
222
  const warnings = [];
194
223
  if (stateMode === "sample")
195
224
  warnings.push("sample state present — it will be shown as DEMO and cannot mask real logs; run `ai-spend-agent reset` to clear it");
@@ -215,6 +244,7 @@ async function doctorCommand(args) {
215
244
  `Claude Code logs: ${claudeFound ? "found" : "not found"}`,
216
245
  `Codex logs: ${codexFound ? "found" : "not found"}`,
217
246
  `provider env references: ${providerRefs.length > 0 ? providerRefs.join(", ") : "none detected"}`,
247
+ `subscription plans: ${planLine}`,
218
248
  "redaction policy: secrets are never printed or persisted",
219
249
  "plan check: available (subscription vs API-rate math)",
220
250
  `data mode you'll get now: ${predictedMode}`,
@@ -663,6 +693,20 @@ async function connectCommand(args) {
663
693
  lines.push(`missing: ${source.fieldsMissing.join(", ")}`);
664
694
  return ok(lines.join("\n"));
665
695
  }
696
+ /** Map a --plan id to a synthetic DetectedPlan (explicit user override). */
697
+ function planOverrideFromFlag(planId) {
698
+ const plan = subscriptionPlans.find((candidate) => candidate.id === planId);
699
+ if (!plan)
700
+ return undefined;
701
+ return {
702
+ agent: plan.agent,
703
+ provider: plan.provider,
704
+ planId: plan.id,
705
+ planLabel: plan.name,
706
+ billing: "subscription",
707
+ source: "--plan override"
708
+ };
709
+ }
666
710
  function describeOrigin(credential) {
667
711
  if (credential.origin === "process_env")
668
712
  return "your shell environment";
@@ -791,7 +835,7 @@ async function reportCommand(args) {
791
835
  return {
792
836
  exitCode: 1,
793
837
  stdout: "",
794
- stderr: `No local spend state found at ${stateDir}. Run scan --sample --path <dir> first. ${error instanceof Error ? error.message : ""}`
838
+ stderr: `Couldn't build a report: ${error instanceof Error ? error.message : String(error)}`
795
839
  };
796
840
  }
797
841
  }
@@ -853,15 +897,35 @@ async function applyArtifactCommand(args) {
853
897
  return {
854
898
  exitCode: 1,
855
899
  stdout: "",
856
- stderr: `No local spend state found at ${stateDir}. Run scan --sample --path <dir> first. ${error instanceof Error ? error.message : ""}`
900
+ stderr: `Couldn't build apply artifacts: ${error instanceof Error ? error.message : String(error)}`
857
901
  };
858
902
  }
859
903
  }
860
904
  async function buildReportInput(stateDir, rootPath) {
861
- const [spendState, discovery, mappings, sourceRegistry, missingSourcePrompts, confirmedMappings, providerRecordsState] = await Promise.all([
862
- readJson(join(stateDir, "spend.json")),
863
- readJson(join(stateDir, "discovery.json")),
864
- readJson(join(stateDir, "mappings.json")),
905
+ let spendState = await readOptionalJson(join(stateDir, "spend.json"), undefined);
906
+ let mappings = await readOptionalJson(join(stateDir, "mappings.json"), undefined);
907
+ // The quickstart intentionally writes nothing — so `report`/`apply-artifact`
908
+ // right after a first run must fall back to the SAME live local-log read
909
+ // (never sample data), then persist it so subsequent runs are consistent.
910
+ if (!spendState?.summary || !spendState.records || spendState.records.length === 0) {
911
+ const logs = await loadLocalAgentUsage({
912
+ claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
913
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
914
+ }).catch(() => undefined);
915
+ if (!logs || logs.records.length === 0) {
916
+ throw new Error("no persisted spend state and no local Claude Code/Codex logs found. " +
917
+ "Run `npx ai-spend-agent` first (or `npx ai-spend-agent scan --sample --path <dir>` for a demo-data report).");
918
+ }
919
+ const records = logs.records;
920
+ const summary = analyzeSpend(records);
921
+ const liveMappings = attributeUsageRecords(records);
922
+ await mkdir(stateDir, { recursive: true });
923
+ await writeLocalSpendState(stateDir, records, summary, liveMappings, "local_logs");
924
+ spendState = { summary, records, mode: "local_logs" };
925
+ mappings = liveMappings;
926
+ }
927
+ const [discovery, sourceRegistry, missingSourcePrompts, confirmedMappings, providerRecordsState] = await Promise.all([
928
+ readOptionalJson(join(stateDir, "discovery.json"), emptyDiscovery(rootPath)),
865
929
  readSourceRegistry(stateDir, rootPath),
866
930
  readOptionalJson(join(stateDir, "missing-sources.json"), []),
867
931
  readConfirmedMappings(stateDir),
@@ -874,7 +938,7 @@ async function buildReportInput(stateDir, rootPath) {
874
938
  allRecords: spendState.records ?? [],
875
939
  dataMode: spendState.mode,
876
940
  discovery,
877
- mappings,
941
+ mappings: mappings ?? [],
878
942
  sourceRegistry,
879
943
  missingSourcePrompts,
880
944
  confirmedMappings,
@@ -882,6 +946,17 @@ async function buildReportInput(stateDir, rootPath) {
882
946
  providerQa: providerRecordsState.qa ? [providerRecordsState.qa] : []
883
947
  };
884
948
  }
949
+ function emptyDiscovery(rootPath) {
950
+ return {
951
+ rootPath,
952
+ scannedFiles: 0,
953
+ skippedDirectories: [],
954
+ unreadablePaths: [],
955
+ signals: [],
956
+ secretsDetected: [],
957
+ redactedEvidence: []
958
+ };
959
+ }
885
960
  async function writeApplyArtifacts(stateDir, reportInput) {
886
961
  const paths = {
887
962
  codingPrompt: join(stateDir, "ai-spend-coding-agent-prompt.md"),
@@ -927,6 +1002,14 @@ function parseArgs(argv) {
927
1002
  parsed.ignoreState = true;
928
1003
  continue;
929
1004
  }
1005
+ if (arg === "--plan") {
1006
+ const next = rest[index + 1];
1007
+ if (next) {
1008
+ parsed.plan = next;
1009
+ index += 1;
1010
+ }
1011
+ continue;
1012
+ }
930
1013
  if (arg === "--group-by") {
931
1014
  const next = rest[index + 1];
932
1015
  if (isGroupByDimension(next)) {
@@ -1203,6 +1286,7 @@ function helpText() {
1203
1286
  "Run with no command for an instant, zero-key demo:",
1204
1287
  " ai-spend-agent Show where your AI money goes (sample/auto-detected data)",
1205
1288
  " ai-spend-agent --group-by agent Drill down by source|model|client|project|agent|user|workspace|apiKey",
1289
+ " ai-spend-agent --plan <id> Declare your plan when auto-detection can't (claude-max-5x|claude-max-20x|claude-pro|chatgpt-plus|chatgpt-pro)",
1206
1290
  "",
1207
1291
  "Connect your real spend (cost data is ADMIN/owner-gated):",
1208
1292
  " ai-spend-agent connect openai Self-serve in ~2 min with an org-owner Admin key",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-spend-agent",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
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.1.5",
53
- "@agent-finops/report": "0.1.5",
52
+ "@agent-finops/core": "0.2.1",
53
+ "@agent-finops/report": "0.2.1",
54
54
  "yocto-spinner": "^1.2.0"
55
55
  }
56
56
  }