ai-spend-agent 0.1.0

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 ADDED
@@ -0,0 +1,1146 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import { analyzeSpend, attributeUsageRecords, detectLocalCredentials, loadLocalAgentUsage, loadSampleUsageData, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, slugifySourceId } from "@agent-finops/core";
8
+ import { generateActionPlanMarkdown, generateApplyArtifactMarkdown, generateDemoPackageMarkdown, generateHtmlReport, generateMarkdownReport, generatePlainEnglishSummary, generatePolicyConfigDraftMarkdown, generateReportCardCaption, generateReportCardSvg, generateVerificationPlanMarkdown, groupByDimensions } from "@agent-finops/report";
9
+ export async function runCli(argv = process.argv.slice(2)) {
10
+ if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
11
+ return ok(helpText());
12
+ }
13
+ const args = parseArgs(argv);
14
+ // Zero-key instant demo is the DEFAULT first run. Running `ai-spend-agent`
15
+ // with no subcommand (or `npx ai-spend-agent`), or with only flags such as
16
+ // `--group-by agent`, lands the wow immediately on sample / auto-detected
17
+ // local data — no credential required.
18
+ if (!args.command || args.command.startsWith("--") || args.command === "quickstart" || args.command === "demo") {
19
+ return quickstartCommand(args);
20
+ }
21
+ if (args.command === "doctor") {
22
+ return doctorCommand(args);
23
+ }
24
+ if (args.command === "init") {
25
+ return initCommand(args);
26
+ }
27
+ if (args.command === "scan") {
28
+ return scanCommand(args);
29
+ }
30
+ if (args.command === "quickstart" || args.command === "demo") {
31
+ return quickstartCommand(args);
32
+ }
33
+ if (args.command === "watch") {
34
+ return watchCommand(args);
35
+ }
36
+ if (args.command === "report") {
37
+ return reportCommand(args);
38
+ }
39
+ if (args.command === "report-card") {
40
+ return reportCardCommand(args);
41
+ }
42
+ if (args.command === "apply-artifact") {
43
+ return applyArtifactCommand(args);
44
+ }
45
+ if (args.command === "add-source") {
46
+ return addSourceCommand(args);
47
+ }
48
+ if (args.command === "list-sources") {
49
+ return listSourcesCommand(args);
50
+ }
51
+ if (args.command === "connect") {
52
+ return connectCommand(args);
53
+ }
54
+ if (args.command === "sync-provider") {
55
+ return syncProviderCommand(args);
56
+ }
57
+ if (args.command === "confirm-mapping") {
58
+ return confirmMappingCommand(args);
59
+ }
60
+ return {
61
+ exitCode: 1,
62
+ stdout: "",
63
+ stderr: `Unknown command: ${args.command}\n${helpText()}`
64
+ };
65
+ }
66
+ async function quickstartCommand(args) {
67
+ // Real data beats sample data: (1) connected/synced state, then (2) usage
68
+ // mined from this machine's agent logs (Claude Code / Codex — the spend no
69
+ // billing API can see), then (3) the bundled sample so the wow ALWAYS lands.
70
+ let records = [];
71
+ let mode = "demo";
72
+ const localSpend = await readOptionalLocalSpend(resolve(args.path));
73
+ if (localSpend && localSpend.length > 0) {
74
+ records = localSpend;
75
+ mode = "connected";
76
+ }
77
+ else if (!args.sample) {
78
+ const logs = await loadLocalAgentUsage({
79
+ // Env overrides keep tests (and unusual installs) isolated from $HOME.
80
+ claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
81
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
82
+ }).catch(() => undefined);
83
+ if (logs && logs.records.length > 0) {
84
+ records = logs.records;
85
+ mode = "local-logs";
86
+ }
87
+ }
88
+ if (records.length === 0) {
89
+ // loadSampleUsageData resolves the bundled CSVs relative to the installed
90
+ // package, so this works from ANY directory (true zero-config).
91
+ records = await loadSampleUsageData();
92
+ mode = "demo";
93
+ }
94
+ const summary = analyzeSpend(records);
95
+ const groupBy = args.groupBy ?? "model";
96
+ const color = args.noColor ? false : undefined;
97
+ // Surface auto-detected credentials so the user knows their next 2-min step,
98
+ // without ever printing a raw secret.
99
+ const detection = await detectLocalCredentials({ cwd: resolve(args.path) });
100
+ const nextSteps = quickstartNextSteps(mode, detection.credentials);
101
+ const summaryText = generatePlainEnglishSummary(summary, {
102
+ records,
103
+ groupBy,
104
+ color,
105
+ mode,
106
+ nextSteps
107
+ });
108
+ return ok(summaryText);
109
+ }
110
+ function quickstartNextSteps(mode, detected) {
111
+ const steps = [];
112
+ if (mode === "local-logs") {
113
+ steps.push("These numbers are API-equivalent ESTIMATES from your local Claude Code / Codex logs.");
114
+ }
115
+ if (detected.length > 0) {
116
+ const names = detected.map((credential) => `${credential.provider} (${credential.hint})`).join(", ");
117
+ steps.push(`Found local key${detected.length === 1 ? "" : "s"}: ${names}`);
118
+ steps.push(`ai-spend-agent connect ${detected[0].provider} use it — note: COST data needs an ADMIN/owner key`);
119
+ }
120
+ else if (mode === "demo" || mode === "local-logs") {
121
+ steps.push("ai-spend-agent connect openai pull your real OpenAI spend (org-owner admin key, ~2 min)");
122
+ steps.push("ai-spend-agent connect anthropic pull your real Anthropic spend (admin key, ~2 min)");
123
+ }
124
+ steps.push("ai-spend-agent report write a shareable Markdown + HTML report");
125
+ steps.push("Want this watched while your laptop is off? Hosted beta waitlist: https://ai-spend-agent.vercel.app");
126
+ return steps;
127
+ }
128
+ async function readOptionalLocalSpend(rootPath) {
129
+ const stateDir = join(rootPath, ".ai-spend-agent");
130
+ try {
131
+ const spend = await readJson(join(stateDir, "spend.json"));
132
+ return spend.records;
133
+ }
134
+ catch {
135
+ return undefined;
136
+ }
137
+ }
138
+ async function doctorCommand(args) {
139
+ const rootPath = resolve(args.path);
140
+ const stateDir = join(rootPath, ".ai-spend-agent");
141
+ return ok([
142
+ "AI Spend Analyst Agent doctor",
143
+ `path: ${rootPath}`,
144
+ "local-first mode: enabled",
145
+ "subscription check: not wired in this slice",
146
+ "redaction policy: secrets are never printed",
147
+ `state directory: ${stateDir}`
148
+ ].join("\n"));
149
+ }
150
+ async function initCommand(args) {
151
+ const rootPath = resolve(args.path);
152
+ const stateDir = join(rootPath, ".ai-spend-agent");
153
+ await mkdir(stateDir, { recursive: true });
154
+ const registry = createLocalFolderSourceRegistry(rootPath);
155
+ await writeJson(join(stateDir, "manifest.json"), {
156
+ product: "AI Spend Analyst Agent",
157
+ mode: "local-first-demo",
158
+ cloudUpload: false,
159
+ cronJobsEnabled: false,
160
+ redactionPolicy: "secrets are never printed; detected values are written only as [REDACTED]",
161
+ sourceRegistry: "sources.json",
162
+ auditLog: "audit-log.json",
163
+ nextCommands: [
164
+ "ai-spend-agent doctor",
165
+ `ai-spend-agent scan --sample --path ${rootPath}`,
166
+ `ai-spend-agent report --out ai-spend-report --path ${rootPath}`
167
+ ]
168
+ });
169
+ await writeJson(join(stateDir, "sources.json"), registry);
170
+ await writeJson(join(stateDir, "audit-log.json"), createScanAuditLog([
171
+ {
172
+ timestamp: registry.updatedAt,
173
+ action: "source_registered",
174
+ sourceId: "local-root",
175
+ path: rootPath,
176
+ detail: "Explicit local folder source approved during init."
177
+ }
178
+ ]));
179
+ return ok([
180
+ "AI Spend Analyst Agent init",
181
+ `path: ${rootPath}`,
182
+ "demo mode: local-first sample workflow",
183
+ "cloud upload: disabled",
184
+ "cron jobs: disabled in V0 demo",
185
+ `state directory: ${stateDir}`,
186
+ `next: ai-spend-agent scan --sample --path ${rootPath}`
187
+ ].join("\n"));
188
+ }
189
+ async function scanCommand(args) {
190
+ const rootPath = resolve(args.path);
191
+ const unsafeReason = unsafeScanRootReason(rootPath);
192
+ if (unsafeReason) {
193
+ return {
194
+ exitCode: 1,
195
+ stdout: "",
196
+ stderr: `Refusing to scan ${rootPath}: ${unsafeReason}. Choose a narrower approved folder with --path.`
197
+ };
198
+ }
199
+ const stateDir = join(rootPath, ".ai-spend-agent");
200
+ await mkdir(stateDir, { recursive: true });
201
+ const registry = createLocalFolderSourceRegistry(rootPath);
202
+ const startedAt = new Date().toISOString();
203
+ const auditEvents = [
204
+ {
205
+ timestamp: registry.updatedAt,
206
+ action: "source_registered",
207
+ sourceId: "local-root",
208
+ path: rootPath,
209
+ detail: "Explicit local folder source approved for read-only scan."
210
+ },
211
+ {
212
+ timestamp: startedAt,
213
+ action: "scan_started",
214
+ sourceId: "local-root",
215
+ path: rootPath,
216
+ detail: "Local scan started with cloud upload disabled."
217
+ }
218
+ ];
219
+ const discovery = await scanLocalUsageSignals(rootPath);
220
+ const missingSourcePrompts = buildMissingSourcePrompts(discovery.signals, registry);
221
+ auditEvents.push({
222
+ timestamp: new Date().toISOString(),
223
+ action: "source_scanned",
224
+ sourceId: "local-root",
225
+ path: rootPath,
226
+ detail: `${discovery.scannedFiles} files scanned; ${discovery.signals.length} signals found.`
227
+ });
228
+ for (const skippedDirectory of discovery.skippedDirectories) {
229
+ auditEvents.push({
230
+ timestamp: new Date().toISOString(),
231
+ action: "source_skipped",
232
+ sourceId: "local-root",
233
+ path: skippedDirectory,
234
+ reason: "Denied or heavy directory skipped during local scan."
235
+ });
236
+ }
237
+ for (const secretName of discovery.secretsDetected) {
238
+ auditEvents.push({
239
+ timestamp: new Date().toISOString(),
240
+ action: "secret_redacted",
241
+ sourceId: "local-root",
242
+ reason: `${secretName} was redacted before persistence/output.`
243
+ });
244
+ }
245
+ auditEvents.push({
246
+ timestamp: new Date().toISOString(),
247
+ action: "scan_completed",
248
+ sourceId: "local-root",
249
+ path: rootPath,
250
+ detail: "Local scan completed without cloud upload."
251
+ });
252
+ await writeJson(join(stateDir, "sources.json"), registry);
253
+ await writeJson(join(stateDir, "audit-log.json"), createScanAuditLog(auditEvents));
254
+ await writeJson(join(stateDir, "discovery.json"), discovery);
255
+ await writeJson(join(stateDir, "missing-sources.json"), missingSourcePrompts);
256
+ const lines = [
257
+ "AI Spend Analyst Agent scan",
258
+ `path: ${rootPath}`,
259
+ "source registry: .ai-spend-agent/sources.json",
260
+ "audit log: .ai-spend-agent/audit-log.json",
261
+ `approved sources: ${registry.approvedSources.length}`,
262
+ `discovery signals: ${discovery.signals.length}`,
263
+ `secrets detected: ${discovery.secretsDetected.length}`
264
+ ];
265
+ if (args.sample) {
266
+ const records = await loadSampleUsageData();
267
+ const summary = analyzeSpend(records);
268
+ const mappings = attributeUsageRecords(records);
269
+ await writeLocalSpendState(stateDir, records, summary, mappings);
270
+ lines.push(`sample records: ${records.length}`);
271
+ lines.push(`total spend: $${summary.totalUsd.toFixed(2)}`);
272
+ lines.push(`attribution mappings: ${mappings.length}`);
273
+ }
274
+ if (discovery.signals.length > 0) {
275
+ lines.push("signals:");
276
+ for (const signal of discovery.signals.slice(0, 8)) {
277
+ lines.push(`- ${signal.provider} ${signal.kind} ${signal.filePath} (${Math.round(signal.confidence * 100)}%)`);
278
+ }
279
+ }
280
+ if (missingSourcePrompts.length > 0) {
281
+ lines.push("missing source prompts:");
282
+ for (const prompt of missingSourcePrompts.slice(0, 8)) {
283
+ lines.push(`- ${prompt.provider}: ${prompt.status}; suggested: ${prompt.suggestedConnector}`);
284
+ }
285
+ }
286
+ return ok(lines.join("\n"));
287
+ }
288
+ async function watchCommand(args) {
289
+ const rootPath = resolve(args.path);
290
+ const stateDir = join(rootPath, ".ai-spend-agent");
291
+ await mkdir(stateDir, { recursive: true });
292
+ const intervalSeconds = Number.isFinite(args.interval) && (args.interval ?? 0) > 0 ? args.interval : 3600;
293
+ // cycles bounds how many iterations run; default 1 keeps the command testable and
294
+ // cron-friendly (cron itself supplies the schedule). Use a higher value or --cycles 0
295
+ // (unbounded) for a long-running local loop.
296
+ const cycles = Number.isFinite(args.cycles) ? args.cycles : 1;
297
+ const unbounded = cycles === 0;
298
+ const collected = [];
299
+ let iteration = 0;
300
+ while (unbounded || iteration < cycles) {
301
+ const previous = await readOptionalJson(join(stateDir, "watch-latest.json"), null);
302
+ const { summary, snapshot, records } = await runWatchCycle(stateDir, args);
303
+ const deltaHeadline = buildDeltaHeadline(previous, snapshot);
304
+ const plainEnglish = generatePlainEnglishSummary(summary, {
305
+ records,
306
+ groupBy: args.groupBy ?? "model",
307
+ color: args.noColor ? false : undefined
308
+ });
309
+ const stamped = [
310
+ `=== watch cycle @ ${snapshot.capturedAt} ===`,
311
+ deltaHeadline,
312
+ plainEnglish
313
+ ].join("\n");
314
+ collected.push(stamped);
315
+ iteration += 1;
316
+ const moreToGo = unbounded || iteration < cycles;
317
+ if (moreToGo) {
318
+ // eslint-disable-next-line no-console
319
+ if (process.env.NODE_ENV !== "test") {
320
+ console.log(stamped);
321
+ console.log(`\n[watch] sleeping ${intervalSeconds}s until next cycle. Press Ctrl+C to stop.\n`);
322
+ }
323
+ await sleep(intervalSeconds * 1000);
324
+ }
325
+ }
326
+ return ok(collected.join("\n\n"));
327
+ }
328
+ async function runWatchCycle(stateDir, args) {
329
+ let records;
330
+ if (args.sample) {
331
+ records = await loadSampleUsageData();
332
+ }
333
+ else {
334
+ records = await loadLiveRecords(stateDir);
335
+ if (records.length === 0) {
336
+ records = await loadSampleUsageData();
337
+ }
338
+ }
339
+ const summary = analyzeSpend(records);
340
+ const mappings = attributeUsageRecords(records);
341
+ await writeLocalSpendState(stateDir, records, summary, mappings);
342
+ const snapshot = {
343
+ capturedAt: new Date().toISOString(),
344
+ totalUsd: summary.totalUsd,
345
+ recordCount: summary.recordCount,
346
+ byModel: summary.byModel.map((entry) => ({ key: entry.key, amountUsd: entry.amountUsd }))
347
+ };
348
+ // Append to the rolling history and persist the latest snapshot for the next run.
349
+ const history = await readOptionalJson(join(stateDir, "watch-history.json"), []);
350
+ await writeJson(join(stateDir, "watch-history.json"), [...history, snapshot].slice(-200));
351
+ await writeJson(join(stateDir, "watch-latest.json"), snapshot);
352
+ await appendAuditEvent(stateDir, {
353
+ timestamp: snapshot.capturedAt,
354
+ action: "scan_completed",
355
+ sourceId: "watch",
356
+ detail: `Watch cycle captured ${snapshot.recordCount} records totaling $${snapshot.totalUsd.toFixed(2)}.`
357
+ });
358
+ return { summary, snapshot, records };
359
+ }
360
+ function buildDeltaHeadline(previous, current) {
361
+ if (!previous) {
362
+ return `First watch snapshot. Baseline AI spend is $${current.totalUsd.toFixed(2)} across ${current.recordCount} charges. Future cycles will report what changed.`;
363
+ }
364
+ const deltaUsd = roundMoneyCli(current.totalUsd - previous.totalUsd);
365
+ const lines = [];
366
+ if (Math.abs(deltaUsd) < 0.01) {
367
+ lines.push(`No change since the last check: AI spend is holding at $${current.totalUsd.toFixed(2)}.`);
368
+ }
369
+ else {
370
+ const direction = deltaUsd > 0 ? "UP" : "DOWN";
371
+ const percent = previous.totalUsd > 0 ? Math.round((deltaUsd / previous.totalUsd) * 100) : 100;
372
+ lines.push(`Spend is ${direction} $${Math.abs(deltaUsd).toFixed(2)} (${Math.abs(percent)}%) since the last check — ` +
373
+ `from $${previous.totalUsd.toFixed(2)} to $${current.totalUsd.toFixed(2)}.`);
374
+ }
375
+ // New-model and per-model spike detection versus the previous snapshot.
376
+ const previousModels = new Map(previous.byModel.map((entry) => [entry.key, entry.amountUsd]));
377
+ const anomalies = [];
378
+ for (const entry of current.byModel) {
379
+ const before = previousModels.get(entry.key);
380
+ if (before === undefined) {
381
+ if (entry.amountUsd >= 1) {
382
+ anomalies.push(`New model "${entry.key}" appeared, already at $${entry.amountUsd.toFixed(2)}.`);
383
+ }
384
+ continue;
385
+ }
386
+ if (before > 0 && entry.amountUsd - before >= 5 && entry.amountUsd / before >= 1.5) {
387
+ anomalies.push(`"${entry.key}" jumped from $${before.toFixed(2)} to $${entry.amountUsd.toFixed(2)}.`);
388
+ }
389
+ }
390
+ if (anomalies.length > 0) {
391
+ lines.push(`Anomalies worth a look: ${anomalies.join(" ")}`);
392
+ }
393
+ return lines.join(" ");
394
+ }
395
+ async function loadLiveRecords(stateDir) {
396
+ const providerState = await readOptionalJson(join(stateDir, "provider-records.json"), { records: [] });
397
+ if (providerState.records.length > 0) {
398
+ return providerState.records;
399
+ }
400
+ const spendState = await readOptionalJson(join(stateDir, "spend.json"), {});
401
+ return spendState.records ?? [];
402
+ }
403
+ function sleep(milliseconds) {
404
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds));
405
+ }
406
+ function roundMoneyCli(value) {
407
+ return Math.round(value * 100) / 100;
408
+ }
409
+ async function addSourceCommand(args) {
410
+ const rootPath = resolve(args.path);
411
+ const stateDir = join(rootPath, ".ai-spend-agent");
412
+ if (!args.sourcePath || !args.sourceType || !args.label) {
413
+ return { exitCode: 1, stdout: "", stderr: "add-source requires --source-path, --type, and --label" };
414
+ }
415
+ const registry = await readSourceRegistry(stateDir, rootPath);
416
+ const sourcePath = args.sourceType === "mcp_tool" ? args.sourcePath : resolve(args.sourcePath);
417
+ const id = slugifySourceId(args.label);
418
+ const nextRegistry = addApprovedSource(registry, {
419
+ id,
420
+ type: args.sourceType,
421
+ label: args.label,
422
+ path: sourcePath,
423
+ provider: args.provider
424
+ });
425
+ await writeJson(join(stateDir, "sources.json"), nextRegistry);
426
+ await appendAuditEvent(stateDir, {
427
+ timestamp: nextRegistry.updatedAt,
428
+ action: "source_registered",
429
+ sourceId: id,
430
+ path: sourcePath,
431
+ detail: `${args.sourceType} approved via CLI add-source.`
432
+ });
433
+ return ok([
434
+ "AI Spend Analyst Agent add-source",
435
+ `source added: ${id}`,
436
+ `type: ${args.sourceType}`,
437
+ `path: ${sourcePath}`,
438
+ `provider: ${args.provider ?? "unknown"}`,
439
+ "read-only: true"
440
+ ].join("\n"));
441
+ }
442
+ async function listSourcesCommand(args) {
443
+ const rootPath = resolve(args.path);
444
+ const stateDir = join(rootPath, ".ai-spend-agent");
445
+ const registry = await readSourceRegistry(stateDir, rootPath);
446
+ const lines = [
447
+ "AI Spend Analyst Agent sources",
448
+ `approved sources: ${registry.approvedSources.length}`
449
+ ];
450
+ for (const source of registry.approvedSources) {
451
+ lines.push(`- ${source.id} | ${source.type} | ${source.label} | ${source.provider ?? "unknown"} | ${source.path ?? "no path"}`);
452
+ }
453
+ return ok(lines.join("\n"));
454
+ }
455
+ // Connect flow leads with the two providers an org owner can self-serve in
456
+ // ~2 minutes. Cursor + Copilot are clearly-labeled team/billing-admin upgrades,
457
+ // not first-run blockers.
458
+ const selfServeProviders = new Set(["openai", "anthropic"]);
459
+ const adminUpgradeProviders = {
460
+ cursor: "requires a Cursor TEAM-ADMIN key (Business plan only)",
461
+ "github-copilot": "requires a GitHub BILLING-ADMIN token (org/enterprise)",
462
+ copilot: "requires a GitHub BILLING-ADMIN token (org/enterprise)"
463
+ };
464
+ const providerAdminEnvHint = {
465
+ openai: "env:OPENAI_ADMIN_KEY",
466
+ anthropic: "env:ANTHROPIC_ADMIN_KEY",
467
+ cursor: "env:CURSOR_ADMIN_KEY",
468
+ "github-copilot": "env:GITHUB_TOKEN",
469
+ copilot: "env:GITHUB_TOKEN"
470
+ };
471
+ async function connectCommand(args) {
472
+ const rootPath = resolve(args.path);
473
+ const stateDir = join(rootPath, ".ai-spend-agent");
474
+ const provider = args.provider ?? "unknown";
475
+ if (!provider || provider === "unknown") {
476
+ return {
477
+ exitCode: 1,
478
+ stdout: "",
479
+ stderr: [
480
+ "connect requires a provider. Start with one you can self-serve in ~2 min:",
481
+ " ai-spend-agent connect openai (org-owner Admin key)",
482
+ " ai-spend-agent connect anthropic (Admin key)",
483
+ "Team/billing-admin upgrades:",
484
+ " ai-spend-agent connect cursor (Cursor team-admin key, Business plan)",
485
+ " ai-spend-agent connect github-copilot (GitHub billing-admin token)"
486
+ ].join("\n")
487
+ };
488
+ }
489
+ const type = args.sourceType ?? "provider_api";
490
+ const registry = await readSourceRegistry(stateDir, rootPath);
491
+ const source = createProviderConnectorStub(provider, type);
492
+ const nextRegistry = addApprovedSource(registry, source);
493
+ await mkdir(stateDir, { recursive: true });
494
+ await writeJson(join(stateDir, "sources.json"), nextRegistry);
495
+ await appendAuditEvent(stateDir, {
496
+ timestamp: nextRegistry.updatedAt,
497
+ action: "source_registered",
498
+ sourceId: source.id,
499
+ detail: `${provider} ${type} connector stub registered. No raw secrets stored.`
500
+ });
501
+ // Auto-detect a local key for this provider (never prints the raw value).
502
+ const detection = await detectLocalCredentials({ cwd: rootPath });
503
+ const detected = detection.credentials.find((credential) => credential.provider === provider);
504
+ const lines = [
505
+ "AI Spend Analyst Agent connect",
506
+ `connector stub: ${source.id}`,
507
+ `provider: ${provider}`,
508
+ `type: ${type}`,
509
+ `access method: ${source.accessMethod}`,
510
+ `verification: ${source.verification}`,
511
+ "secrets: no raw secrets stored; we only reference a local env var such as env:OPENAI_ADMIN_KEY"
512
+ ];
513
+ if (selfServeProviders.has(provider)) {
514
+ lines.push("tier: self-serve — an org owner can enable this in ~2 minutes");
515
+ }
516
+ else if (adminUpgradeProviders[provider]) {
517
+ lines.push(`tier: ADMIN UPGRADE — ${adminUpgradeProviders[provider]}`);
518
+ }
519
+ lines.push("IMPORTANT: cost data is ADMIN-gated. A regular API key authenticates but will NOT return spend; use an admin/owner key.");
520
+ if (detected) {
521
+ lines.push("");
522
+ lines.push(`auto-detected: a ${provider} key in ${detected.reference} (${detected.hint}) from ${describeOrigin(detected)}`);
523
+ if (detected.isLikelyAdminKey) {
524
+ const adminRef = providerAdminEnvHint[provider] ?? detected.reference;
525
+ lines.push(`next: ai-spend-agent sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
526
+ }
527
+ else {
528
+ const adminRef = providerAdminEnvHint[provider] ?? "env:YOUR_ADMIN_KEY";
529
+ lines.push(`this looks like a regular key — for COST data set an admin key in ${adminRef}, then:`);
530
+ lines.push(` ai-spend-agent sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
531
+ }
532
+ }
533
+ else {
534
+ const adminRef = providerAdminEnvHint[provider] ?? "env:YOUR_ADMIN_KEY";
535
+ lines.push("");
536
+ lines.push(`next: export an admin key reference, e.g. ${adminRef}, then run:`);
537
+ lines.push(` ai-spend-agent sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
538
+ }
539
+ lines.push(`missing: ${source.fieldsMissing.join(", ")}`);
540
+ return ok(lines.join("\n"));
541
+ }
542
+ function describeOrigin(credential) {
543
+ if (credential.origin === "process_env")
544
+ return "your shell environment";
545
+ if (credential.origin === "dotenv")
546
+ return ".env";
547
+ return "shell rc file";
548
+ }
549
+ async function syncProviderCommand(args) {
550
+ const rootPath = resolve(args.path);
551
+ const stateDir = join(rootPath, ".ai-spend-agent");
552
+ if (!args.provider || !args.authReference || !args.startTime) {
553
+ return { exitCode: 1, stdout: "", stderr: "sync-provider requires --provider, --auth-reference env:NAME, and --start-time" };
554
+ }
555
+ try {
556
+ const result = await fetchProviderUsageRecords({
557
+ provider: args.provider,
558
+ sourceId: `${args.provider}-provider-api`,
559
+ authReference: args.authReference,
560
+ startTime: args.startTime,
561
+ endTime: args.endTime,
562
+ org: args.org,
563
+ enterprise: args.enterprise,
564
+ accountId: args.accountId
565
+ });
566
+ const registry = await readSourceRegistry(stateDir, rootPath);
567
+ const nextRegistry = addApprovedSource(registry, result.source);
568
+ const summary = analyzeSpend(result.records);
569
+ const mappings = attributeUsageRecords(result.records);
570
+ await mkdir(stateDir, { recursive: true });
571
+ await writeJson(join(stateDir, "sources.json"), nextRegistry);
572
+ await writeJson(join(stateDir, "provider-records.json"), {
573
+ provider: result.provider,
574
+ fetchedAt: result.fetchedAt,
575
+ completeness: result.completeness,
576
+ sourceId: result.source.id,
577
+ records: result.records,
578
+ qa: result.qa
579
+ });
580
+ await writeLocalSpendState(stateDir, result.records, summary, mappings);
581
+ await appendAuditEvent(stateDir, {
582
+ timestamp: result.fetchedAt,
583
+ action: "source_scanned",
584
+ sourceId: result.source.id,
585
+ detail: `${args.provider} provider connector synced ${result.records.length} verified records. Auth reference only; no raw secrets stored.`
586
+ });
587
+ return ok([
588
+ "AI Spend Analyst Agent sync-provider",
589
+ `provider: ${result.provider}`,
590
+ `source: ${result.source.id}`,
591
+ `verification: ${result.source.verification}`,
592
+ `verified records: ${result.records.length}`,
593
+ `total spend: $${summary.totalUsd.toFixed(2)}`,
594
+ "auth: reference-only; raw secrets were not persisted or printed"
595
+ ].join("\n"));
596
+ }
597
+ catch (error) {
598
+ return {
599
+ exitCode: 1,
600
+ stdout: "",
601
+ stderr: sanitizeSecretishError(error instanceof Error ? error.message : String(error), args.authReference)
602
+ };
603
+ }
604
+ }
605
+ async function confirmMappingCommand(args) {
606
+ const rootPath = resolve(args.path);
607
+ const stateDir = join(rootPath, ".ai-spend-agent");
608
+ if (!args.provider || !args.sourceId) {
609
+ return { exitCode: 1, stdout: "", stderr: "confirm-mapping requires --provider and --source-id" };
610
+ }
611
+ const mapping = confirmMapping({
612
+ provider: args.provider,
613
+ sourceId: args.sourceId,
614
+ team: args.team,
615
+ person: args.person,
616
+ client: args.client,
617
+ project: args.project,
618
+ agent: args.agent,
619
+ workflow: args.workflow,
620
+ evidence: args.evidence ? [args.evidence] : [],
621
+ confidence: args.confidence ?? 0.7
622
+ });
623
+ const mappings = await readConfirmedMappings(stateDir);
624
+ const nextMappings = [...mappings.filter((candidate) => candidate.id !== mapping.id), mapping];
625
+ await mkdir(stateDir, { recursive: true });
626
+ await writeJson(join(stateDir, "confirmed-mappings.json"), nextMappings);
627
+ await appendAuditEvent(stateDir, {
628
+ timestamp: mapping.confirmedAt,
629
+ action: "mapping_confirmed",
630
+ sourceId: args.sourceId,
631
+ detail: `${args.provider} mapped to ${[args.team, args.project, args.workflow].filter(Boolean).join(" / ")}`
632
+ });
633
+ return ok([
634
+ "AI Spend Analyst Agent confirm-mapping",
635
+ `mapping confirmed: ${mapping.id}`,
636
+ `provider: ${mapping.provider}`,
637
+ `target: ${[mapping.team, mapping.project, mapping.workflow].filter(Boolean).join(" / ")}`,
638
+ `confidence: ${mapping.confidence}`
639
+ ].join("\n"));
640
+ }
641
+ async function reportCommand(args) {
642
+ const rootPath = resolve(args.path);
643
+ const stateDir = join(rootPath, ".ai-spend-agent");
644
+ try {
645
+ const reportInput = await buildReportInput(stateDir, rootPath);
646
+ const outBase = args.out ? resolve(rootPath, args.out) : join(stateDir, "report");
647
+ const markdownPath = `${outBase}.md`;
648
+ const htmlPath = `${outBase}.html`;
649
+ await writeFile(markdownPath, generateMarkdownReport(reportInput), "utf8");
650
+ await writeFile(htmlPath, generateHtmlReport(reportInput), "utf8");
651
+ const artifactPaths = await writeApplyArtifacts(stateDir, reportInput);
652
+ return ok([
653
+ "AI Spend Analyst Agent report",
654
+ `path: ${rootPath}`,
655
+ `markdown: ${markdownPath}`,
656
+ `html: ${htmlPath}`,
657
+ `apply artifact: ${artifactPaths.codingPrompt}`,
658
+ `action plan: ${artifactPaths.actionPlan}`,
659
+ `policy/config draft: ${artifactPaths.policyConfigDraft}`,
660
+ `verification plan: ${artifactPaths.verificationPlan}`,
661
+ `demo package: ${artifactPaths.demoPackage}`,
662
+ `total spend: $${reportInput.summary.totalUsd.toFixed(2)}`,
663
+ "privacy: local files only; no cloud upload performed"
664
+ ].join("\n"));
665
+ }
666
+ catch (error) {
667
+ return {
668
+ exitCode: 1,
669
+ stdout: "",
670
+ stderr: `No local spend state found at ${stateDir}. Run scan --sample --path <dir> first. ${error instanceof Error ? error.message : ""}`
671
+ };
672
+ }
673
+ }
674
+ async function reportCardCommand(args) {
675
+ const rootPath = resolve(args.path);
676
+ let records;
677
+ if (args.sample) {
678
+ records = await loadSampleUsageData();
679
+ }
680
+ else {
681
+ const local = await readOptionalLocalSpend(rootPath);
682
+ records = local && local.length > 0 ? local : await loadSampleUsageData();
683
+ }
684
+ const summary = analyzeSpend(records);
685
+ const outPath = args.out ? resolve(rootPath, args.out) : join(rootPath, "ai-spend-card.svg");
686
+ await mkdir(dirname(outPath), { recursive: true });
687
+ await writeFile(outPath, generateReportCardSvg({ summary, records }), "utf8");
688
+ return ok([
689
+ "Shareable AI spend report card written (redacted — no client/project/user names).",
690
+ `card: ${outPath}`,
691
+ "",
692
+ "Caption to share:",
693
+ generateReportCardCaption({ summary, records }),
694
+ "",
695
+ "privacy: rendered locally; only totals, savings, and model-level cuts are included."
696
+ ].join("\n"));
697
+ }
698
+ async function applyArtifactCommand(args) {
699
+ const rootPath = resolve(args.path);
700
+ const stateDir = join(rootPath, ".ai-spend-agent");
701
+ try {
702
+ const reportInput = await buildReportInput(stateDir, rootPath);
703
+ const artifactPaths = await writeApplyArtifacts(stateDir, reportInput);
704
+ return ok([
705
+ "AI Spend Analyst Agent apply-artifact",
706
+ `path: ${rootPath}`,
707
+ `coding prompt: ${artifactPaths.codingPrompt}`,
708
+ `action plan: ${artifactPaths.actionPlan}`,
709
+ `policy/config draft: ${artifactPaths.policyConfigDraft}`,
710
+ `verification plan: ${artifactPaths.verificationPlan}`,
711
+ `demo package: ${artifactPaths.demoPackage}`,
712
+ "safety: generated artifacts only; no external systems changed"
713
+ ].join("\n"));
714
+ }
715
+ catch (error) {
716
+ return {
717
+ exitCode: 1,
718
+ stdout: "",
719
+ stderr: `No local spend state found at ${stateDir}. Run scan --sample --path <dir> first. ${error instanceof Error ? error.message : ""}`
720
+ };
721
+ }
722
+ }
723
+ async function buildReportInput(stateDir, rootPath) {
724
+ const [spendState, discovery, mappings, sourceRegistry, missingSourcePrompts, confirmedMappings, providerRecordsState] = await Promise.all([
725
+ readJson(join(stateDir, "spend.json")),
726
+ readJson(join(stateDir, "discovery.json")),
727
+ readJson(join(stateDir, "mappings.json")),
728
+ readSourceRegistry(stateDir, rootPath),
729
+ readOptionalJson(join(stateDir, "missing-sources.json"), []),
730
+ readConfirmedMappings(stateDir),
731
+ readOptionalJson(join(stateDir, "provider-records.json"), { records: [] })
732
+ ]);
733
+ return {
734
+ summary: spendState.summary,
735
+ discovery,
736
+ mappings,
737
+ sourceRegistry,
738
+ missingSourcePrompts,
739
+ confirmedMappings,
740
+ providerRecords: providerRecordsState.records,
741
+ providerQa: providerRecordsState.qa ? [providerRecordsState.qa] : []
742
+ };
743
+ }
744
+ async function writeApplyArtifacts(stateDir, reportInput) {
745
+ const paths = {
746
+ codingPrompt: join(stateDir, "ai-spend-coding-agent-prompt.md"),
747
+ actionPlan: join(stateDir, "ai-spend-action-plan.md"),
748
+ policyConfigDraft: join(stateDir, "ai-spend-policy-config-draft.md"),
749
+ verificationPlan: join(stateDir, "ai-spend-verify-plan.md"),
750
+ demoPackage: join(stateDir, "demo-package.md")
751
+ };
752
+ await writeFile(paths.codingPrompt, generateApplyArtifactMarkdown(reportInput), "utf8");
753
+ await writeFile(paths.actionPlan, generateActionPlanMarkdown(reportInput), "utf8");
754
+ await writeFile(paths.policyConfigDraft, generatePolicyConfigDraftMarkdown(reportInput), "utf8");
755
+ await writeFile(paths.verificationPlan, generateVerificationPlanMarkdown(reportInput), "utf8");
756
+ await writeFile(paths.demoPackage, generateDemoPackageMarkdown(reportInput), "utf8");
757
+ return paths;
758
+ }
759
+ function parseArgs(argv) {
760
+ // If the first token is a flag (e.g. `ai-spend-agent --group-by agent`),
761
+ // there is no subcommand: parse the whole argv as flags for the default
762
+ // instant-demo command.
763
+ const hasCommand = argv.length > 0 && !argv[0].startsWith("-");
764
+ const command = hasCommand ? argv[0] : undefined;
765
+ const rest = hasCommand ? argv.slice(1) : argv;
766
+ const parsed = {
767
+ command,
768
+ sample: false,
769
+ path: process.cwd()
770
+ };
771
+ if (command === "connect" && rest[0] && !rest[0].startsWith("--")) {
772
+ parsed.provider = rest[0];
773
+ rest.shift();
774
+ }
775
+ for (let index = 0; index < rest.length; index += 1) {
776
+ const arg = rest[index];
777
+ if (arg === "--sample") {
778
+ parsed.sample = true;
779
+ continue;
780
+ }
781
+ if (arg === "--no-color") {
782
+ parsed.noColor = true;
783
+ continue;
784
+ }
785
+ if (arg === "--group-by") {
786
+ const next = rest[index + 1];
787
+ if (isGroupByDimension(next)) {
788
+ parsed.groupBy = next;
789
+ index += 1;
790
+ }
791
+ continue;
792
+ }
793
+ if (arg === "--path") {
794
+ const next = rest[index + 1];
795
+ if (next) {
796
+ parsed.path = next;
797
+ index += 1;
798
+ }
799
+ continue;
800
+ }
801
+ if (arg === "--out") {
802
+ const next = rest[index + 1];
803
+ if (next) {
804
+ parsed.out = next;
805
+ index += 1;
806
+ }
807
+ continue;
808
+ }
809
+ if (arg === "--source-path") {
810
+ const next = rest[index + 1];
811
+ if (next) {
812
+ parsed.sourcePath = next;
813
+ index += 1;
814
+ }
815
+ continue;
816
+ }
817
+ if (arg === "--type") {
818
+ const next = rest[index + 1];
819
+ if (isSourceType(next)) {
820
+ parsed.sourceType = next;
821
+ index += 1;
822
+ }
823
+ continue;
824
+ }
825
+ if (arg === "--provider") {
826
+ const next = rest[index + 1];
827
+ if (next) {
828
+ parsed.provider = next;
829
+ index += 1;
830
+ }
831
+ continue;
832
+ }
833
+ if (arg === "--source-id") {
834
+ const next = rest[index + 1];
835
+ if (next) {
836
+ parsed.sourceId = next;
837
+ index += 1;
838
+ }
839
+ continue;
840
+ }
841
+ if (arg === "--team") {
842
+ const next = rest[index + 1];
843
+ if (next) {
844
+ parsed.team = next;
845
+ index += 1;
846
+ }
847
+ continue;
848
+ }
849
+ if (arg === "--person") {
850
+ const next = rest[index + 1];
851
+ if (next) {
852
+ parsed.person = next;
853
+ index += 1;
854
+ }
855
+ continue;
856
+ }
857
+ if (arg === "--client") {
858
+ const next = rest[index + 1];
859
+ if (next) {
860
+ parsed.client = next;
861
+ index += 1;
862
+ }
863
+ continue;
864
+ }
865
+ if (arg === "--project") {
866
+ const next = rest[index + 1];
867
+ if (next) {
868
+ parsed.project = next;
869
+ index += 1;
870
+ }
871
+ continue;
872
+ }
873
+ if (arg === "--agent") {
874
+ const next = rest[index + 1];
875
+ if (next) {
876
+ parsed.agent = next;
877
+ index += 1;
878
+ }
879
+ continue;
880
+ }
881
+ if (arg === "--workflow") {
882
+ const next = rest[index + 1];
883
+ if (next) {
884
+ parsed.workflow = next;
885
+ index += 1;
886
+ }
887
+ continue;
888
+ }
889
+ if (arg === "--evidence") {
890
+ const next = rest[index + 1];
891
+ if (next) {
892
+ parsed.evidence = next;
893
+ index += 1;
894
+ }
895
+ continue;
896
+ }
897
+ if (arg === "--confidence") {
898
+ const next = rest[index + 1];
899
+ if (next) {
900
+ parsed.confidence = Number(next);
901
+ index += 1;
902
+ }
903
+ continue;
904
+ }
905
+ if (arg === "--label") {
906
+ const next = rest[index + 1];
907
+ if (next) {
908
+ parsed.label = next;
909
+ index += 1;
910
+ }
911
+ continue;
912
+ }
913
+ if (arg === "--auth-reference") {
914
+ const next = rest[index + 1];
915
+ if (next) {
916
+ parsed.authReference = next;
917
+ index += 1;
918
+ }
919
+ continue;
920
+ }
921
+ if (arg === "--start-time") {
922
+ const next = rest[index + 1];
923
+ if (next) {
924
+ parsed.startTime = Number(next);
925
+ index += 1;
926
+ }
927
+ continue;
928
+ }
929
+ if (arg === "--end-time") {
930
+ const next = rest[index + 1];
931
+ if (next) {
932
+ parsed.endTime = Number(next);
933
+ index += 1;
934
+ }
935
+ continue;
936
+ }
937
+ if (arg === "--org") {
938
+ const next = rest[index + 1];
939
+ if (next) {
940
+ parsed.org = next;
941
+ index += 1;
942
+ }
943
+ continue;
944
+ }
945
+ if (arg === "--enterprise") {
946
+ const next = rest[index + 1];
947
+ if (next) {
948
+ parsed.enterprise = next;
949
+ index += 1;
950
+ }
951
+ continue;
952
+ }
953
+ if (arg === "--account-id") {
954
+ const next = rest[index + 1];
955
+ if (next) {
956
+ parsed.accountId = next;
957
+ index += 1;
958
+ }
959
+ continue;
960
+ }
961
+ if (arg === "--group-by") {
962
+ const next = rest[index + 1];
963
+ if (isGroupByDimension(next)) {
964
+ parsed.groupBy = next;
965
+ index += 1;
966
+ }
967
+ continue;
968
+ }
969
+ if (arg === "--interval") {
970
+ const next = rest[index + 1];
971
+ if (next) {
972
+ parsed.interval = Number(next);
973
+ index += 1;
974
+ }
975
+ continue;
976
+ }
977
+ if (arg === "--cycles") {
978
+ const next = rest[index + 1];
979
+ if (next) {
980
+ parsed.cycles = Number(next);
981
+ index += 1;
982
+ }
983
+ continue;
984
+ }
985
+ }
986
+ return parsed;
987
+ }
988
+ function sanitizeSecretishError(message, authReference) {
989
+ let sanitized = message.replace(/sk-[A-Za-z0-9_-]+/g, "[REDACTED]");
990
+ if (authReference && !authReference.startsWith("env:")) {
991
+ sanitized = sanitized.split(authReference).join("[REDACTED]");
992
+ }
993
+ return sanitized;
994
+ }
995
+ function unsafeScanRootReason(rootPath) {
996
+ const homePath = resolve(homedir());
997
+ if (rootPath === homePath) {
998
+ return "the home directory is too broad for V0 approved-source scanning";
999
+ }
1000
+ if (rootPath === "/") {
1001
+ return "the filesystem root is too broad for V0 approved-source scanning";
1002
+ }
1003
+ return undefined;
1004
+ }
1005
+ async function writeLocalSpendState(stateDir, records, summary, mappings) {
1006
+ await writeJson(join(stateDir, "spend.json"), { records, summary });
1007
+ await writeJson(join(stateDir, "mappings.json"), mappings);
1008
+ }
1009
+ async function readSourceRegistry(stateDir, rootPath) {
1010
+ try {
1011
+ return await readJson(join(stateDir, "sources.json"));
1012
+ }
1013
+ catch {
1014
+ return createLocalFolderSourceRegistry(rootPath);
1015
+ }
1016
+ }
1017
+ async function readConfirmedMappings(stateDir) {
1018
+ try {
1019
+ return await readJson(join(stateDir, "confirmed-mappings.json"));
1020
+ }
1021
+ catch {
1022
+ return [];
1023
+ }
1024
+ }
1025
+ function isGroupByDimension(value) {
1026
+ return value !== undefined && groupByDimensions.includes(value);
1027
+ }
1028
+ function isSourceType(value) {
1029
+ return value === "local_folder" ||
1030
+ value === "provider_export" ||
1031
+ value === "provider_api" ||
1032
+ value === "browser_account" ||
1033
+ value === "local_tool_detection" ||
1034
+ value === "mcp_tool" ||
1035
+ value === "internal_system";
1036
+ }
1037
+ async function appendAuditEvent(stateDir, event) {
1038
+ let auditLog = createScanAuditLog();
1039
+ try {
1040
+ auditLog = await readJson(join(stateDir, "audit-log.json"));
1041
+ }
1042
+ catch {
1043
+ // Create a fresh local-only audit log if init has not run yet.
1044
+ }
1045
+ await writeJson(join(stateDir, "audit-log.json"), createScanAuditLog([...auditLog.events, event]));
1046
+ }
1047
+ async function readJson(path) {
1048
+ return JSON.parse(await readFile(path, "utf8"));
1049
+ }
1050
+ async function readOptionalJson(path, fallback) {
1051
+ try {
1052
+ return await readJson(path);
1053
+ }
1054
+ catch {
1055
+ return fallback;
1056
+ }
1057
+ }
1058
+ async function writeJson(path, value) {
1059
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
1060
+ }
1061
+ function ok(stdout) {
1062
+ return { exitCode: 0, stdout, stderr: "" };
1063
+ }
1064
+ function helpText() {
1065
+ return [
1066
+ "AI Spend Analyst — your AI spend in one view in 90 seconds",
1067
+ "",
1068
+ "Run with no command for an instant, zero-key demo:",
1069
+ " ai-spend-agent Show where your AI money goes (sample/auto-detected data)",
1070
+ " ai-spend-agent --group-by agent Drill down by source|model|client|project|agent|user|workspace|apiKey",
1071
+ "",
1072
+ "Connect your real spend (cost data is ADMIN/owner-gated):",
1073
+ " ai-spend-agent connect openai Self-serve in ~2 min with an org-owner Admin key",
1074
+ " ai-spend-agent connect anthropic Self-serve in ~2 min with an Admin key",
1075
+ " ai-spend-agent connect cursor Upgrade: requires a Cursor team-admin key (Business plan)",
1076
+ " ai-spend-agent connect github-copilot Upgrade: requires a GitHub billing-admin token",
1077
+ " ai-spend-agent sync-provider ... Pull verified cost via a local env: reference (never a raw key)",
1078
+ "",
1079
+ "Watch continuously (deltas + anomalies):",
1080
+ " watch [--interval N] Re-run analysis on an interval and report deltas/anomalies",
1081
+ " [--cycles N] [--group-by ...] --cycles 0 runs forever; default 1 (cron-friendly)",
1082
+ "",
1083
+ "Other commands:",
1084
+ " init [--path <dir>] Initialize local state",
1085
+ " doctor Check local runtime and safety posture",
1086
+ " scan [--path <dir>] Scan a local workspace for AI usage signals",
1087
+ " scan --sample Include deterministic sample spend analysis",
1088
+ " quickstart [--sample] Plain-English 90-second readout (alias of the default run)",
1089
+ " [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: model",
1090
+ " report [--out <name>] Generate local Markdown and HTML reports",
1091
+ " report-card [--out f.svg] Write a redacted, shareable SVG spend card + caption",
1092
+ " apply-artifact Generate coding prompt, action plan, policy/config, verification, demo package",
1093
+ "",
1094
+ "Cron (production watch): add a crontab entry such as:",
1095
+ " 0 * * * * cd /path/to/workspace && ai-spend-agent watch --interval 3600 --cycles 1 >> ai-spend-watch.log 2>&1",
1096
+ "",
1097
+ "Privacy: local-first. No files, credentials, or spend data are uploaded. Secrets are never printed."
1098
+ ].join("\n");
1099
+ }
1100
+ // Main-module check that survives npm's bin SYMLINKS: argv[1] is
1101
+ // node_modules/.bin/ai-spend-agent (a symlink), so resolve it to the real
1102
+ // file before comparing. A naive `file://${argv[1]}` match silently no-ops
1103
+ // for every npx/global-install user.
1104
+ const invokedAsMain = (() => {
1105
+ const entry = process.argv[1];
1106
+ if (!entry)
1107
+ return false;
1108
+ try {
1109
+ return import.meta.url === pathToFileURL(realpathSync(entry)).href;
1110
+ }
1111
+ catch {
1112
+ return false;
1113
+ }
1114
+ })();
1115
+ if (invokedAsMain) {
1116
+ const argv = process.argv.slice(2);
1117
+ const command = argv[0];
1118
+ const isInstantDemo = !command || command === "quickstart" || command === "demo";
1119
+ // Show a spinner only for the work-heavy instant-demo path, and only on a
1120
+ // real TTY so piped output stays clean.
1121
+ let spinner;
1122
+ if (isInstantDemo && process.stdout.isTTY && !process.env.NO_COLOR) {
1123
+ try {
1124
+ const { default: yoctoSpinner } = await import("yocto-spinner");
1125
+ spinner = yoctoSpinner({ text: "Analyzing your AI spend…" }).start();
1126
+ }
1127
+ catch {
1128
+ // Spinner is optional; never block the wow on it.
1129
+ }
1130
+ }
1131
+ let result;
1132
+ try {
1133
+ result = await runCli(argv);
1134
+ }
1135
+ finally {
1136
+ spinner?.stop();
1137
+ }
1138
+ if (result.stdout) {
1139
+ console.log(result.stdout);
1140
+ }
1141
+ if (result.stderr) {
1142
+ console.error(result.stderr);
1143
+ }
1144
+ process.exitCode = result.exitCode;
1145
+ }
1146
+ //# sourceMappingURL=index.js.map