ai-spend-agent 0.5.7 → 0.5.9
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 +202 -66
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { realpathSync } from "node:fs";
|
|
|
3
3
|
import { mkdir, readFile, rm, stat } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
-
import { analyzeSpend, attributeUsageRecords, buildUsageGlance, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, writeSafeStateText, loadDeadContext, sampleDeadContext, loadLocalAgentUsage, loadSampleUsageData, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, slugifySourceId } from "@agent-finops/core";
|
|
6
|
+
import { analyzeSpend, attributeUsageRecords, buildUsageGlance, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, writeSafeStateText, loadDeadContext, sampleDeadContext, latestObservedWorkingDirectory, isBundledSampleUsage, loadLocalAgentUsage, loadSampleUsageData, parseUsageRecord, 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("--version") || argv.includes("-v")) {
|
|
@@ -82,7 +82,10 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
async function quickstartCommand(args) {
|
|
85
|
-
const
|
|
85
|
+
const sinceDays = args.sinceDays ?? 30;
|
|
86
|
+
if (!validSinceDays(sinceDays))
|
|
87
|
+
return invalidSinceDaysResult();
|
|
88
|
+
const { records, mode, warnings, codexInvocationFiles } = await loadInstantReadData(args);
|
|
86
89
|
const summaryRecords = mode === "connected"
|
|
87
90
|
? selectProviderFinancialHeadlineRecords(records)
|
|
88
91
|
: records;
|
|
@@ -94,7 +97,12 @@ async function quickstartCommand(args) {
|
|
|
94
97
|
// Persona: --plan override wins; otherwise read the plans the coding agents
|
|
95
98
|
// themselves persisted locally (read-only, whitelisted fields, no network).
|
|
96
99
|
let detectedPlans;
|
|
97
|
-
if (args.
|
|
100
|
+
if (args.sample) {
|
|
101
|
+
// An explicit sample run must be deterministic and safe to record/share.
|
|
102
|
+
// Never mix the developer's real local plan into illustrative output.
|
|
103
|
+
detectedPlans = [];
|
|
104
|
+
}
|
|
105
|
+
else if (args.plan) {
|
|
98
106
|
const override = planOverrideFromFlag(args.plan);
|
|
99
107
|
if (!override) {
|
|
100
108
|
return {
|
|
@@ -114,7 +122,15 @@ async function quickstartCommand(args) {
|
|
|
114
122
|
}
|
|
115
123
|
// Surface auto-detected credentials so the user knows their next 2-min step,
|
|
116
124
|
// without ever printing a raw secret.
|
|
117
|
-
|
|
125
|
+
// Sample output is designed for demos, docs, and screenshots. Keeping local
|
|
126
|
+
// credential discovery out of that path prevents even redacted machine-
|
|
127
|
+
// specific hints from leaking into a recording.
|
|
128
|
+
const detection = args.sample
|
|
129
|
+
? { credentials: [], scannedFiles: [] }
|
|
130
|
+
: await detectLocalCredentials({
|
|
131
|
+
cwd: resolve(args.path),
|
|
132
|
+
home: process.env.AI_SPEND_CLAUDE_HOME_DIR
|
|
133
|
+
});
|
|
118
134
|
const nextSteps = quickstartNextSteps(mode, detection.credentials);
|
|
119
135
|
// Dead-context cost, globalized across the user's whole Claude Code setup
|
|
120
136
|
// (all projects' MCP + user-scope skills/agents/commands, vs. every
|
|
@@ -132,8 +148,9 @@ async function quickstartCommand(args) {
|
|
|
132
148
|
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
133
149
|
projectDir: resolve(args.path),
|
|
134
150
|
includeAllProjectMcp: true,
|
|
135
|
-
sinceIso:
|
|
136
|
-
windowDays:
|
|
151
|
+
sinceIso: sinceIsoForDays(sinceDays),
|
|
152
|
+
windowDays: sinceDays,
|
|
153
|
+
codexInvocationFiles
|
|
137
154
|
}).catch(() => undefined);
|
|
138
155
|
// Sample dead-context is shown ONLY on the demo readout. A real readout
|
|
139
156
|
// (local logs / connected billing) never gets fabricated waste injected —
|
|
@@ -158,21 +175,21 @@ async function quickstartCommand(args) {
|
|
|
158
175
|
}
|
|
159
176
|
async function glanceCommand(args) {
|
|
160
177
|
const sinceDays = args.sinceDays ?? 30;
|
|
161
|
-
if (!
|
|
162
|
-
return
|
|
163
|
-
exitCode: 1,
|
|
164
|
-
stdout: "",
|
|
165
|
-
stderr: "--since-days must be a whole number between 1 and 365"
|
|
166
|
-
};
|
|
167
|
-
}
|
|
178
|
+
if (!validSinceDays(sinceDays))
|
|
179
|
+
return invalidSinceDaysResult();
|
|
168
180
|
const logs = await loadLocalAgentUsage({
|
|
169
181
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
170
182
|
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
171
|
-
sinceIso:
|
|
183
|
+
sinceIso: sinceIsoForDays(sinceDays),
|
|
184
|
+
collectCodexInvocationEvidence: true
|
|
172
185
|
});
|
|
173
186
|
const calls = args.project
|
|
174
187
|
? logs.calls.filter((call) => call.project === args.project)
|
|
175
188
|
: logs.calls;
|
|
189
|
+
const latestWorkingDirectory = latestObservedWorkingDirectory(calls);
|
|
190
|
+
const contextProjectDir = args.pathExplicit
|
|
191
|
+
? resolve(args.path)
|
|
192
|
+
: latestWorkingDirectory ?? resolve(args.path);
|
|
176
193
|
let detectedPlans;
|
|
177
194
|
if (args.plan) {
|
|
178
195
|
const override = planOverrideFromFlag(args.plan);
|
|
@@ -198,9 +215,10 @@ async function glanceCommand(args) {
|
|
|
198
215
|
codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
|
|
199
216
|
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
200
217
|
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
201
|
-
projectDir:
|
|
202
|
-
sinceIso:
|
|
203
|
-
windowDays: sinceDays
|
|
218
|
+
projectDir: contextProjectDir,
|
|
219
|
+
sinceIso: sinceIsoForDays(sinceDays),
|
|
220
|
+
windowDays: sinceDays,
|
|
221
|
+
codexInvocationFiles: logs.codexInvocationFiles
|
|
204
222
|
});
|
|
205
223
|
const snapshot = buildUsageGlance(calls, {
|
|
206
224
|
filesParsed: logs.filesParsed,
|
|
@@ -213,18 +231,14 @@ async function glanceCommand(args) {
|
|
|
213
231
|
}
|
|
214
232
|
async function contextHealthCommand(args) {
|
|
215
233
|
const sinceDays = args.sinceDays ?? 30;
|
|
216
|
-
if (!
|
|
217
|
-
return
|
|
218
|
-
|
|
219
|
-
stdout: "",
|
|
220
|
-
stderr: "--since-days must be a whole number between 1 and 365"
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
const sinceIso = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1_000).toISOString();
|
|
234
|
+
if (!validSinceDays(sinceDays))
|
|
235
|
+
return invalidSinceDaysResult();
|
|
236
|
+
const sinceIso = sinceIsoForDays(sinceDays);
|
|
224
237
|
const logs = await loadLocalAgentUsage({
|
|
225
238
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
226
239
|
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
227
|
-
sinceIso
|
|
240
|
+
sinceIso,
|
|
241
|
+
collectCodexInvocationEvidence: true
|
|
228
242
|
});
|
|
229
243
|
const calls = args.project
|
|
230
244
|
? logs.calls.filter((call) => call.project === args.project)
|
|
@@ -238,7 +252,8 @@ async function contextHealthCommand(args) {
|
|
|
238
252
|
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
239
253
|
projectDir: resolve(args.path),
|
|
240
254
|
sinceIso,
|
|
241
|
-
windowDays: sinceDays
|
|
255
|
+
windowDays: sinceDays,
|
|
256
|
+
codexInvocationFiles: logs.codexInvocationFiles
|
|
242
257
|
});
|
|
243
258
|
return ok(args.json ? JSON.stringify(health) : renderContextHealth(health));
|
|
244
259
|
}
|
|
@@ -254,20 +269,23 @@ function renderContextHealth(health) {
|
|
|
254
269
|
`Confidence: ${health.confidence}`,
|
|
255
270
|
"",
|
|
256
271
|
"Activation",
|
|
257
|
-
` Discoverable: ${activation.discoverableItems} Invoked: ${activation.explicitlyInvokedItems} MCP
|
|
272
|
+
` Discoverable: ${activation.discoverableItems} Invoked: ${activation.explicitlyInvokedItems} MCP configured: ${activation.mcpConfiguredItems}`,
|
|
273
|
+
` MCP always-load requested: ${activation.mcpAlwaysLoadedItems} Legacy schema-loaded label: ${activation.mcpSchemaLoadedItems}`,
|
|
258
274
|
` Hook-injected: ${activation.hookInjectedItems} Other lifecycle hooks: ${activation.lifecycleHooks} Unmeasured weight: ${activation.unmeasuredItems}`,
|
|
259
275
|
` Invocation-unobservable: ${activation.invocationUnobservableItems}`,
|
|
260
276
|
"",
|
|
261
|
-
`
|
|
277
|
+
`No matching invocation among observable inventory (${dead.windowDays}d): ${dead.neverInvokedItems}/${dead.loadedItems} ` +
|
|
262
278
|
`(${dead.measuredNeverInvokedItems} measured, ${dead.unmeasuredNeverInvokedItems} unmeasured)`
|
|
263
279
|
];
|
|
264
280
|
if (health.currentSession) {
|
|
265
281
|
const session = health.currentSession;
|
|
266
|
-
lines.push(`
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
282
|
+
lines.push(`Latest turn: ${session.agent}${session.project ? ` · ${session.project}` : ""} · ` +
|
|
283
|
+
(session.usageSource === "not_available"
|
|
284
|
+
? "input context unavailable; cumulative lifetime usage excluded"
|
|
285
|
+
: `${session.contextTokens.toLocaleString("en-US")} input context tokens · ` +
|
|
286
|
+
(session.ratioToMedian === null
|
|
287
|
+
? `${session.comparisonSessions} comparable prior session${session.comparisonSessions === 1 ? "" : "s"}; baseline not yet sufficient`
|
|
288
|
+
: `${session.ratioCapped ? "at least " : ""}${session.ratioToMedian}× comparable median (${session.comparisonSessions} prior)`)));
|
|
271
289
|
}
|
|
272
290
|
const churn = health.contextChurn;
|
|
273
291
|
if (churn.currentSessionEvidence === "matched") {
|
|
@@ -331,7 +349,9 @@ async function loadInstantReadData(args) {
|
|
|
331
349
|
const logs = await loadLocalAgentUsage({
|
|
332
350
|
// Env overrides keep tests (and unusual installs) isolated from $HOME.
|
|
333
351
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
334
|
-
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
|
|
352
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
353
|
+
sinceIso: sinceIsoForDays(args.sinceDays ?? 30),
|
|
354
|
+
collectCodexInvocationEvidence: true
|
|
335
355
|
}).catch(() => undefined);
|
|
336
356
|
if (logs && logs.records.length > 0) {
|
|
337
357
|
// Persisted local_logs state (written by report/apply-artifact) is the
|
|
@@ -340,7 +360,12 @@ async function loadInstantReadData(args) {
|
|
|
340
360
|
if (persisted && persisted.records.length > 0 && persisted.mode !== "connected_provider" && persisted.mode !== "local_logs") {
|
|
341
361
|
warnings.push("Ignored persisted sample/legacy state in .ai-spend-agent/spend.json — showing your real local agent logs. Run `ai-spend-agent reset` to clear it, or pass --ignore-state.");
|
|
342
362
|
}
|
|
343
|
-
return {
|
|
363
|
+
return {
|
|
364
|
+
records: logs.records,
|
|
365
|
+
mode: "local-logs",
|
|
366
|
+
warnings,
|
|
367
|
+
codexInvocationFiles: logs.codexInvocationFiles
|
|
368
|
+
};
|
|
344
369
|
}
|
|
345
370
|
// No real logs. Persisted sample/legacy state may still be shown, but only as
|
|
346
371
|
// DEMO (never as connected), with a warning when its origin is unknown.
|
|
@@ -375,7 +400,10 @@ async function doctorCommand(args) {
|
|
|
375
400
|
const claudeFound = detected.includes("claude-code");
|
|
376
401
|
const codexFound = detected.includes("codex");
|
|
377
402
|
const hasLogs = claudeFound || codexFound;
|
|
378
|
-
const detection = await detectLocalCredentials({
|
|
403
|
+
const detection = await detectLocalCredentials({
|
|
404
|
+
cwd: rootPath,
|
|
405
|
+
home: process.env.AI_SPEND_CLAUDE_HOME_DIR
|
|
406
|
+
}).catch(() => ({ credentials: [] }));
|
|
379
407
|
const providerRefs = detection.credentials.map((credential) => `${credential.provider} (${credential.hint})`);
|
|
380
408
|
const plans = await detectLocalPlans({
|
|
381
409
|
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
@@ -399,7 +427,7 @@ async function doctorCommand(args) {
|
|
|
399
427
|
? "your local agent logs (estimated at API-equivalent rates)"
|
|
400
428
|
: "demo sample (illustrative)";
|
|
401
429
|
const lines = [
|
|
402
|
-
"
|
|
430
|
+
"aibill doctor",
|
|
403
431
|
`node version: ${process.version}`,
|
|
404
432
|
`cli version: ${await cliVersion()}`,
|
|
405
433
|
"local-first mode: enabled (no cloud upload, no telemetry)",
|
|
@@ -437,7 +465,7 @@ async function resetCommand(args) {
|
|
|
437
465
|
if (!isNodeError(error, "ENOENT"))
|
|
438
466
|
throw error;
|
|
439
467
|
return ok([
|
|
440
|
-
"
|
|
468
|
+
"aibill reset",
|
|
441
469
|
`path: ${rootPath}`,
|
|
442
470
|
"nothing to clear (no persisted spend state found)",
|
|
443
471
|
"next run will re-read your real local agent logs (or demo sample if none)."
|
|
@@ -457,7 +485,7 @@ async function resetCommand(args) {
|
|
|
457
485
|
}
|
|
458
486
|
}
|
|
459
487
|
return ok([
|
|
460
|
-
"
|
|
488
|
+
"aibill reset",
|
|
461
489
|
`path: ${rootPath}`,
|
|
462
490
|
removed.length > 0 ? `cleared: ${removed.join(", ")}` : "nothing to clear (no persisted spend state found)",
|
|
463
491
|
"next run will re-read your real local agent logs (or demo sample if none)."
|
|
@@ -469,7 +497,7 @@ async function initCommand(args) {
|
|
|
469
497
|
await mkdir(stateDir, { recursive: true });
|
|
470
498
|
const registry = createLocalFolderSourceRegistry(rootPath);
|
|
471
499
|
await writeJson(join(stateDir, "manifest.json"), {
|
|
472
|
-
product: "
|
|
500
|
+
product: "aibill",
|
|
473
501
|
mode: "local-first-demo",
|
|
474
502
|
cloudUpload: false,
|
|
475
503
|
cronJobsEnabled: false,
|
|
@@ -493,7 +521,7 @@ async function initCommand(args) {
|
|
|
493
521
|
}
|
|
494
522
|
]));
|
|
495
523
|
return ok([
|
|
496
|
-
"
|
|
524
|
+
"aibill init",
|
|
497
525
|
`path: ${rootPath}`,
|
|
498
526
|
"demo mode: local-first sample workflow",
|
|
499
527
|
"cloud upload: disabled",
|
|
@@ -570,7 +598,7 @@ async function scanCommand(args) {
|
|
|
570
598
|
await writeJson(join(stateDir, "discovery.json"), discovery);
|
|
571
599
|
await writeJson(join(stateDir, "missing-sources.json"), missingSourcePrompts);
|
|
572
600
|
const lines = [
|
|
573
|
-
"
|
|
601
|
+
"aibill scan",
|
|
574
602
|
`path: ${rootPath}`,
|
|
575
603
|
"source registry: .ai-spend-agent/sources.json",
|
|
576
604
|
"audit log: .ai-spend-agent/audit-log.json",
|
|
@@ -779,7 +807,7 @@ async function addSourceCommand(args) {
|
|
|
779
807
|
detail: `${args.sourceType} approved via CLI add-source.`
|
|
780
808
|
});
|
|
781
809
|
return ok([
|
|
782
|
-
"
|
|
810
|
+
"aibill add-source",
|
|
783
811
|
`source added: ${id}`,
|
|
784
812
|
`type: ${args.sourceType}`,
|
|
785
813
|
`path: ${sourcePath}`,
|
|
@@ -792,7 +820,7 @@ async function listSourcesCommand(args) {
|
|
|
792
820
|
const stateDir = join(rootPath, ".ai-spend-agent");
|
|
793
821
|
const registry = await readSourceRegistry(stateDir, rootPath);
|
|
794
822
|
const lines = [
|
|
795
|
-
"
|
|
823
|
+
"aibill sources",
|
|
796
824
|
`approved sources: ${registry.approvedSources.length}`
|
|
797
825
|
];
|
|
798
826
|
for (const source of registry.approvedSources) {
|
|
@@ -847,10 +875,13 @@ async function connectCommand(args) {
|
|
|
847
875
|
detail: `${provider} ${type} connector stub registered. No raw secrets stored.`
|
|
848
876
|
});
|
|
849
877
|
// Auto-detect a local key for this provider (never prints the raw value).
|
|
850
|
-
const detection = await detectLocalCredentials({
|
|
878
|
+
const detection = await detectLocalCredentials({
|
|
879
|
+
cwd: rootPath,
|
|
880
|
+
home: process.env.AI_SPEND_CLAUDE_HOME_DIR
|
|
881
|
+
});
|
|
851
882
|
const detected = detection.credentials.find((credential) => credential.provider === provider);
|
|
852
883
|
const lines = [
|
|
853
|
-
"
|
|
884
|
+
"aibill connect",
|
|
854
885
|
`connector stub: ${source.id}`,
|
|
855
886
|
`provider: ${provider}`,
|
|
856
887
|
`type: ${type}`,
|
|
@@ -983,7 +1014,7 @@ async function syncProviderCommand(args) {
|
|
|
983
1014
|
detail: `${args.provider} provider connector synced ${result.records.length} evidence records with ${result.coverage} coverage. Auth reference only; no raw secrets stored.`
|
|
984
1015
|
});
|
|
985
1016
|
return ok([
|
|
986
|
-
"
|
|
1017
|
+
"aibill sync-provider",
|
|
987
1018
|
`provider: ${result.provider}`,
|
|
988
1019
|
`source: ${result.source.id}`,
|
|
989
1020
|
`verification: ${result.source.verification}`,
|
|
@@ -1035,7 +1066,7 @@ async function confirmMappingCommand(args) {
|
|
|
1035
1066
|
detail: `${args.provider} mapped to ${[args.team, args.project, args.workflow].filter(Boolean).join(" / ")}`
|
|
1036
1067
|
});
|
|
1037
1068
|
return ok([
|
|
1038
|
-
"
|
|
1069
|
+
"aibill confirm-mapping",
|
|
1039
1070
|
`mapping confirmed: ${mapping.id}`,
|
|
1040
1071
|
`provider: ${mapping.provider}`,
|
|
1041
1072
|
`target: ${[mapping.team, mapping.project, mapping.workflow].filter(Boolean).join(" / ")}`,
|
|
@@ -1046,7 +1077,10 @@ async function reportCommand(args) {
|
|
|
1046
1077
|
const rootPath = resolve(args.path);
|
|
1047
1078
|
const stateDir = join(rootPath, ".ai-spend-agent");
|
|
1048
1079
|
try {
|
|
1049
|
-
const
|
|
1080
|
+
const sinceDays = args.sinceDays ?? 30;
|
|
1081
|
+
if (!validSinceDays(sinceDays))
|
|
1082
|
+
return invalidSinceDaysResult();
|
|
1083
|
+
const reportInput = await buildReportInput(stateDir, rootPath, sinceDays);
|
|
1050
1084
|
const outBase = args.out ? resolve(rootPath, args.out) : join(stateDir, "report");
|
|
1051
1085
|
const markdownPath = `${outBase}.md`;
|
|
1052
1086
|
const htmlPath = `${outBase}.html`;
|
|
@@ -1054,7 +1088,7 @@ async function reportCommand(args) {
|
|
|
1054
1088
|
await writeLocalReportFile(htmlPath, generateHtmlReport(reportInput), stateDir);
|
|
1055
1089
|
const artifactPaths = await writeApplyArtifacts(stateDir, reportInput);
|
|
1056
1090
|
return ok([
|
|
1057
|
-
"
|
|
1091
|
+
"aibill report",
|
|
1058
1092
|
`path: ${rootPath}`,
|
|
1059
1093
|
`markdown: ${markdownPath}`,
|
|
1060
1094
|
`html: ${htmlPath}`,
|
|
@@ -1063,8 +1097,8 @@ async function reportCommand(args) {
|
|
|
1063
1097
|
`policy/config draft: ${artifactPaths.policyConfigDraft}`,
|
|
1064
1098
|
`verification plan: ${artifactPaths.verificationPlan}`,
|
|
1065
1099
|
`demo package: ${artifactPaths.demoPackage}`,
|
|
1066
|
-
`total
|
|
1067
|
-
"privacy:
|
|
1100
|
+
`cost/value evidence total: $${reportInput.summary.totalUsd.toFixed(2)}`,
|
|
1101
|
+
"privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
|
|
1068
1102
|
"",
|
|
1069
1103
|
"next:",
|
|
1070
1104
|
` open ${htmlPath} view the full report in your browser`,
|
|
@@ -1118,7 +1152,7 @@ async function reportCardCommand(args) {
|
|
|
1118
1152
|
"Caption to share:",
|
|
1119
1153
|
generateReportCardCaption({ summary, records: headlineRecords, mode }),
|
|
1120
1154
|
"",
|
|
1121
|
-
"privacy: rendered locally; only totals,
|
|
1155
|
+
"privacy: rendered locally; only totals, generic candidate categories, and evidence labels are included."
|
|
1122
1156
|
].join("\n"));
|
|
1123
1157
|
}
|
|
1124
1158
|
catch (error) {
|
|
@@ -1133,13 +1167,34 @@ async function applyArtifactCommand(args) {
|
|
|
1133
1167
|
const rootPath = resolve(args.path);
|
|
1134
1168
|
const stateDir = join(rootPath, ".ai-spend-agent");
|
|
1135
1169
|
try {
|
|
1136
|
-
const
|
|
1170
|
+
const sinceDays = args.sinceDays ?? 30;
|
|
1171
|
+
if (!validSinceDays(sinceDays))
|
|
1172
|
+
return invalidSinceDaysResult();
|
|
1173
|
+
// `--sample` is a privacy boundary, not presentation sugar. It must never
|
|
1174
|
+
// fall through to live transcript, plan, credential, or persisted-state
|
|
1175
|
+
// discovery — regardless of where the flag appears after the command.
|
|
1176
|
+
const reportInput = args.sample
|
|
1177
|
+
? await buildExplicitSampleReportInput(rootPath)
|
|
1178
|
+
: await buildReportInput(stateDir, rootPath, sinceDays);
|
|
1179
|
+
await mkdir(stateDir, { recursive: true });
|
|
1137
1180
|
const artifactPaths = await writeApplyArtifacts(stateDir, reportInput);
|
|
1138
1181
|
// The prompt IS the product of this command — print it so a terminal
|
|
1139
1182
|
// user can copy it right here instead of hunting for a file path.
|
|
1140
1183
|
const codingPrompt = await readFile(artifactPaths.codingPrompt, "utf8");
|
|
1184
|
+
if (args.sample) {
|
|
1185
|
+
return ok([
|
|
1186
|
+
"aibill apply-artifact",
|
|
1187
|
+
"data: DEMO sample data (illustrative — not your logs, account, bill, project, or workflow)",
|
|
1188
|
+
"artifacts: .ai-spend-agent/ (non-executable demo files)",
|
|
1189
|
+
"safety: no live transcripts, account metadata, credentials, or persisted spend state were read",
|
|
1190
|
+
"",
|
|
1191
|
+
"──── non-executable demo prompt (also saved under .ai-spend-agent/) ────",
|
|
1192
|
+
"",
|
|
1193
|
+
codingPrompt.trimEnd()
|
|
1194
|
+
].join("\n"));
|
|
1195
|
+
}
|
|
1141
1196
|
return ok([
|
|
1142
|
-
"
|
|
1197
|
+
"aibill apply-artifact",
|
|
1143
1198
|
`path: ${rootPath}`,
|
|
1144
1199
|
`action plan: ${artifactPaths.actionPlan}`,
|
|
1145
1200
|
`policy/config draft: ${artifactPaths.policyConfigDraft}`,
|
|
@@ -1160,9 +1215,51 @@ async function applyArtifactCommand(args) {
|
|
|
1160
1215
|
};
|
|
1161
1216
|
}
|
|
1162
1217
|
}
|
|
1163
|
-
async function
|
|
1218
|
+
async function buildExplicitSampleReportInput(rootPath) {
|
|
1219
|
+
const records = await loadSampleUsageData();
|
|
1220
|
+
return {
|
|
1221
|
+
// Fixed alongside the bundled fixture so repeated demo runs stay stable
|
|
1222
|
+
// and cannot absorb this machine's clock or account state into an asset.
|
|
1223
|
+
generatedAt: "2026-05-20T00:00:00.000Z",
|
|
1224
|
+
summary: analyzeSpend(records),
|
|
1225
|
+
allRecords: records,
|
|
1226
|
+
dataMode: "sample",
|
|
1227
|
+
discovery: emptyDiscovery(rootPath),
|
|
1228
|
+
mappings: attributeUsageRecords(records),
|
|
1229
|
+
missingSourcePrompts: [],
|
|
1230
|
+
confirmedMappings: [],
|
|
1231
|
+
providerRecords: [],
|
|
1232
|
+
providerQa: [],
|
|
1233
|
+
deadContext: sampleDeadContext(),
|
|
1234
|
+
detectedPlans: []
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
|
|
1238
|
+
// One anchor for logs, Context Health, the paste-ready prompt, and every
|
|
1239
|
+
// supporting Apply artifact. This prevents millisecond window drift between
|
|
1240
|
+
// files generated by the same command.
|
|
1241
|
+
const generatedAt = new Date();
|
|
1242
|
+
const sinceIso = sinceIsoForDays(sinceDays, generatedAt);
|
|
1243
|
+
let freshLocalCalls;
|
|
1244
|
+
let freshCodexInvocationFiles;
|
|
1164
1245
|
let spendState = await readOptionalJson(join(stateDir, "spend.json"), undefined);
|
|
1165
1246
|
let mappings = await readOptionalJson(join(stateDir, "mappings.json"), undefined);
|
|
1247
|
+
// Never trust a persisted summary or an absent mode. Re-parse the records,
|
|
1248
|
+
// recover the narrowly identifiable bundled sample written by older
|
|
1249
|
+
// releases, and recompute decision output under the current evidence rules.
|
|
1250
|
+
// Any other unlabeled state remains unlabeled and therefore non-executable.
|
|
1251
|
+
if (spendState?.records && spendState.records.length > 0) {
|
|
1252
|
+
const records = spendState.records.map((record) => parseUsageRecord(record));
|
|
1253
|
+
const mode = spendState.mode ?? (isBundledSampleUsage(records) ? "sample" : undefined);
|
|
1254
|
+
const headlineRecords = mode === "connected_provider"
|
|
1255
|
+
? selectProviderFinancialHeadlineRecords(records)
|
|
1256
|
+
: records;
|
|
1257
|
+
spendState = {
|
|
1258
|
+
records,
|
|
1259
|
+
mode,
|
|
1260
|
+
summary: analyzeSpend(headlineRecords)
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1166
1263
|
// Local-log state is a CACHE, not a source of truth: the quickstart always
|
|
1167
1264
|
// re-reads the logs fresh, so report/apply must too — otherwise yesterday's
|
|
1168
1265
|
// persisted snapshot makes the artifact's numbers disagree with the screen.
|
|
@@ -1182,9 +1279,13 @@ async function buildReportInput(stateDir, rootPath) {
|
|
|
1182
1279
|
if (needsFreshLogs) {
|
|
1183
1280
|
const logs = await loadLocalAgentUsage({
|
|
1184
1281
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
1185
|
-
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
|
|
1282
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
1283
|
+
sinceIso,
|
|
1284
|
+
collectCodexInvocationEvidence: true
|
|
1186
1285
|
}).catch(() => undefined);
|
|
1187
1286
|
if (logs && logs.records.length > 0) {
|
|
1287
|
+
freshLocalCalls = logs.calls;
|
|
1288
|
+
freshCodexInvocationFiles = logs.codexInvocationFiles;
|
|
1188
1289
|
const records = logs.records;
|
|
1189
1290
|
const summary = analyzeSpend(records);
|
|
1190
1291
|
const liveMappings = attributeUsageRecords(records);
|
|
@@ -1218,8 +1319,9 @@ async function buildReportInput(stateDir, rootPath) {
|
|
|
1218
1319
|
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
1219
1320
|
projectDir: rootPath,
|
|
1220
1321
|
includeAllProjectMcp: true,
|
|
1221
|
-
sinceIso
|
|
1222
|
-
windowDays:
|
|
1322
|
+
sinceIso,
|
|
1323
|
+
windowDays: sinceDays,
|
|
1324
|
+
codexInvocationFiles: freshCodexInvocationFiles
|
|
1223
1325
|
}).catch(() => undefined)
|
|
1224
1326
|
: undefined;
|
|
1225
1327
|
const detectedPlans = spendState.mode === "local_logs"
|
|
@@ -1228,10 +1330,30 @@ async function buildReportInput(stateDir, rootPath) {
|
|
|
1228
1330
|
codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
|
|
1229
1331
|
}).catch(() => [])
|
|
1230
1332
|
: [];
|
|
1333
|
+
// Report/apply and Glance consume the same canonical Context Health result.
|
|
1334
|
+
// If live transcript calls are unavailable, omit it instead of fabricating a
|
|
1335
|
+
// session-level recommendation from day-aggregate spend records.
|
|
1336
|
+
const contextHealth = spendState.mode === "local_logs" && freshLocalCalls
|
|
1337
|
+
? await loadContextHealth(freshLocalCalls, {
|
|
1338
|
+
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
1339
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
1340
|
+
claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
|
|
1341
|
+
codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
|
|
1342
|
+
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
1343
|
+
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
1344
|
+
projectDir: rootPath,
|
|
1345
|
+
includeAllProjectMcp: true,
|
|
1346
|
+
sinceIso,
|
|
1347
|
+
windowDays: sinceDays,
|
|
1348
|
+
codexInvocationFiles: freshCodexInvocationFiles
|
|
1349
|
+
}).catch(() => undefined)
|
|
1350
|
+
: undefined;
|
|
1231
1351
|
return {
|
|
1352
|
+
generatedAt: generatedAt.toISOString(),
|
|
1232
1353
|
summary: spendState.summary,
|
|
1233
1354
|
deadContext,
|
|
1234
1355
|
detectedPlans,
|
|
1356
|
+
contextHealth,
|
|
1235
1357
|
// Evidence ledger is built from the SAME records as the confidence
|
|
1236
1358
|
// breakdown so the two sections can never contradict each other.
|
|
1237
1359
|
allRecords: spendState.mode === "connected_provider"
|
|
@@ -1247,6 +1369,19 @@ async function buildReportInput(stateDir, rootPath) {
|
|
|
1247
1369
|
providerQa: providerRecordsState.qa ? [providerRecordsState.qa] : []
|
|
1248
1370
|
};
|
|
1249
1371
|
}
|
|
1372
|
+
function validSinceDays(value) {
|
|
1373
|
+
return Number.isInteger(value) && value >= 1 && value <= 365;
|
|
1374
|
+
}
|
|
1375
|
+
function sinceIsoForDays(value, now = new Date()) {
|
|
1376
|
+
return new Date(now.getTime() - value * 24 * 60 * 60 * 1_000).toISOString();
|
|
1377
|
+
}
|
|
1378
|
+
function invalidSinceDaysResult() {
|
|
1379
|
+
return {
|
|
1380
|
+
exitCode: 1,
|
|
1381
|
+
stdout: "",
|
|
1382
|
+
stderr: "--since-days must be a whole number between 1 and 365"
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1250
1385
|
function emptyDiscovery(rootPath) {
|
|
1251
1386
|
return {
|
|
1252
1387
|
rootPath,
|
|
@@ -1348,6 +1483,7 @@ function parseArgs(argv) {
|
|
|
1348
1483
|
const next = rest[index + 1];
|
|
1349
1484
|
if (next) {
|
|
1350
1485
|
parsed.path = next;
|
|
1486
|
+
parsed.pathExplicit = true;
|
|
1351
1487
|
index += 1;
|
|
1352
1488
|
}
|
|
1353
1489
|
continue;
|
|
@@ -1644,20 +1780,20 @@ function helpText() {
|
|
|
1644
1780
|
" --ignore-state On the default/quickstart run, ignore persisted spend.json for this run",
|
|
1645
1781
|
" scan [--path <dir>] Scan a local workspace for AI usage signals",
|
|
1646
1782
|
" scan --sample Include deterministic sample spend analysis",
|
|
1647
|
-
" quickstart [--sample]
|
|
1648
|
-
" [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: model",
|
|
1649
|
-
" report [--out <name>]
|
|
1783
|
+
" quickstart [--sample] [--since-days N] Plain-English local readout (default 30 days)",
|
|
1784
|
+
" [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: project for local logs; model otherwise",
|
|
1785
|
+
" report [--out <name>] [--since-days N] Generate local Markdown and HTML reports from the same window",
|
|
1650
1786
|
" report-card [--out f.svg] Write your AI Receipt — a redacted, shareable SVG + caption",
|
|
1651
|
-
" glance [--project <name>] [--plan <id>] Emit the local, machine-readable Glance snapshot JSON",
|
|
1787
|
+
" glance [--project <name>] [--plan <id>] [--since-days N] Emit the local, machine-readable Glance snapshot JSON",
|
|
1652
1788
|
" context [--project <name>] [--since-days N] Show hook-aware Context Health in the terminal",
|
|
1653
1789
|
" [--json] Emit the same canonical Context Health object used by MCP and Glance",
|
|
1654
|
-
" apply
|
|
1790
|
+
" apply [--sample] [--since-days N] Print an evidence-constrained inspection/approval prompt + verification plans",
|
|
1655
1791
|
" apply-artifact Same as `apply` (long form)",
|
|
1656
1792
|
"",
|
|
1657
1793
|
"Cron (production watch): add a crontab entry such as:",
|
|
1658
1794
|
" 0 * * * * cd /path/to/workspace && ai-spend-agent watch --interval 3600 --cycles 1 >> ai-spend-watch.log 2>&1",
|
|
1659
1795
|
"",
|
|
1660
|
-
"Privacy: local
|
|
1796
|
+
"Privacy: local analysis and reports upload nothing. Only explicit sync-provider contacts the selected provider through an env: reference; secrets are never printed or persisted."
|
|
1661
1797
|
].join("\n");
|
|
1662
1798
|
}
|
|
1663
1799
|
// Main-module check that survives npm's bin SYMLINKS: argv[1] is
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-spend-agent",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Local-first financial
|
|
3
|
+
"version": "0.5.9",
|
|
4
|
+
"description": "Local-first financial accountability CLI for Claude Code and Codex work, cost evidence, attribution, runway, provenance, and Context Health.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"prepack": "npm run build"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@agent-finops/core": "0.5.
|
|
58
|
-
"@agent-finops/report": "0.5.
|
|
57
|
+
"@agent-finops/core": "0.5.9",
|
|
58
|
+
"@agent-finops/report": "0.5.9",
|
|
59
59
|
"yocto-spinner": "^1.2.0"
|
|
60
60
|
}
|
|
61
61
|
}
|