ai-spend-agent 0.7.3 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/dist/index.js +522 -133
- package/dist/statuslineRuntime.js +58 -12
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -4,18 +4,38 @@ import { mkdir, readFile, rm, stat } from "node:fs/promises";
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
-
import { analyzeSpend, attributeUsageRecords, buildUsageGlance, buildActivitySnapshot, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, invalidateConnectedSpendTrustReceipt, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, writeSafeStateText, verifyConnectedSpendTrustReceipt, verifyConnectedSourceRegistryTrustReceipt, writeConnectedSpendTrustReceipt, loadDeadContext, sampleDeadContext, latestObservedWorkingDirectory, downgradeSampleUsageEvidence, isBundledSampleUsage, loadLocalAgentUsage, loadLocalAgentFinancialUsage, localAgentFormatDescriptors, localAgentFormatLabel, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, normalizeSourceRegistry, downgradeUntrustedSourceRegistryClaims, buildSourceStatuses, slugifySourceId, financialEvidenceForRecords, formatSourceStatuses, readActivitySnapshot, recordActivitySnapshotRefreshFailure, sourceStatusDefinitions, writeActivitySnapshot } from "@agent-finops/core";
|
|
7
|
+
import { analyzeSpend, attributeUsageRecords, buildUsageGlance, buildActivitySnapshot, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, invalidateConnectedSpendTrustReceipt, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, summarizeProviderFinancials, providerFinancialCompleteness, writeSafeStateText, verifyConnectedSpendTrustReceipt, verifyConnectedSourceRegistryTrustReceipt, writeConnectedSpendTrustReceipt, loadDeadContext, sampleDeadContext, latestObservedWorkingDirectory, downgradeSampleUsageEvidence, isBundledSampleUsage, loadLocalAgentUsage, loadLocalAgentFinancialUsage, localAgentFormatDescriptors, localAgentFormatLabel, localAgentFormatSupports, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createProviderConnection, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, normalizeSourceRegistry, downgradeUntrustedSourceRegistryClaims, buildSourceStatuses, applyProviderContractGate, applyProviderContractGateToSourceRegistry, slugifySourceId, financialEvidenceForRecords, formatSourceStatuses, readActivitySnapshot, recordActivitySnapshotRefreshFailure, sourceStatusDefinitions, writeActivitySnapshot } from "@agent-finops/core";
|
|
8
8
|
import { StatuslineInstallerError, installClaudeStatusline, uninstallClaudeStatusline } from "./statuslineInstaller.js";
|
|
9
9
|
import { readStatuslineCache, renderStatusline } from "./statuslineRuntime.js";
|
|
10
10
|
import { generateActionPlanMarkdown, generateApplyArtifactMarkdown, generateDemoPackageMarkdown, generateHtmlReport, generateMarkdownReport, generatePlainEnglishSummary, generatePolicyConfigDraftMarkdown, generateReportCardCaption, generateReportCardSvg, generateVerificationPlanMarkdown, groupByDimensions } from "@agent-finops/report";
|
|
11
11
|
export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
12
|
-
if (argv.
|
|
12
|
+
if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-v")) {
|
|
13
13
|
return ok(await cliVersion());
|
|
14
14
|
}
|
|
15
15
|
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
16
16
|
return ok(helpText());
|
|
17
17
|
}
|
|
18
18
|
const args = parseArgs(argv);
|
|
19
|
+
if (args.parseErrors.length > 0) {
|
|
20
|
+
return {
|
|
21
|
+
exitCode: 1,
|
|
22
|
+
stdout: "",
|
|
23
|
+
stderr: [
|
|
24
|
+
...args.parseErrors.map((error) => `Invalid arguments: ${sanitizeSecretishError(error)}`),
|
|
25
|
+
"Run `npx aibill --help` to see supported commands and flags."
|
|
26
|
+
].join("\n")
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (args.json && args.command !== "context" && args.command !== "context-health" && args.command !== "glance") {
|
|
30
|
+
return {
|
|
31
|
+
exitCode: 1,
|
|
32
|
+
stdout: "",
|
|
33
|
+
stderr: [
|
|
34
|
+
"--json is not available for the main receipt yet; no text receipt was substituted.",
|
|
35
|
+
"Use `npx aibill context --json` for the canonical Context Health object or `npx aibill glance` for the canonical Glance snapshot."
|
|
36
|
+
].join("\n")
|
|
37
|
+
};
|
|
38
|
+
}
|
|
19
39
|
if (args.groupByInvalid) {
|
|
20
40
|
return {
|
|
21
41
|
exitCode: 1,
|
|
@@ -23,10 +43,8 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
|
23
43
|
stderr: `--group-by needs a dimension: ${groupByDimensions.join("|")}\nexample: npx aibill --group-by project`
|
|
24
44
|
};
|
|
25
45
|
}
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// `--group-by agent`, lands the wow immediately on sample / auto-detected
|
|
29
|
-
// local data — no credential required.
|
|
46
|
+
// Running with no subcommand reads only evidence available on this machine.
|
|
47
|
+
// Illustrative records are reachable only through an explicit --sample.
|
|
30
48
|
if (!args.command || args.command.startsWith("--") || args.command === "quickstart" || args.command === "demo") {
|
|
31
49
|
return quickstartCommand(args);
|
|
32
50
|
}
|
|
@@ -84,14 +102,24 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
|
84
102
|
return {
|
|
85
103
|
exitCode: 1,
|
|
86
104
|
stdout: "",
|
|
87
|
-
stderr: `Unknown command: ${args.command}\n${helpText()}`
|
|
105
|
+
stderr: `Unknown command: ${sanitizeSecretishError(args.command)}\n${helpText()}`
|
|
88
106
|
};
|
|
89
107
|
}
|
|
90
108
|
async function quickstartCommand(args) {
|
|
91
109
|
const sinceDays = args.sinceDays ?? 30;
|
|
92
110
|
if (!validSinceDays(sinceDays))
|
|
93
111
|
return invalidSinceDaysResult();
|
|
112
|
+
if (args.plan && !planOverrideFromFlag(args.plan)) {
|
|
113
|
+
return {
|
|
114
|
+
exitCode: 1,
|
|
115
|
+
stdout: "",
|
|
116
|
+
stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
|
|
117
|
+
};
|
|
118
|
+
}
|
|
94
119
|
const { records, mode, warnings, providerCoverage, codexInvocationFiles } = await loadInstantReadData(args);
|
|
120
|
+
if (records.length === 0) {
|
|
121
|
+
return noEvidenceResult("receipt", warnings, sinceDays);
|
|
122
|
+
}
|
|
95
123
|
const summaryRecords = mode === "connected"
|
|
96
124
|
? selectProviderFinancialHeadlineRecords(records)
|
|
97
125
|
: records;
|
|
@@ -100,6 +128,7 @@ async function quickstartCommand(args) {
|
|
|
100
128
|
// ("which project burns my plan"); demo/connected keep by-model.
|
|
101
129
|
const groupBy = args.groupBy ?? (mode === "local-logs" ? "project" : "model");
|
|
102
130
|
const color = args.noColor ? false : undefined;
|
|
131
|
+
const outputWidth = terminalOutputWidth();
|
|
103
132
|
// Persona: --plan override wins; otherwise read the plans the coding agents
|
|
104
133
|
// themselves persisted locally (read-only, whitelisted fields, no network).
|
|
105
134
|
let detectedPlans;
|
|
@@ -114,7 +143,7 @@ async function quickstartCommand(args) {
|
|
|
114
143
|
return {
|
|
115
144
|
exitCode: 1,
|
|
116
145
|
stdout: "",
|
|
117
|
-
stderr: `Unknown --plan "${args.plan}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
|
|
146
|
+
stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
|
|
118
147
|
};
|
|
119
148
|
}
|
|
120
149
|
detectedPlans = [override];
|
|
@@ -173,12 +202,17 @@ async function quickstartCommand(args) {
|
|
|
173
202
|
nextSteps,
|
|
174
203
|
deadContext,
|
|
175
204
|
detectedPlans,
|
|
205
|
+
width: outputWidth,
|
|
176
206
|
// An explicit --group-by is a drill-down question: answer with just the
|
|
177
207
|
// table + window instead of repeating the whole readout.
|
|
178
|
-
view: args.groupBy ? "breakdown" : "full"
|
|
208
|
+
view: args.groupBy ? "breakdown" : args.full ? "full" : "compact"
|
|
179
209
|
});
|
|
180
|
-
const
|
|
181
|
-
|
|
210
|
+
const detailedView = Boolean(args.groupBy || args.full);
|
|
211
|
+
const header = [
|
|
212
|
+
...(detailedView ? wrapCliHeader(dataModeBanner(mode, summaryRecords), " ", outputWidth) : []),
|
|
213
|
+
...warnings.flatMap((warning) => wrapCliHeader(warning, " ! ", outputWidth))
|
|
214
|
+
].join("\n");
|
|
215
|
+
return ok(header ? `${header}\n${summaryText}` : summaryText);
|
|
182
216
|
}
|
|
183
217
|
async function glanceCommand(args) {
|
|
184
218
|
const sinceDays = args.sinceDays ?? 30;
|
|
@@ -187,12 +221,14 @@ async function glanceCommand(args) {
|
|
|
187
221
|
const logs = await loadLocalAgentUsage({
|
|
188
222
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
189
223
|
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
224
|
+
geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
|
|
190
225
|
sinceIso: sinceIsoForDays(sinceDays),
|
|
191
226
|
collectCodexInvocationEvidence: true
|
|
192
227
|
});
|
|
228
|
+
const glanceCalls = logs.calls.filter((call) => (localAgentFormatSupports(call.agent, "glance")));
|
|
193
229
|
const calls = args.project
|
|
194
|
-
?
|
|
195
|
-
:
|
|
230
|
+
? glanceCalls.filter((call) => call.project === args.project)
|
|
231
|
+
: glanceCalls;
|
|
196
232
|
const latestWorkingDirectory = latestObservedWorkingDirectory(calls);
|
|
197
233
|
const contextProjectDir = args.pathExplicit
|
|
198
234
|
? resolve(args.path)
|
|
@@ -204,7 +240,7 @@ async function glanceCommand(args) {
|
|
|
204
240
|
return {
|
|
205
241
|
exitCode: 1,
|
|
206
242
|
stdout: "",
|
|
207
|
-
stderr: `Unknown --plan "${args.plan}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
|
|
243
|
+
stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
|
|
208
244
|
};
|
|
209
245
|
}
|
|
210
246
|
detectedPlans = [override];
|
|
@@ -228,10 +264,12 @@ async function glanceCommand(args) {
|
|
|
228
264
|
codexInvocationFiles: logs.codexInvocationFiles
|
|
229
265
|
});
|
|
230
266
|
const snapshot = buildUsageGlance(calls, {
|
|
231
|
-
filesParsed: logs.
|
|
232
|
-
|
|
267
|
+
filesParsed: logs.sourceScans
|
|
268
|
+
.filter((scan) => localAgentFormatSupports(scan.agent, "glance"))
|
|
269
|
+
.reduce((total, scan) => total + scan.filesParsed, 0),
|
|
270
|
+
detectedAgents: logs.agentsDetected.filter((agent) => (localAgentFormatSupports(agent, "glance"))),
|
|
233
271
|
detectedPlans,
|
|
234
|
-
limitCalls:
|
|
272
|
+
limitCalls: glanceCalls,
|
|
235
273
|
contextHealth
|
|
236
274
|
});
|
|
237
275
|
return ok(JSON.stringify(snapshot));
|
|
@@ -244,12 +282,14 @@ async function contextHealthCommand(args) {
|
|
|
244
282
|
const logs = await loadLocalAgentUsage({
|
|
245
283
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
246
284
|
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
285
|
+
geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
|
|
247
286
|
sinceIso,
|
|
248
287
|
collectCodexInvocationEvidence: true
|
|
249
288
|
});
|
|
289
|
+
const contextCalls = logs.calls.filter((call) => (localAgentFormatSupports(call.agent, "contextHealth")));
|
|
250
290
|
const calls = args.project
|
|
251
|
-
?
|
|
252
|
-
:
|
|
291
|
+
? contextCalls.filter((call) => call.project === args.project)
|
|
292
|
+
: contextCalls;
|
|
253
293
|
const health = await loadContextHealth(calls, {
|
|
254
294
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
255
295
|
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
@@ -320,13 +360,13 @@ function quickstartNextSteps(mode, detected) {
|
|
|
320
360
|
if (detected.length > 0) {
|
|
321
361
|
const names = detected.map((credential) => `${credential.provider} (${credential.hint})`).join(", ");
|
|
322
362
|
steps.push(`Found local key${detected.length === 1 ? "" : "s"}: ${names}`);
|
|
323
|
-
steps.push(`npx aibill connect ${detected[0].provider}
|
|
363
|
+
steps.push(`npx aibill connect ${detected[0].provider} set up the admin connector, then sync provider-reported cost`);
|
|
324
364
|
}
|
|
325
365
|
steps.push(mode === "demo"
|
|
326
366
|
? "npx aibill report --sample write a clearly labeled demo Markdown + HTML report"
|
|
327
367
|
: "npx aibill report write a shareable Markdown + HTML report");
|
|
328
368
|
steps.push("npx aibill --group-by project see which project has the most observed activity");
|
|
329
|
-
steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://
|
|
369
|
+
steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://asktilden.com");
|
|
330
370
|
return steps;
|
|
331
371
|
}
|
|
332
372
|
async function readPersistedSpend(rootPath, options = {}) {
|
|
@@ -343,9 +383,12 @@ async function readPersistedSpend(rootPath, options = {}) {
|
|
|
343
383
|
// tag. A copied/tampered sample must never become connected billing merely
|
|
344
384
|
// because `mode` was changed in JSON.
|
|
345
385
|
const mode = isBundledSampleUsage(parsedRecords) ? "sample" : storedMode;
|
|
346
|
-
const
|
|
386
|
+
const parsedModeRecords = mode === "sample" || mode === undefined
|
|
347
387
|
? downgradeSampleUsageEvidence(parsedRecords)
|
|
348
388
|
: parsedRecords;
|
|
389
|
+
const records = mode === "connected_provider"
|
|
390
|
+
? applyProviderContractGate(parsedModeRecords)
|
|
391
|
+
: parsedModeRecords;
|
|
349
392
|
if (spend.checkedAt !== undefined && !validIsoString(spend.checkedAt)) {
|
|
350
393
|
throw new Error("persisted spend checkedAt must be an ISO timestamp");
|
|
351
394
|
}
|
|
@@ -410,7 +453,7 @@ async function loadInstantReadData(args) {
|
|
|
410
453
|
looksConnected &&
|
|
411
454
|
persisted.connectedTrust?.trusted === true) {
|
|
412
455
|
return {
|
|
413
|
-
records: persisted.records,
|
|
456
|
+
records: applyProviderContractGate(persisted.records),
|
|
414
457
|
mode: "connected",
|
|
415
458
|
warnings,
|
|
416
459
|
...(persisted.providerCoverage ? { providerCoverage: persisted.providerCoverage } : {})
|
|
@@ -420,13 +463,22 @@ async function loadInstantReadData(args) {
|
|
|
420
463
|
warnings.push(`${persisted.connectedTrust.message} CLI: run \`npx aibill connect <provider>\` or repeat the prior \`npx aibill sync-provider ...\` command. The repository-provided connected totals were ignored.`);
|
|
421
464
|
}
|
|
422
465
|
// Real local agent logs (Claude Code / Codex) beat any sample/legacy state.
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
466
|
+
let logs;
|
|
467
|
+
try {
|
|
468
|
+
logs = await loadLocalAgentUsage({
|
|
469
|
+
// Env overrides keep tests (and unusual installs) isolated from $HOME.
|
|
470
|
+
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
471
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
472
|
+
geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
|
|
473
|
+
sinceIso: sinceIsoForDays(args.sinceDays ?? 30),
|
|
474
|
+
collectCodexInvocationEvidence: true
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// A failed read is not the same as an honest empty scan. Keep the compact
|
|
479
|
+
// result useful without leaking a local path or raw parser exception.
|
|
480
|
+
warnings.push("Some local agent evidence could not be read; coverage is incomplete.");
|
|
481
|
+
}
|
|
430
482
|
if (logs && logs.records.length > 0) {
|
|
431
483
|
// Persisted local_logs state (written by report/apply-artifact) is the
|
|
432
484
|
// same data source we just re-read — superseding it silently is correct,
|
|
@@ -441,25 +493,99 @@ async function loadInstantReadData(args) {
|
|
|
441
493
|
codexInvocationFiles: logs.codexInvocationFiles
|
|
442
494
|
};
|
|
443
495
|
}
|
|
444
|
-
|
|
445
|
-
|
|
496
|
+
const geminiPresence = logs?.sourceScans.find((scan) => scan.agent === "gemini-cli");
|
|
497
|
+
if (geminiPresence && ((geminiPresence.detectionSignals ?? 0) > 0 || geminiPresence.filesDiscovered > 0)) {
|
|
498
|
+
warnings.push("Gemini CLI was detected, but no supported chats JSON/JSONL financial evidence was found. No financial rows were created; logs.json is presence-only evidence. Need this coverage? +1 or contribute a synthetic fixture: https://github.com/futurastudio/ai-spend-agent/issues/new?template=provider_or_agent.yml");
|
|
499
|
+
return {
|
|
500
|
+
records: [],
|
|
501
|
+
mode: "local-logs",
|
|
502
|
+
warnings,
|
|
503
|
+
codexInvocationFiles: logs?.codexInvocationFiles
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
// No real logs. Sample and legacy persisted state must never appear unless
|
|
507
|
+
// this invocation explicitly opted into --sample.
|
|
446
508
|
if (persisted && persisted.records.length > 0 && persisted.mode !== "connected_provider" && persisted.mode !== "local_logs") {
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
return { records: persisted.records, mode: "demo", warnings };
|
|
509
|
+
warnings.push(persisted.mode === "sample"
|
|
510
|
+
? "Sample state exists but was not displayed because this run did not include --sample."
|
|
511
|
+
: "Legacy state with no trustworthy data-mode label was ignored. Run `npx aibill reset`, then collect fresh evidence.");
|
|
451
512
|
}
|
|
452
513
|
if (persisted?.mode === "local_logs") {
|
|
453
514
|
warnings.push("Ignored persisted local-log cache because no current Claude Code/Codex source records were found. Re-run the local agent activity first; repository state alone cannot authorize an Apply action.");
|
|
454
515
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
516
|
+
return { records: [], mode: "local-logs", warnings };
|
|
517
|
+
}
|
|
518
|
+
function noEvidenceResult(surface, warnings, sinceDays) {
|
|
519
|
+
const surfaceLine = surface === "watch"
|
|
520
|
+
? "Watch has no financial baseline yet; no zero total or sample activity was recorded."
|
|
521
|
+
: surface === "report-card"
|
|
522
|
+
? "No receipt was written because there is no supported financial evidence to summarize."
|
|
523
|
+
: `No supported AI usage evidence was found in the last ${sinceDays} days.`;
|
|
524
|
+
return {
|
|
525
|
+
exitCode: surface === "receipt" ? 0 : 1,
|
|
526
|
+
stdout: surface === "receipt"
|
|
527
|
+
? [
|
|
528
|
+
"aibill · local only",
|
|
529
|
+
"",
|
|
530
|
+
surfaceLine,
|
|
531
|
+
"Looked for: Claude Code, Codex, and Gemini CLI local history.",
|
|
532
|
+
"Nothing was uploaded. No sample data was substituted.",
|
|
533
|
+
...warnings.map((warning) => `! ${warning}`),
|
|
534
|
+
"",
|
|
535
|
+
"Next",
|
|
536
|
+
" npx aibill doctor --sources see the exact evidence gap and setup paths"
|
|
537
|
+
].join("\n")
|
|
538
|
+
: "",
|
|
539
|
+
stderr: surface === "receipt"
|
|
540
|
+
? ""
|
|
541
|
+
: [
|
|
542
|
+
surfaceLine,
|
|
543
|
+
"Run `npx aibill doctor --sources` to see the exact evidence gap.",
|
|
544
|
+
"Local evidence: use Claude Code, Codex, or Gemini CLI normally, then retry.",
|
|
545
|
+
"Provider billing: connect openai, anthropic, cursor, or github-copilot with an admin credential reference.",
|
|
546
|
+
"For illustrative output only, rerun this command with --sample."
|
|
547
|
+
].join("\n")
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function terminalOutputWidth() {
|
|
551
|
+
const envColumns = Number(process.env.COLUMNS);
|
|
552
|
+
const ttyColumns = process.stdout.columns;
|
|
553
|
+
const requested = Number.isFinite(envColumns) && envColumns > 0
|
|
554
|
+
? envColumns
|
|
555
|
+
: Number.isFinite(ttyColumns) && (ttyColumns ?? 0) > 0
|
|
556
|
+
? ttyColumns
|
|
557
|
+
: 72;
|
|
558
|
+
return Math.max(40, Math.min(120, Math.floor(requested)));
|
|
559
|
+
}
|
|
560
|
+
function wrapCliHeader(text, prefix, width) {
|
|
561
|
+
const available = Math.max(12, width - prefix.length);
|
|
562
|
+
const words = text.trim().split(/\s+/u);
|
|
563
|
+
const lines = [];
|
|
564
|
+
let current = "";
|
|
565
|
+
for (const word of words) {
|
|
566
|
+
if (!current) {
|
|
567
|
+
current = word;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (`${current} ${word}`.length <= available) {
|
|
571
|
+
current += ` ${word}`;
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
lines.push(current);
|
|
575
|
+
current = word;
|
|
576
|
+
}
|
|
577
|
+
if (current)
|
|
578
|
+
lines.push(current);
|
|
579
|
+
const continuationPrefix = " ".repeat(prefix.length);
|
|
580
|
+
return lines.map((line, index) => `${index === 0 ? prefix : continuationPrefix}${line}`);
|
|
458
581
|
}
|
|
459
582
|
/** A one-line, unmissable banner telling the user which data they're seeing. */
|
|
460
|
-
function dataModeBanner(mode) {
|
|
461
|
-
if (mode === "local-logs")
|
|
462
|
-
return
|
|
583
|
+
function dataModeBanner(mode, records) {
|
|
584
|
+
if (mode === "local-logs") {
|
|
585
|
+
return records.some((record) => typeof record.amountUsd === "number")
|
|
586
|
+
? "DATA MODE: your local agent logs (estimated at API-equivalent rates)"
|
|
587
|
+
: "DATA MODE: local agent evidence (financial value unavailable; no demo sample substituted)";
|
|
588
|
+
}
|
|
463
589
|
if (mode === "connected")
|
|
464
590
|
return "DATA MODE: connected provider billing";
|
|
465
591
|
return "DATA MODE: demo sample (illustrative — not your real spend)";
|
|
@@ -479,12 +605,18 @@ async function doctorCommand(args) {
|
|
|
479
605
|
: "no state";
|
|
480
606
|
const logs = await loadLocalAgentUsage({
|
|
481
607
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
482
|
-
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
|
|
608
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
609
|
+
geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR
|
|
483
610
|
}).catch(() => undefined);
|
|
484
611
|
const detected = logs?.agentsDetected ?? [];
|
|
485
612
|
const claudeFound = detected.includes("claude-code");
|
|
486
613
|
const codexFound = detected.includes("codex");
|
|
487
|
-
const
|
|
614
|
+
const geminiScan = logs?.sourceScans.find((scan) => scan.agent === "gemini-cli");
|
|
615
|
+
const geminiFinancialFound = detected.includes("gemini-cli");
|
|
616
|
+
const geminiPresenceFound = Boolean(geminiScan && ((geminiScan.detectionSignals ?? 0) > 0 || geminiScan.filesDiscovered > 0));
|
|
617
|
+
const geminiFound = geminiFinancialFound || geminiPresenceFound;
|
|
618
|
+
const hasFinancialLogs = claudeFound || codexFound || geminiFinancialFound;
|
|
619
|
+
const hasLocalSource = hasFinancialLogs || geminiPresenceFound;
|
|
488
620
|
const detection = await detectLocalCredentials({
|
|
489
621
|
cwd: rootPath,
|
|
490
622
|
home: process.env.AI_SPEND_CLAUDE_HOME_DIR
|
|
@@ -505,15 +637,20 @@ async function doctorCommand(args) {
|
|
|
505
637
|
if (persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === false) {
|
|
506
638
|
warnings.push(`${persisted.connectedTrust.message} Run \`npx aibill connect <provider>\` or repeat the prior \`npx aibill sync-provider ...\` command.`);
|
|
507
639
|
}
|
|
508
|
-
if (!
|
|
509
|
-
warnings.push("no
|
|
640
|
+
if (geminiPresenceFound && !geminiFinancialFound) {
|
|
641
|
+
warnings.push("Gemini CLI detected, but no supported chats JSON/JSONL financial evidence was found. No financial rows were created; logs.json is presence-only evidence. Need this coverage? +1 or contribute a synthetic fixture: https://github.com/futurastudio/ai-spend-agent/issues/new?template=provider_or_agent.yml");
|
|
642
|
+
}
|
|
643
|
+
if (!hasLocalSource)
|
|
644
|
+
warnings.push("no supported Claude Code, Codex, or Gemini CLI session evidence found — the default receipt will remain empty; use --sample only for the labeled demo");
|
|
510
645
|
if (providerRefs.length === 0)
|
|
511
|
-
warnings.push("no provider admin keys detected —
|
|
646
|
+
warnings.push("no provider admin keys detected — set up and sync an OpenAI/Anthropic admin connector for provider-reported cost (local logs stay API-equivalent estimates)");
|
|
512
647
|
const predictedMode = connectedStateTrusted
|
|
513
648
|
? "connected provider billing"
|
|
514
|
-
:
|
|
649
|
+
: hasFinancialLogs
|
|
515
650
|
? "your local agent logs (estimated at API-equivalent rates)"
|
|
516
|
-
:
|
|
651
|
+
: geminiPresenceFound
|
|
652
|
+
? "local Gemini CLI presence only (financial evidence unavailable; no sample substituted)"
|
|
653
|
+
: "no supported evidence yet (no sample substituted)";
|
|
517
654
|
const lines = [
|
|
518
655
|
"aibill doctor",
|
|
519
656
|
`node version: ${process.version}`,
|
|
@@ -524,6 +661,11 @@ async function doctorCommand(args) {
|
|
|
524
661
|
`state mode: ${stateMode}`,
|
|
525
662
|
`Claude Code logs: ${claudeFound ? "found" : "not found"}`,
|
|
526
663
|
`Codex logs: ${codexFound ? "found" : "not found"}`,
|
|
664
|
+
`Gemini CLI sessions: ${geminiFinancialFound
|
|
665
|
+
? "found"
|
|
666
|
+
: geminiPresenceFound
|
|
667
|
+
? "detected, but no supported chats financial rows found"
|
|
668
|
+
: "not found"}`,
|
|
527
669
|
`provider env references: ${providerRefs.length > 0 ? providerRefs.join(", ") : "none detected"}`,
|
|
528
670
|
`subscription plans: ${planLine}`,
|
|
529
671
|
"redaction policy: secrets are never printed or persisted",
|
|
@@ -549,7 +691,8 @@ async function doctorSourcesCommand(args) {
|
|
|
549
691
|
try {
|
|
550
692
|
localLogs = await loadLocalAgentUsage({
|
|
551
693
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
552
|
-
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
|
|
694
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
695
|
+
geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR
|
|
553
696
|
});
|
|
554
697
|
}
|
|
555
698
|
catch (error) {
|
|
@@ -659,14 +802,18 @@ function localFinancialEvidenceNote(records, evidence, scan) {
|
|
|
659
802
|
if (scan.directoryStatus === "unreadable") {
|
|
660
803
|
return "The local transcript path could not be read; absence of usage cannot be confirmed.";
|
|
661
804
|
}
|
|
805
|
+
if (scan.agent === "gemini-cli" && (scan.detectionSignals ?? 0) > 0 &&
|
|
806
|
+
scan.filesDiscovered === 0) {
|
|
807
|
+
return "Gemini CLI detected, but no supported chats JSON/JSONL financial evidence was found. logs.json is presence-only evidence; zero financial rows were created. Need this coverage? +1 or contribute a synthetic fixture: https://github.com/futurastudio/ai-spend-agent/issues/new?template=provider_or_agent.yml";
|
|
808
|
+
}
|
|
662
809
|
if (scan.filesDiscovered === 0) {
|
|
663
|
-
return "The local transcript directory was readable, but no
|
|
810
|
+
return "The local transcript directory was readable, but no supported session files were found.";
|
|
664
811
|
}
|
|
665
812
|
if (scan.unreadableFiles > 0) {
|
|
666
813
|
return `${scan.filesDiscovered} transcript file(s) were found, but ${scan.unreadableFiles} could not be read; absence of usage cannot be confirmed.`;
|
|
667
814
|
}
|
|
668
815
|
if (scan.malformedLines > 0) {
|
|
669
|
-
return `${scan.filesDiscovered} transcript file(s) were found, but no valid usage rows were parsed; ${scan.malformedLines} malformed
|
|
816
|
+
return `${scan.filesDiscovered} transcript file(s) were found, but no valid usage rows were parsed; ${scan.malformedLines} malformed session record(s) were skipped.`;
|
|
670
817
|
}
|
|
671
818
|
return `${scan.filesDiscovered} transcript file(s) were found, but no supported usage rows were observed.`;
|
|
672
819
|
}
|
|
@@ -697,9 +844,13 @@ function localFinancialEvidenceNote(records, evidence, scan) {
|
|
|
697
844
|
function localAgentDiagnosticSummary(diagnostics) {
|
|
698
845
|
const relevant = diagnostics.filter((diagnostic) => diagnostic.code !== "directory_missing");
|
|
699
846
|
const unsupported = relevant.filter((diagnostic) => diagnostic.code === "unsupported_token_shape");
|
|
700
|
-
const malformed = relevant.filter((diagnostic) => diagnostic.code === "malformed_jsonl");
|
|
847
|
+
const malformed = relevant.filter((diagnostic) => (diagnostic.code === "malformed_jsonl" || diagnostic.code === "malformed_session_file"));
|
|
701
848
|
const messages = [...new Set(relevant
|
|
702
|
-
.filter((diagnostic) => ![
|
|
849
|
+
.filter((diagnostic) => ![
|
|
850
|
+
"unsupported_token_shape",
|
|
851
|
+
"malformed_jsonl",
|
|
852
|
+
"malformed_session_file"
|
|
853
|
+
].includes(diagnostic.code))
|
|
703
854
|
.map((diagnostic) => diagnostic.message))];
|
|
704
855
|
const unsupportedCount = unsupported
|
|
705
856
|
.reduce((total, diagnostic) => total + diagnostic.count, 0);
|
|
@@ -709,7 +860,7 @@ function localAgentDiagnosticSummary(diagnostics) {
|
|
|
709
860
|
const malformedCount = malformed
|
|
710
861
|
.reduce((total, diagnostic) => total + diagnostic.count, 0);
|
|
711
862
|
if (malformedCount > 0) {
|
|
712
|
-
messages.push(`${malformedCount} malformed
|
|
863
|
+
messages.push(`${malformedCount} malformed session record(s) were skipped in ${localAgentFormatLabel(malformed[0].agent)} transcripts.`);
|
|
713
864
|
}
|
|
714
865
|
return messages.length > 0 ? messages.join(" ") : undefined;
|
|
715
866
|
}
|
|
@@ -981,7 +1132,7 @@ async function resetCommand(args) {
|
|
|
981
1132
|
"aibill reset",
|
|
982
1133
|
`path: ${rootPath}`,
|
|
983
1134
|
"nothing to clear (no persisted spend state found)",
|
|
984
|
-
"next run will re-read your real local agent logs
|
|
1135
|
+
"next run will re-read your real local agent logs; no sample is substituted without --sample."
|
|
985
1136
|
].join("\n"));
|
|
986
1137
|
}
|
|
987
1138
|
// Clear derived spend state so a prior `scan --sample` (or stale provider
|
|
@@ -1001,7 +1152,7 @@ async function resetCommand(args) {
|
|
|
1001
1152
|
"aibill reset",
|
|
1002
1153
|
`path: ${rootPath}`,
|
|
1003
1154
|
removed.length > 0 ? `cleared: ${removed.join(", ")}` : "nothing to clear (no persisted spend state found)",
|
|
1004
|
-
"next run will re-read your real local agent logs
|
|
1155
|
+
"next run will re-read your real local agent logs; no sample is substituted without --sample."
|
|
1005
1156
|
].join("\n"));
|
|
1006
1157
|
}
|
|
1007
1158
|
async function statuslineCommand(args, runtime) {
|
|
@@ -1117,7 +1268,7 @@ async function initCommand(args, runtime = {}) {
|
|
|
1117
1268
|
return {
|
|
1118
1269
|
exitCode: 1,
|
|
1119
1270
|
stdout: "",
|
|
1120
|
-
stderr: `Unknown --plan "${args.plan}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
|
|
1271
|
+
stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
|
|
1121
1272
|
};
|
|
1122
1273
|
}
|
|
1123
1274
|
detectedPlanOverride = [override];
|
|
@@ -1210,6 +1361,7 @@ async function collectAndPublishActivitySnapshot(input) {
|
|
|
1210
1361
|
logs = await loadLocalAgentFinancialUsage({
|
|
1211
1362
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
1212
1363
|
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
1364
|
+
geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
|
|
1213
1365
|
sinceIso: sinceIsoForDays(30, asOf)
|
|
1214
1366
|
});
|
|
1215
1367
|
}
|
|
@@ -1222,13 +1374,14 @@ async function collectAndPublishActivitySnapshot(input) {
|
|
|
1222
1374
|
const persisted = persistedResult.persisted;
|
|
1223
1375
|
const trustedProviderRecords = persisted?.mode === "connected_provider" &&
|
|
1224
1376
|
persisted.connectedTrust?.trusted === true
|
|
1225
|
-
? selectProviderFinancialHeadlineRecords(persisted.records)
|
|
1377
|
+
? selectProviderFinancialHeadlineRecords(applyProviderContractGate(persisted.records))
|
|
1226
1378
|
: [];
|
|
1227
1379
|
let activitySnapshot;
|
|
1228
1380
|
let cacheStatus;
|
|
1381
|
+
const statuslineSourceScans = logs?.sourceScans.filter((scan) => (localAgentFormatSupports(scan.agent, "statuslineSnapshot"))) ?? [];
|
|
1229
1382
|
const structuredSourceFailure = logs !== undefined &&
|
|
1230
|
-
|
|
1231
|
-
!
|
|
1383
|
+
statuslineSourceScans.some((scan) => scan.directoryStatus === "unreadable") &&
|
|
1384
|
+
!statuslineSourceScans.some((scan) => scan.directoryStatus === "readable");
|
|
1232
1385
|
if (logs && !structuredSourceFailure) {
|
|
1233
1386
|
let refreshErrorCode = "invalid_evidence";
|
|
1234
1387
|
try {
|
|
@@ -1298,7 +1451,7 @@ async function refreshStatuslineCommand(args, runtime) {
|
|
|
1298
1451
|
return {
|
|
1299
1452
|
exitCode: 1,
|
|
1300
1453
|
stdout: "",
|
|
1301
|
-
stderr: `Unknown --plan "${args.plan}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
|
|
1454
|
+
stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
|
|
1302
1455
|
};
|
|
1303
1456
|
}
|
|
1304
1457
|
detectedPlanOverride = [override];
|
|
@@ -1516,13 +1669,18 @@ function activitySnapshotProvider(provider) {
|
|
|
1516
1669
|
function formatInitReceipt(input) {
|
|
1517
1670
|
const records = input.logs?.records ?? [];
|
|
1518
1671
|
const pricedRecords = records.filter((record) => typeof record.amountUsd === "number");
|
|
1672
|
+
const registryOnlyLines = initRegistryOnlyFinancialLines(records);
|
|
1519
1673
|
const sourceFailures = (input.logs?.sourceScans ?? []).some((scan) => scan.directoryStatus === "unreadable") ||
|
|
1520
1674
|
(input.logs?.diagnostics ?? []).some((diagnostic) => diagnostic.severity === "error");
|
|
1521
|
-
const
|
|
1675
|
+
const snapshotLines = input.scanError || sourceFailures && records.length === 0
|
|
1522
1676
|
? ["API-equivalent usage value: unavailable — the local scan could not prove an empty result"]
|
|
1523
1677
|
: input.activitySnapshot
|
|
1524
1678
|
? initApiEquivalentWindowLines(input.activitySnapshot)
|
|
1525
1679
|
: ["API-equivalent usage value: unavailable — no snapshot was produced"];
|
|
1680
|
+
const receiptLines = registryOnlyLines.length > 0 &&
|
|
1681
|
+
input.activitySnapshot?.mode === "empty" && !input.scanError
|
|
1682
|
+
? registryOnlyLines
|
|
1683
|
+
: [...snapshotLines, ...registryOnlyLines];
|
|
1526
1684
|
const planLine = input.detectedPlans.length > 0
|
|
1527
1685
|
? input.detectedPlans.map((plan) => {
|
|
1528
1686
|
const known = plan.planId ?? "unrecognized plan";
|
|
@@ -1536,7 +1694,10 @@ function formatInitReceipt(input) {
|
|
|
1536
1694
|
const validation = scan.jsonlValidationCoverage === "financial_events_only"
|
|
1537
1695
|
? "; financial-event JSONL validation only"
|
|
1538
1696
|
: "";
|
|
1539
|
-
|
|
1697
|
+
const detection = (scan.detectionSignals ?? 0) > 0
|
|
1698
|
+
? `; ${scan.detectionSignals} presence-only signal(s)`
|
|
1699
|
+
: "";
|
|
1700
|
+
return ` ${scan.agent}: ${scan.directoryStatus}; ${scan.filesParsed}/${scan.filesDiscovered} files parsed; ${priced}/${agentRecords.length} rows priced${detection}${skipped > 0 ? `; ${skipped} old files skipped` : ""}${validation}`;
|
|
1540
1701
|
});
|
|
1541
1702
|
const diagnosticLines = (input.logs?.diagnostics ?? [])
|
|
1542
1703
|
.filter((diagnostic) => diagnostic.code !== "directory_missing")
|
|
@@ -1545,7 +1706,7 @@ function formatInitReceipt(input) {
|
|
|
1545
1706
|
return [
|
|
1546
1707
|
"aibill init",
|
|
1547
1708
|
`state project: ${sanitizeSecretishError(basename(input.rootPath))}`,
|
|
1548
|
-
"local usage scope:
|
|
1709
|
+
"local usage scope: supported Claude Code, Codex, and Gemini CLI financial evidence on this machine (last 30 days)",
|
|
1549
1710
|
"provider scope: trusted connected billing from this state project only (shown separately)",
|
|
1550
1711
|
"",
|
|
1551
1712
|
"FIRST RECEIPT · API-equivalent usage value · last 30 days",
|
|
@@ -1565,6 +1726,25 @@ function formatInitReceipt(input) {
|
|
|
1565
1726
|
"next: npx aibill doctor --sources"
|
|
1566
1727
|
].filter((line) => line !== "").join("\n");
|
|
1567
1728
|
}
|
|
1729
|
+
function initRegistryOnlyFinancialLines(records) {
|
|
1730
|
+
const lines = [];
|
|
1731
|
+
for (const descriptor of localAgentFormatDescriptors) {
|
|
1732
|
+
if (descriptor.capabilities.statuslineSnapshot)
|
|
1733
|
+
continue;
|
|
1734
|
+
const sourceRecords = records.filter((record) => record.agentId === descriptor.id);
|
|
1735
|
+
if (sourceRecords.length === 0)
|
|
1736
|
+
continue;
|
|
1737
|
+
const priced = sourceRecords.filter((record) => typeof record.amountUsd === "number");
|
|
1738
|
+
if (priced.length === 0) {
|
|
1739
|
+
lines.push(`${descriptor.id} API-equivalent value: unavailable — ${sourceRecords.length} observed row(s) lacked complete token components or a supported model price`);
|
|
1740
|
+
continue;
|
|
1741
|
+
}
|
|
1742
|
+
const amount = analyzeSpend(priced).totalUsd;
|
|
1743
|
+
const unpriced = sourceRecords.length - priced.length;
|
|
1744
|
+
lines.push(`${descriptor.id} experimental value: ~${formatOptionalUsd(amount)} 30d (API-equivalent; fixture-verified; not billed spend${unpriced > 0 ? `; ${unpriced} row(s) unpriced` : ""})`);
|
|
1745
|
+
}
|
|
1746
|
+
return lines;
|
|
1747
|
+
}
|
|
1568
1748
|
function formatInitProviderEvidence(input) {
|
|
1569
1749
|
const windowStart = input.asOf.getTime() - 30 * 24 * 60 * 60 * 1_000;
|
|
1570
1750
|
const inWindow = input.trustedProviderRecords.filter((record) => {
|
|
@@ -1629,7 +1809,7 @@ function initApiEquivalentWindowLines(snapshot) {
|
|
|
1629
1809
|
agent.unsupportedUsageSnapshots === 0 &&
|
|
1630
1810
|
agent.jsonlValidationCoverage === "complete");
|
|
1631
1811
|
return [completeZero
|
|
1632
|
-
? "API-equivalent value:
|
|
1812
|
+
? "API-equivalent usage value: unavailable — no priced evidence was observed in readable local sources"
|
|
1633
1813
|
: "API-equivalent usage value: unavailable — local source coverage is incomplete; no zero total was inferred"];
|
|
1634
1814
|
}
|
|
1635
1815
|
if (snapshot.mode === "unresolved" && snapshot.unresolved) {
|
|
@@ -1749,7 +1929,7 @@ async function scanCommand(args) {
|
|
|
1749
1929
|
const mappings = attributeUsageRecords(records);
|
|
1750
1930
|
await writeLocalSpendState(stateDir, records, summary, mappings, "sample");
|
|
1751
1931
|
lines.push(`sample records: ${records.length}`);
|
|
1752
|
-
lines.push(`total spend:
|
|
1932
|
+
lines.push(`total spend: ${formatOptionalUsd(summary.totalUsd)}`);
|
|
1753
1933
|
lines.push(`attribution mappings: ${mappings.length}`);
|
|
1754
1934
|
}
|
|
1755
1935
|
if (discovery.signals.length > 0) {
|
|
@@ -1781,6 +1961,9 @@ async function watchCommand(args) {
|
|
|
1781
1961
|
while (unbounded || iteration < cycles) {
|
|
1782
1962
|
const previous = await readOptionalJson(join(stateDir, "watch-latest.json"), null);
|
|
1783
1963
|
const { summary, snapshot, records, mode } = await runWatchCycle(stateDir, args);
|
|
1964
|
+
if (records.length === 0) {
|
|
1965
|
+
return noEvidenceResult("watch", [], args.sinceDays ?? 30);
|
|
1966
|
+
}
|
|
1784
1967
|
const deltaHeadline = buildDeltaHeadline(previous, snapshot);
|
|
1785
1968
|
// Watch's job is DELTAS: render the compact breakdown view per cycle, not
|
|
1786
1969
|
// the whole diagnose→verify readout again (the quickstart owns that).
|
|
@@ -1789,6 +1972,7 @@ async function watchCommand(args) {
|
|
|
1789
1972
|
groupBy: args.groupBy ?? "model",
|
|
1790
1973
|
color: args.noColor ? false : undefined,
|
|
1791
1974
|
mode: mode === "sample" ? "demo" : mode === "connected_provider" ? "connected" : "local-logs",
|
|
1975
|
+
width: terminalOutputWidth(),
|
|
1792
1976
|
view: "breakdown"
|
|
1793
1977
|
});
|
|
1794
1978
|
const stamped = [
|
|
@@ -1836,7 +2020,7 @@ async function runWatchCycle(stateDir, args) {
|
|
|
1836
2020
|
// Watch may observe an already trusted provider snapshot, but it may not
|
|
1837
2021
|
// mint trust from repository-authored provider-records.json or rewrite
|
|
1838
2022
|
// connected state. Only an explicit provider sync can do that.
|
|
1839
|
-
records = persisted.records;
|
|
2023
|
+
records = applyProviderContractGate(persisted.records);
|
|
1840
2024
|
mode = "connected_provider";
|
|
1841
2025
|
}
|
|
1842
2026
|
else {
|
|
@@ -1844,31 +2028,35 @@ async function runWatchCycle(stateDir, args) {
|
|
|
1844
2028
|
// never serve a stale snapshot.
|
|
1845
2029
|
const logs = await loadLocalAgentUsage({
|
|
1846
2030
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
1847
|
-
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
|
|
2031
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
2032
|
+
geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR
|
|
1848
2033
|
}).catch(() => undefined);
|
|
1849
2034
|
if (logs && logs.records.length > 0) {
|
|
1850
2035
|
records = logs.records;
|
|
1851
2036
|
mode = "local_logs";
|
|
1852
2037
|
}
|
|
1853
2038
|
else {
|
|
1854
|
-
records =
|
|
1855
|
-
mode = "
|
|
2039
|
+
records = [];
|
|
2040
|
+
mode = "local_logs";
|
|
1856
2041
|
}
|
|
1857
2042
|
}
|
|
1858
2043
|
}
|
|
1859
2044
|
const headlineRecords = mode === "connected_provider"
|
|
1860
|
-
? selectProviderFinancialHeadlineRecords(records)
|
|
2045
|
+
? selectProviderFinancialHeadlineRecords(applyProviderContractGate(records))
|
|
1861
2046
|
: records;
|
|
1862
2047
|
const summary = analyzeSpend(headlineRecords);
|
|
2048
|
+
const financialAmountAvailable = headlineRecords.some((record) => typeof record.amountUsd === "number");
|
|
1863
2049
|
const mappings = attributeUsageRecords(records);
|
|
1864
2050
|
if (mode !== "connected_provider") {
|
|
1865
2051
|
await writeLocalSpendState(stateDir, records, summary, mappings, mode);
|
|
1866
2052
|
}
|
|
1867
2053
|
const snapshot = {
|
|
1868
2054
|
capturedAt: new Date().toISOString(),
|
|
1869
|
-
totalUsd: summary.totalUsd,
|
|
2055
|
+
totalUsd: financialAmountAvailable ? summary.totalUsd : null,
|
|
1870
2056
|
recordCount: summary.recordCount,
|
|
1871
|
-
byModel:
|
|
2057
|
+
byModel: financialAmountAvailable
|
|
2058
|
+
? summary.byModel.map((entry) => ({ key: entry.key, amountUsd: entry.amountUsd }))
|
|
2059
|
+
: []
|
|
1872
2060
|
};
|
|
1873
2061
|
// Append to the rolling history and persist the latest snapshot for the next run.
|
|
1874
2062
|
const history = await readOptionalJson(join(stateDir, "watch-history.json"), []);
|
|
@@ -1878,24 +2066,35 @@ async function runWatchCycle(stateDir, args) {
|
|
|
1878
2066
|
timestamp: snapshot.capturedAt,
|
|
1879
2067
|
action: "scan_completed",
|
|
1880
2068
|
sourceId: "watch",
|
|
1881
|
-
detail:
|
|
2069
|
+
detail: snapshot.totalUsd === null
|
|
2070
|
+
? `Watch cycle captured ${snapshot.recordCount} records with no priced financial evidence; total unavailable.`
|
|
2071
|
+
: `Watch cycle captured ${snapshot.recordCount} records totaling ${formatOptionalUsd(snapshot.totalUsd)}.`
|
|
1882
2072
|
});
|
|
1883
2073
|
return { summary, snapshot, records: headlineRecords, mode };
|
|
1884
2074
|
}
|
|
1885
2075
|
function buildDeltaHeadline(previous, current) {
|
|
1886
2076
|
if (!previous) {
|
|
1887
|
-
|
|
2077
|
+
if (current.totalUsd === null) {
|
|
2078
|
+
return `First watch snapshot. Financial baseline is unavailable across ${current.recordCount} records; missing/null is not zero. Future priced cycles will establish a numeric baseline.`;
|
|
2079
|
+
}
|
|
2080
|
+
return `First watch snapshot. Baseline AI spend is ${formatOptionalUsd(current.totalUsd)} across ${current.recordCount} charges. Future cycles will report what changed.`;
|
|
2081
|
+
}
|
|
2082
|
+
if (current.totalUsd === null) {
|
|
2083
|
+
return `Financial evidence is unavailable across ${current.recordCount} records; missing/null is not zero and no numeric delta was calculated.`;
|
|
2084
|
+
}
|
|
2085
|
+
if (previous.totalUsd === null) {
|
|
2086
|
+
return `A priced financial baseline is now available at ${formatOptionalUsd(current.totalUsd)} across ${current.recordCount} records. The prior snapshot was unavailable, so no numeric delta was calculated.`;
|
|
1888
2087
|
}
|
|
1889
2088
|
const deltaUsd = roundMoneyCli(current.totalUsd - previous.totalUsd);
|
|
1890
2089
|
const lines = [];
|
|
1891
|
-
if (
|
|
1892
|
-
lines.push(`No change since the last check: AI spend is holding at
|
|
2090
|
+
if (deltaUsd === 0) {
|
|
2091
|
+
lines.push(`No change since the last check: AI spend is holding at ${formatOptionalUsd(current.totalUsd)}.`);
|
|
1893
2092
|
}
|
|
1894
2093
|
else {
|
|
1895
2094
|
const direction = deltaUsd > 0 ? "UP" : "DOWN";
|
|
1896
2095
|
const percent = previous.totalUsd > 0 ? Math.round((deltaUsd / previous.totalUsd) * 100) : 100;
|
|
1897
|
-
lines.push(`Spend is ${direction}
|
|
1898
|
-
`from
|
|
2096
|
+
lines.push(`Spend is ${direction} ${formatOptionalUsd(Math.abs(deltaUsd))} (${Math.abs(percent)}%) since the last check — ` +
|
|
2097
|
+
`from ${formatOptionalUsd(previous.totalUsd)} to ${formatOptionalUsd(current.totalUsd)}.`);
|
|
1899
2098
|
}
|
|
1900
2099
|
// New-model and per-model spike detection versus the previous snapshot.
|
|
1901
2100
|
const previousModels = new Map(previous.byModel.map((entry) => [entry.key, entry.amountUsd]));
|
|
@@ -1904,12 +2103,12 @@ function buildDeltaHeadline(previous, current) {
|
|
|
1904
2103
|
const before = previousModels.get(entry.key);
|
|
1905
2104
|
if (before === undefined) {
|
|
1906
2105
|
if (entry.amountUsd >= 1) {
|
|
1907
|
-
anomalies.push(`New model "${entry.key}" appeared, already at
|
|
2106
|
+
anomalies.push(`New model "${entry.key}" appeared, already at ${formatOptionalUsd(entry.amountUsd)}.`);
|
|
1908
2107
|
}
|
|
1909
2108
|
continue;
|
|
1910
2109
|
}
|
|
1911
2110
|
if (before > 0 && entry.amountUsd - before >= 5 && entry.amountUsd / before >= 1.5) {
|
|
1912
|
-
anomalies.push(`"${entry.key}" jumped from
|
|
2111
|
+
anomalies.push(`"${entry.key}" jumped from ${formatOptionalUsd(before)} to ${formatOptionalUsd(entry.amountUsd)}.`);
|
|
1913
2112
|
}
|
|
1914
2113
|
}
|
|
1915
2114
|
if (anomalies.length > 0) {
|
|
@@ -1921,7 +2120,7 @@ function sleep(milliseconds) {
|
|
|
1921
2120
|
return new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds));
|
|
1922
2121
|
}
|
|
1923
2122
|
function roundMoneyCli(value) {
|
|
1924
|
-
return Math.round(value *
|
|
2123
|
+
return Math.round(value * 10_000) / 10_000;
|
|
1925
2124
|
}
|
|
1926
2125
|
async function addSourceCommand(args) {
|
|
1927
2126
|
const rootPath = resolve(args.path);
|
|
@@ -1982,6 +2181,10 @@ const adminUpgradeProviders = {
|
|
|
1982
2181
|
"github-copilot": "requires a GitHub BILLING-ADMIN token (org/enterprise)",
|
|
1983
2182
|
copilot: "requires a GitHub BILLING-ADMIN token (org/enterprise)"
|
|
1984
2183
|
};
|
|
2184
|
+
const supportedAdminProviders = new Set(["openai", "anthropic", "cursor", "github-copilot"]);
|
|
2185
|
+
const providerAliases = {
|
|
2186
|
+
copilot: "github-copilot"
|
|
2187
|
+
};
|
|
1985
2188
|
const providerAdminEnvHint = {
|
|
1986
2189
|
openai: "env:OPENAI_ADMIN_KEY",
|
|
1987
2190
|
anthropic: "env:ANTHROPIC_ADMIN_KEY",
|
|
@@ -1989,10 +2192,20 @@ const providerAdminEnvHint = {
|
|
|
1989
2192
|
"github-copilot": "env:GITHUB_TOKEN",
|
|
1990
2193
|
copilot: "env:GITHUB_TOKEN"
|
|
1991
2194
|
};
|
|
2195
|
+
function providerSyncSetupCommand(provider, adminRef) {
|
|
2196
|
+
if (provider === "cursor") {
|
|
2197
|
+
return `npx aibill sync-provider --provider cursor --auth-reference ${adminRef} --account-id <team-label>`;
|
|
2198
|
+
}
|
|
2199
|
+
if (provider === "github-copilot") {
|
|
2200
|
+
return `npx aibill sync-provider --provider github-copilot --auth-reference ${adminRef} --org <organization>`;
|
|
2201
|
+
}
|
|
2202
|
+
return `npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`;
|
|
2203
|
+
}
|
|
1992
2204
|
async function connectCommand(args) {
|
|
1993
2205
|
const rootPath = resolve(args.path);
|
|
1994
2206
|
const stateDir = join(rootPath, ".ai-spend-agent");
|
|
1995
|
-
const
|
|
2207
|
+
const requestedProvider = (args.provider ?? "unknown").trim().toLowerCase();
|
|
2208
|
+
const provider = providerAliases[requestedProvider] ?? requestedProvider;
|
|
1996
2209
|
if (!provider || provider === "unknown") {
|
|
1997
2210
|
return {
|
|
1998
2211
|
exitCode: 1,
|
|
@@ -2008,6 +2221,20 @@ async function connectCommand(args) {
|
|
|
2008
2221
|
};
|
|
2009
2222
|
}
|
|
2010
2223
|
const type = args.sourceType ?? "provider_api";
|
|
2224
|
+
if (!supportedAdminProviders.has(provider) || type !== "provider_api") {
|
|
2225
|
+
const reason = !supportedAdminProviders.has(provider)
|
|
2226
|
+
? `connect does not implement provider "${sanitizeSecretishError(requestedProvider)}".`
|
|
2227
|
+
: `connect supports provider_api only; received "${sanitizeSecretishError(type)}".`;
|
|
2228
|
+
return {
|
|
2229
|
+
exitCode: 1,
|
|
2230
|
+
stdout: "",
|
|
2231
|
+
stderr: [
|
|
2232
|
+
reason,
|
|
2233
|
+
"Supported admin connectors: openai, anthropic, cursor, github-copilot.",
|
|
2234
|
+
"For an approved file/export boundary, use `npx aibill add-source` instead."
|
|
2235
|
+
].join("\n")
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2011
2238
|
const registry = await readSourceRegistry(stateDir, rootPath);
|
|
2012
2239
|
const source = createProviderConnectorStub(provider, type);
|
|
2013
2240
|
const nextRegistry = addApprovedSource(registry, source);
|
|
@@ -2034,7 +2261,7 @@ async function connectCommand(args) {
|
|
|
2034
2261
|
`boundary approval: ${source.boundaryApproval}`,
|
|
2035
2262
|
`validation coverage: ${source.validationCoverage}`,
|
|
2036
2263
|
`financial evidence: ${source.financialEvidence}`,
|
|
2037
|
-
|
|
2264
|
+
`secrets: no raw secrets stored; we only reference a local env var such as ${providerAdminEnvHint[provider] ?? "env:YOUR_ADMIN_KEY"}`
|
|
2038
2265
|
];
|
|
2039
2266
|
if (selfServeProviders.has(provider)) {
|
|
2040
2267
|
lines.push("tier: self-serve — an org owner can enable this in ~2 minutes");
|
|
@@ -2048,19 +2275,19 @@ async function connectCommand(args) {
|
|
|
2048
2275
|
lines.push(`auto-detected: a ${provider} key in ${detected.reference} (${detected.hint}) from ${describeOrigin(detected)}`);
|
|
2049
2276
|
if (detected.isLikelyAdminKey) {
|
|
2050
2277
|
const adminRef = providerAdminEnvHint[provider] ?? detected.reference;
|
|
2051
|
-
lines.push(`next:
|
|
2278
|
+
lines.push(`next: ${providerSyncSetupCommand(provider, adminRef)}`);
|
|
2052
2279
|
}
|
|
2053
2280
|
else {
|
|
2054
2281
|
const adminRef = providerAdminEnvHint[provider] ?? "env:YOUR_ADMIN_KEY";
|
|
2055
2282
|
lines.push(`this looks like a regular key — for COST data set an admin key in ${adminRef}, then:`);
|
|
2056
|
-
lines.push(`
|
|
2283
|
+
lines.push(` ${providerSyncSetupCommand(provider, adminRef)}`);
|
|
2057
2284
|
}
|
|
2058
2285
|
}
|
|
2059
2286
|
else {
|
|
2060
2287
|
const adminRef = providerAdminEnvHint[provider] ?? "env:YOUR_ADMIN_KEY";
|
|
2061
2288
|
lines.push("");
|
|
2062
2289
|
lines.push(`next: export an admin key reference, e.g. ${adminRef}, then run:`);
|
|
2063
|
-
lines.push(`
|
|
2290
|
+
lines.push(` ${providerSyncSetupCommand(provider, adminRef)}`);
|
|
2064
2291
|
}
|
|
2065
2292
|
lines.push(`missing: ${source.fieldsMissing.join(", ")}`);
|
|
2066
2293
|
return ok(lines.join("\n"));
|
|
@@ -2089,17 +2316,62 @@ function describeOrigin(credential) {
|
|
|
2089
2316
|
async function syncProviderCommand(args) {
|
|
2090
2317
|
const rootPath = resolve(args.path);
|
|
2091
2318
|
const stateDir = join(rootPath, ".ai-spend-agent");
|
|
2092
|
-
|
|
2319
|
+
const requestedProvider = (args.provider ?? "").trim().toLowerCase();
|
|
2320
|
+
const provider = providerAliases[requestedProvider] ?? requestedProvider;
|
|
2321
|
+
if (!provider) {
|
|
2322
|
+
return {
|
|
2323
|
+
exitCode: 1,
|
|
2324
|
+
stdout: "",
|
|
2325
|
+
stderr: [
|
|
2326
|
+
"sync-provider requires --provider.",
|
|
2327
|
+
"Supported admin connectors: openai, anthropic, cursor, github-copilot."
|
|
2328
|
+
].join("\n")
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2331
|
+
if (!supportedAdminProviders.has(provider)) {
|
|
2093
2332
|
return {
|
|
2094
2333
|
exitCode: 1,
|
|
2095
2334
|
stdout: "",
|
|
2096
2335
|
stderr: [
|
|
2097
|
-
|
|
2098
|
-
"
|
|
2099
|
-
" npx aibill sync-provider --provider anthropic --auth-reference env:ANTHROPIC_ADMIN_KEY --start-time 1750000000"
|
|
2336
|
+
`sync-provider does not implement provider "${sanitizeSecretishError(requestedProvider)}".`,
|
|
2337
|
+
"Supported admin connectors: openai, anthropic, cursor, github-copilot."
|
|
2100
2338
|
].join("\n")
|
|
2101
2339
|
};
|
|
2102
2340
|
}
|
|
2341
|
+
if (!args.authReference) {
|
|
2342
|
+
return {
|
|
2343
|
+
exitCode: 1,
|
|
2344
|
+
stdout: "",
|
|
2345
|
+
stderr: `sync-provider ${provider} requires --auth-reference env:NAME; raw secrets are not accepted.`
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
const usesRequestedTimeBounds = provider === "openai" || provider === "anthropic";
|
|
2349
|
+
if (usesRequestedTimeBounds && args.startTime === undefined) {
|
|
2350
|
+
return {
|
|
2351
|
+
exitCode: 1,
|
|
2352
|
+
stdout: "",
|
|
2353
|
+
stderr: [
|
|
2354
|
+
`sync-provider ${provider} requires --start-time <unix seconds>; --end-time is optional.`,
|
|
2355
|
+
`example: npx aibill sync-provider --provider ${provider} --auth-reference ${providerAdminEnvHint[provider]} --start-time 1750000000`
|
|
2356
|
+
].join("\n")
|
|
2357
|
+
};
|
|
2358
|
+
}
|
|
2359
|
+
if (!usesRequestedTimeBounds && (args.startTime !== undefined || args.endTime !== undefined)) {
|
|
2360
|
+
return {
|
|
2361
|
+
exitCode: 1,
|
|
2362
|
+
stdout: "",
|
|
2363
|
+
stderr: provider === "cursor"
|
|
2364
|
+
? "Cursor Admin spend is returned for the provider's current team subscription cycle; --start-time/--end-time are not accepted because the connector cannot enforce them."
|
|
2365
|
+
: "GitHub Copilot returns its latest metrics-report window plus current seat data; --start-time/--end-time are not accepted because the connector cannot enforce them."
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
if (provider === "github-copilot" && Boolean(args.org) === Boolean(args.enterprise)) {
|
|
2369
|
+
return {
|
|
2370
|
+
exitCode: 1,
|
|
2371
|
+
stdout: "",
|
|
2372
|
+
stderr: "GitHub Copilot sync requires exactly one of --org <organization> or --enterprise <enterprise>."
|
|
2373
|
+
};
|
|
2374
|
+
}
|
|
2103
2375
|
try {
|
|
2104
2376
|
// A provider sync may merge only a prior connected snapshot whose exact
|
|
2105
2377
|
// repository bytes have a matching external machine receipt. A cloned
|
|
@@ -2111,21 +2383,36 @@ async function syncProviderCommand(args) {
|
|
|
2111
2383
|
? priorSpend
|
|
2112
2384
|
: undefined;
|
|
2113
2385
|
const result = await fetchProviderUsageRecords({
|
|
2114
|
-
provider
|
|
2115
|
-
sourceId: `${
|
|
2386
|
+
provider,
|
|
2387
|
+
sourceId: `${provider}-provider-api`,
|
|
2116
2388
|
authReference: args.authReference,
|
|
2117
|
-
|
|
2389
|
+
// Core keeps a single connector input contract. Non-time-bounded
|
|
2390
|
+
// adapters ignore this sentinel and the CLI rejects user-supplied bounds
|
|
2391
|
+
// above so no requested interval can be silently discarded.
|
|
2392
|
+
startTime: usesRequestedTimeBounds ? args.startTime : 0,
|
|
2118
2393
|
endTime: args.endTime,
|
|
2119
2394
|
org: args.org,
|
|
2120
2395
|
enterprise: args.enterprise,
|
|
2121
2396
|
accountId: args.accountId
|
|
2122
2397
|
});
|
|
2123
|
-
const
|
|
2398
|
+
const syncedRecords = applyProviderContractGate(result.records);
|
|
2399
|
+
const syncedFinancials = summarizeProviderFinancials(syncedRecords);
|
|
2400
|
+
const syncedCompleteness = providerFinancialCompleteness(syncedRecords, result.coverage);
|
|
2401
|
+
const syncedSource = createProviderConnection({
|
|
2402
|
+
provider: result.provider,
|
|
2403
|
+
sourceId: result.source.id,
|
|
2404
|
+
authReference: args.authReference,
|
|
2405
|
+
verifiedRecordCount: syncedRecords.length,
|
|
2406
|
+
totalUsd: syncedFinancials.headlineUsd,
|
|
2407
|
+
completeness: syncedCompleteness,
|
|
2408
|
+
fetchedAt: new Date(result.fetchedAt)
|
|
2409
|
+
});
|
|
2410
|
+
const records = applyProviderContractGate([
|
|
2124
2411
|
...(trustedPrior?.records ?? []).filter((record) => record.source.provider !== result.provider),
|
|
2125
|
-
...
|
|
2126
|
-
].sort((left, right) => left.timestamp.localeCompare(right.timestamp));
|
|
2412
|
+
...syncedRecords
|
|
2413
|
+
]).sort((left, right) => left.timestamp.localeCompare(right.timestamp));
|
|
2127
2414
|
const registry = await readSourceRegistry(stateDir, rootPath);
|
|
2128
|
-
const nextRegistry = addApprovedSource(registry,
|
|
2415
|
+
const nextRegistry = addApprovedSource(registry, syncedSource);
|
|
2129
2416
|
const headlineRecords = selectProviderFinancialHeadlineRecords(records);
|
|
2130
2417
|
const summary = analyzeSpend(headlineRecords);
|
|
2131
2418
|
const mappings = attributeUsageRecords(records);
|
|
@@ -2139,7 +2426,7 @@ async function syncProviderCommand(args) {
|
|
|
2139
2426
|
};
|
|
2140
2427
|
const financialsByProvider = {
|
|
2141
2428
|
...trustedAccountingMap(trustedPrior?.accounting, "financialsByProvider"),
|
|
2142
|
-
[result.provider]:
|
|
2429
|
+
[result.provider]: syncedFinancials
|
|
2143
2430
|
};
|
|
2144
2431
|
const checkedAtByProvider = {
|
|
2145
2432
|
...trustedAccountingMap(trustedPrior?.accounting, "checkedAtByProvider"),
|
|
@@ -2160,10 +2447,10 @@ async function syncProviderCommand(args) {
|
|
|
2160
2447
|
await writeJson(join(stateDir, "provider-records.json"), {
|
|
2161
2448
|
provider: result.provider,
|
|
2162
2449
|
fetchedAt: result.fetchedAt,
|
|
2163
|
-
completeness:
|
|
2450
|
+
completeness: syncedCompleteness,
|
|
2164
2451
|
coverage: result.coverage,
|
|
2165
|
-
financials:
|
|
2166
|
-
sourceId:
|
|
2452
|
+
financials: syncedFinancials,
|
|
2453
|
+
sourceId: syncedSource.id,
|
|
2167
2454
|
records,
|
|
2168
2455
|
qa: result.qa,
|
|
2169
2456
|
qaByProvider,
|
|
@@ -2189,31 +2476,36 @@ async function syncProviderCommand(args) {
|
|
|
2189
2476
|
await appendAuditEvent(stateDir, {
|
|
2190
2477
|
timestamp: result.fetchedAt,
|
|
2191
2478
|
action: "source_scanned",
|
|
2192
|
-
sourceId:
|
|
2479
|
+
sourceId: syncedSource.id,
|
|
2193
2480
|
detail: `${args.provider} provider connector synced ${result.records.length} evidence records with ${result.coverage} coverage. Auth reference only; no raw secrets stored.`
|
|
2194
2481
|
});
|
|
2195
2482
|
await writeConnectedSpendTrustReceipt(rootPath, await readSafeStateText(stateDir, "spend.json"), { sourceRegistryContents: await readSafeStateText(stateDir, "sources.json") });
|
|
2196
2483
|
return ok([
|
|
2197
2484
|
"aibill sync-provider",
|
|
2198
2485
|
`provider: ${result.provider}`,
|
|
2199
|
-
`source: ${
|
|
2200
|
-
`boundary approval: ${
|
|
2201
|
-
`validation coverage: ${
|
|
2202
|
-
`financial evidence: ${
|
|
2486
|
+
`source: ${syncedSource.id}`,
|
|
2487
|
+
`boundary approval: ${syncedSource.boundaryApproval}`,
|
|
2488
|
+
`validation coverage: ${syncedSource.validationCoverage}`,
|
|
2489
|
+
`financial evidence: ${syncedSource.financialEvidence}`,
|
|
2203
2490
|
`coverage: ${result.coverage}`,
|
|
2204
2491
|
`records fetched: ${result.records.length}`,
|
|
2205
|
-
|
|
2206
|
-
|
|
2492
|
+
provider === "cursor"
|
|
2493
|
+
? "source window: current team subscription cycle returned by Cursor"
|
|
2494
|
+
: provider === "github-copilot"
|
|
2495
|
+
? "source window: latest Copilot metrics-report window plus current seat data"
|
|
2496
|
+
: `source window: requested from ${new Date(args.startTime * 1_000).toISOString()}${args.endTime === undefined ? " through provider current time" : ` through ${new Date(args.endTime * 1_000).toISOString()}`}`,
|
|
2497
|
+
`headline basis: ${syncedFinancials.headlineBasis}`,
|
|
2498
|
+
`synced provider headline: ${formatOptionalUsd(syncedFinancials.headlineUsd)}`,
|
|
2207
2499
|
`combined headline spend: ${selectProviderFinancialHeadlineRecords(records).some((record) => typeof record.amountUsd === "number") ? formatOptionalUsd(summary.totalUsd) : "unavailable"}`,
|
|
2208
|
-
...(
|
|
2209
|
-
? [`API-equivalent estimate (kept separate):
|
|
2500
|
+
...(syncedFinancials.apiEquivalentEstimatedUsd !== null
|
|
2501
|
+
? [`API-equivalent estimate (kept separate): ${formatOptionalUsd(syncedFinancials.apiEquivalentEstimatedUsd)}`]
|
|
2210
2502
|
: []),
|
|
2211
2503
|
"auth: reference-only; raw secrets were not persisted or printed"
|
|
2212
2504
|
].join("\n"));
|
|
2213
2505
|
}
|
|
2214
2506
|
catch (error) {
|
|
2215
2507
|
const sanitizedError = sanitizeSecretishError(error instanceof Error ? error.message : String(error), args.authReference);
|
|
2216
|
-
await recordProviderSourceAttempt(stateDir,
|
|
2508
|
+
await recordProviderSourceAttempt(stateDir, provider, new Date().toISOString(), sanitizedError).catch(() => {
|
|
2217
2509
|
// Source-status state is diagnostic only. Do not hide the provider's
|
|
2218
2510
|
// real error if derived-state persistence is unavailable.
|
|
2219
2511
|
});
|
|
@@ -2323,8 +2615,11 @@ async function reportCommand(args) {
|
|
|
2323
2615
|
`verification plan: ${artifactPaths.verificationPlan}`,
|
|
2324
2616
|
`demo package: ${artifactPaths.demoPackage}`,
|
|
2325
2617
|
reportInput.dataMode === "sample"
|
|
2326
|
-
? `DEMO SAMPLE · illustrative cost/value evidence total:
|
|
2327
|
-
:
|
|
2618
|
+
? `DEMO SAMPLE · illustrative cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)} · not user data`
|
|
2619
|
+
: reportInput.dataMode === "connected_provider" &&
|
|
2620
|
+
!(reportInput.allRecords ?? reportInput.providerRecords ?? []).some((record) => typeof record.amountUsd === "number")
|
|
2621
|
+
? "cost/value evidence total: Unavailable · no priced financial evidence; missing/null is not zero"
|
|
2622
|
+
: `cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)}`,
|
|
2328
2623
|
"privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
|
|
2329
2624
|
"",
|
|
2330
2625
|
"next:",
|
|
@@ -2339,7 +2634,7 @@ async function reportCommand(args) {
|
|
|
2339
2634
|
return {
|
|
2340
2635
|
exitCode: 1,
|
|
2341
2636
|
stdout: "",
|
|
2342
|
-
stderr: `Couldn't build a report: ${error instanceof Error ? error.message : String(error)}`
|
|
2637
|
+
stderr: `Couldn't build a report: ${sanitizeSecretishError(error instanceof Error ? error.message : String(error))}`
|
|
2343
2638
|
};
|
|
2344
2639
|
}
|
|
2345
2640
|
}
|
|
@@ -2363,7 +2658,10 @@ async function reportCardCommand(args) {
|
|
|
2363
2658
|
// would reject a harmless receipt written from the user's home directory.
|
|
2364
2659
|
// Output still goes through the safe-write/symlink checks below.
|
|
2365
2660
|
const rootPath = args.sample ? resolve(args.path) : await resolveSafeScanRoot(args.path);
|
|
2366
|
-
const { records, mode, providerCoverage } = await loadInstantReadData(args);
|
|
2661
|
+
const { records, mode, providerCoverage, warnings } = await loadInstantReadData(args);
|
|
2662
|
+
if (records.length === 0) {
|
|
2663
|
+
return noEvidenceResult("report-card", warnings, args.sinceDays ?? 30);
|
|
2664
|
+
}
|
|
2367
2665
|
const headlineRecords = mode === "connected"
|
|
2368
2666
|
? selectProviderFinancialHeadlineRecords(records)
|
|
2369
2667
|
: records;
|
|
@@ -2403,7 +2701,7 @@ async function reportCardCommand(args) {
|
|
|
2403
2701
|
return {
|
|
2404
2702
|
exitCode: 1,
|
|
2405
2703
|
stdout: "",
|
|
2406
|
-
stderr: `Couldn't write the report card: ${error instanceof Error ? error.message : String(error)}`
|
|
2704
|
+
stderr: `Couldn't write the report card: ${sanitizeSecretishError(error instanceof Error ? error.message : String(error))}`
|
|
2407
2705
|
};
|
|
2408
2706
|
}
|
|
2409
2707
|
}
|
|
@@ -2437,6 +2735,13 @@ async function applyArtifactCommand(args) {
|
|
|
2437
2735
|
codingPrompt.trimEnd()
|
|
2438
2736
|
].join("\n"));
|
|
2439
2737
|
}
|
|
2738
|
+
const noCandidateGuidance = codingPrompt.includes("NO SCOPED CHANGE CANDIDATE")
|
|
2739
|
+
? [
|
|
2740
|
+
"",
|
|
2741
|
+
"No scoped change is supported yet. Collect the missing source evidence instead of guessing:",
|
|
2742
|
+
...applyEvidenceAcquisitionLines(reportInput)
|
|
2743
|
+
]
|
|
2744
|
+
: [];
|
|
2440
2745
|
return ok([
|
|
2441
2746
|
"aibill apply-artifact",
|
|
2442
2747
|
`path: ${rootPath}`,
|
|
@@ -2445,6 +2750,7 @@ async function applyArtifactCommand(args) {
|
|
|
2445
2750
|
`verification plan: ${artifactPaths.verificationPlan}`,
|
|
2446
2751
|
`demo package: ${artifactPaths.demoPackage}`,
|
|
2447
2752
|
"safety: generated artifacts only; no external systems changed",
|
|
2753
|
+
...noCandidateGuidance,
|
|
2448
2754
|
"",
|
|
2449
2755
|
`──── copy everything below into Claude Code / Codex (also saved at ${artifactPaths.codingPrompt}) ────`,
|
|
2450
2756
|
"",
|
|
@@ -2455,10 +2761,35 @@ async function applyArtifactCommand(args) {
|
|
|
2455
2761
|
return {
|
|
2456
2762
|
exitCode: 1,
|
|
2457
2763
|
stdout: "",
|
|
2458
|
-
stderr:
|
|
2764
|
+
stderr: [
|
|
2765
|
+
`Couldn't build apply artifacts: ${sanitizeSecretishError(error instanceof Error ? error.message : String(error))}`,
|
|
2766
|
+
"Collect evidence, then retry Apply:",
|
|
2767
|
+
"- Claude Code / Codex / Gemini CLI: use the agent normally so supported local history exists.",
|
|
2768
|
+
"- OpenAI / Anthropic / Cursor / GitHub Copilot: run `npx aibill connect <provider>` with an admin credential reference.",
|
|
2769
|
+
"- Diagnose the exact gap: `npx aibill doctor --sources`.",
|
|
2770
|
+
"- Demo only: `npx aibill apply --sample` (non-executable)."
|
|
2771
|
+
].join("\n")
|
|
2459
2772
|
};
|
|
2460
2773
|
}
|
|
2461
2774
|
}
|
|
2775
|
+
function applyEvidenceAcquisitionLines(input) {
|
|
2776
|
+
const records = input.allRecords ?? input.providerRecords ?? [];
|
|
2777
|
+
const providers = new Set(records.map((record) => record.source.provider));
|
|
2778
|
+
const lines = [];
|
|
2779
|
+
if (providers.has("anthropic") || providers.has("openai") || input.dataMode === "local_logs") {
|
|
2780
|
+
lines.push("- Local coding agents: run `npx aibill context --json` after comparable Claude Code/Codex sessions to inspect action-capable context evidence.");
|
|
2781
|
+
}
|
|
2782
|
+
if (providers.has("gemini") || providers.has("gemini-cli")) {
|
|
2783
|
+
lines.push("- Gemini CLI: current chat evidence is financial-only; unsupported context/action evidence remains missing.");
|
|
2784
|
+
}
|
|
2785
|
+
if (input.dataMode === "connected_provider") {
|
|
2786
|
+
lines.push("- Provider billing: sync explicit call/invocation-level workload evidence; aggregate owner or spend rows do not authorize a change.");
|
|
2787
|
+
}
|
|
2788
|
+
if (lines.length === 0) {
|
|
2789
|
+
lines.push("- Run `npx aibill doctor --sources` to see which local or provider evidence is missing.");
|
|
2790
|
+
}
|
|
2791
|
+
return lines;
|
|
2792
|
+
}
|
|
2462
2793
|
async function buildExplicitSampleReportInput(rootPath) {
|
|
2463
2794
|
const records = await loadSampleUsageData();
|
|
2464
2795
|
return {
|
|
@@ -2510,9 +2841,12 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
|
|
|
2510
2841
|
// A bundled sample remains sample even if a conflicting mode was written.
|
|
2511
2842
|
// This guards report and Apply separately from the quickstart read path.
|
|
2512
2843
|
const mode = isBundledSampleUsage(parsedRecords) ? "sample" : storedMode;
|
|
2513
|
-
const
|
|
2844
|
+
const parsedModeRecords = mode === "sample" || mode === undefined
|
|
2514
2845
|
? downgradeSampleUsageEvidence(parsedRecords)
|
|
2515
2846
|
: parsedRecords;
|
|
2847
|
+
const records = mode === "connected_provider"
|
|
2848
|
+
? applyProviderContractGate(parsedModeRecords)
|
|
2849
|
+
: parsedModeRecords;
|
|
2516
2850
|
unavailablePersistedLocalLogs = mode === "local_logs";
|
|
2517
2851
|
const headlineRecords = mode === "connected_provider"
|
|
2518
2852
|
? selectProviderFinancialHeadlineRecords(records)
|
|
@@ -2560,6 +2894,7 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
|
|
|
2560
2894
|
const logs = await loadLocalAgentUsage({
|
|
2561
2895
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
2562
2896
|
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
2897
|
+
geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
|
|
2563
2898
|
sinceIso,
|
|
2564
2899
|
collectCodexInvocationEvidence: true
|
|
2565
2900
|
}).catch(() => undefined);
|
|
@@ -2588,11 +2923,12 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
|
|
|
2588
2923
|
throw new Error(`${untrustedConnectedStateMessage} No connected totals or Apply actions were generated.`);
|
|
2589
2924
|
}
|
|
2590
2925
|
if (unavailablePersistedLocalLogs) {
|
|
2591
|
-
throw new Error("Persisted local-log state is an untrusted cache and its source
|
|
2926
|
+
throw new Error("Persisted local-log state is an untrusted cache and its source local-agent records are unavailable. " +
|
|
2592
2927
|
"Re-run `npx aibill` while the local transcripts are available; no report or Apply action was generated from repository state alone.");
|
|
2593
2928
|
}
|
|
2594
|
-
throw new Error("no persisted spend state and no local
|
|
2595
|
-
"
|
|
2929
|
+
throw new Error("no persisted spend state and no supported local-agent financial evidence found. " +
|
|
2930
|
+
"Use Claude Code, Codex, or Gemini CLI normally; connect an admin provider for billed cost; " +
|
|
2931
|
+
"or run `npx aibill doctor --sources` to inspect the exact gap.");
|
|
2596
2932
|
}
|
|
2597
2933
|
const [discovery, sourceRegistry, missingSourcePrompts, confirmedMappings, persistedProviderRecordsState] = await Promise.all([
|
|
2598
2934
|
readOptionalJson(join(stateDir, "discovery.json"), emptyDiscovery(rootPath)),
|
|
@@ -2651,7 +2987,7 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
|
|
|
2651
2987
|
// If live transcript calls are unavailable, omit it instead of fabricating a
|
|
2652
2988
|
// session-level recommendation from day-aggregate spend records.
|
|
2653
2989
|
const contextHealth = spendState.mode === "local_logs" && freshLocalCalls
|
|
2654
|
-
? await loadContextHealth(freshLocalCalls, {
|
|
2990
|
+
? await loadContextHealth(freshLocalCalls.filter((call) => (localAgentFormatSupports(call.agent, "contextHealth"))), {
|
|
2655
2991
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
2656
2992
|
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
2657
2993
|
claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
|
|
@@ -2740,7 +3076,8 @@ function parseArgs(argv) {
|
|
|
2740
3076
|
const parsed = {
|
|
2741
3077
|
command,
|
|
2742
3078
|
sample: false,
|
|
2743
|
-
path: process.cwd()
|
|
3079
|
+
path: process.cwd(),
|
|
3080
|
+
parseErrors: []
|
|
2744
3081
|
};
|
|
2745
3082
|
if (command === "statusline" && rest[0] && !rest[0].startsWith("--")) {
|
|
2746
3083
|
parsed.statuslineAction = rest.shift();
|
|
@@ -2749,8 +3086,26 @@ function parseArgs(argv) {
|
|
|
2749
3086
|
parsed.provider = rest[0];
|
|
2750
3087
|
rest.shift();
|
|
2751
3088
|
}
|
|
3089
|
+
const valueFlags = new Set([
|
|
3090
|
+
"--plan", "--since-days", "--path", "--out",
|
|
3091
|
+
"--source-path", "--type", "--provider", "--source-id", "--team",
|
|
3092
|
+
"--person", "--client", "--project", "--agent", "--workflow",
|
|
3093
|
+
"--evidence", "--confidence", "--label", "--auth-reference",
|
|
3094
|
+
"--start-time", "--end-time", "--org", "--enterprise", "--account-id",
|
|
3095
|
+
"--interval", "--cycles"
|
|
3096
|
+
]);
|
|
3097
|
+
const numericValueFlags = new Set([
|
|
3098
|
+
"--since-days", "--confidence", "--start-time", "--end-time", "--interval", "--cycles"
|
|
3099
|
+
]);
|
|
2752
3100
|
for (let index = 0; index < rest.length; index += 1) {
|
|
2753
3101
|
const arg = rest[index];
|
|
3102
|
+
const nextValue = rest[index + 1];
|
|
3103
|
+
const nextLooksLikeFlag = nextValue?.startsWith("--") ||
|
|
3104
|
+
(!numericValueFlags.has(arg) && nextValue?.startsWith("-"));
|
|
3105
|
+
if (valueFlags.has(arg) && (nextValue === undefined || nextLooksLikeFlag)) {
|
|
3106
|
+
parsed.parseErrors.push(`${arg} requires a value`);
|
|
3107
|
+
continue;
|
|
3108
|
+
}
|
|
2754
3109
|
if (arg === "--sample") {
|
|
2755
3110
|
parsed.sample = true;
|
|
2756
3111
|
continue;
|
|
@@ -2763,6 +3118,10 @@ function parseArgs(argv) {
|
|
|
2763
3118
|
parsed.json = true;
|
|
2764
3119
|
continue;
|
|
2765
3120
|
}
|
|
3121
|
+
if (arg === "--full") {
|
|
3122
|
+
parsed.full = true;
|
|
3123
|
+
continue;
|
|
3124
|
+
}
|
|
2766
3125
|
if (arg === "--sources") {
|
|
2767
3126
|
parsed.sources = true;
|
|
2768
3127
|
continue;
|
|
@@ -2846,6 +3205,10 @@ function parseArgs(argv) {
|
|
|
2846
3205
|
parsed.sourceType = next;
|
|
2847
3206
|
index += 1;
|
|
2848
3207
|
}
|
|
3208
|
+
else if (next) {
|
|
3209
|
+
parsed.parseErrors.push(`--type received unsupported source type "${next}"`);
|
|
3210
|
+
index += 1;
|
|
3211
|
+
}
|
|
2849
3212
|
continue;
|
|
2850
3213
|
}
|
|
2851
3214
|
if (arg === "--provider") {
|
|
@@ -2928,6 +3291,9 @@ function parseArgs(argv) {
|
|
|
2928
3291
|
if (Number.isFinite(value) && value >= 0 && value <= 1) {
|
|
2929
3292
|
parsed.confidence = value;
|
|
2930
3293
|
}
|
|
3294
|
+
else {
|
|
3295
|
+
parsed.parseErrors.push("--confidence must be a number between 0 and 1");
|
|
3296
|
+
}
|
|
2931
3297
|
index += 1;
|
|
2932
3298
|
}
|
|
2933
3299
|
continue;
|
|
@@ -2951,7 +3317,11 @@ function parseArgs(argv) {
|
|
|
2951
3317
|
if (arg === "--start-time") {
|
|
2952
3318
|
const next = rest[index + 1];
|
|
2953
3319
|
if (next) {
|
|
2954
|
-
|
|
3320
|
+
const value = Number(next);
|
|
3321
|
+
if (Number.isFinite(value))
|
|
3322
|
+
parsed.startTime = value;
|
|
3323
|
+
else
|
|
3324
|
+
parsed.parseErrors.push("--start-time must be a finite Unix timestamp");
|
|
2955
3325
|
index += 1;
|
|
2956
3326
|
}
|
|
2957
3327
|
continue;
|
|
@@ -2959,7 +3329,11 @@ function parseArgs(argv) {
|
|
|
2959
3329
|
if (arg === "--end-time") {
|
|
2960
3330
|
const next = rest[index + 1];
|
|
2961
3331
|
if (next) {
|
|
2962
|
-
|
|
3332
|
+
const value = Number(next);
|
|
3333
|
+
if (Number.isFinite(value))
|
|
3334
|
+
parsed.endTime = value;
|
|
3335
|
+
else
|
|
3336
|
+
parsed.parseErrors.push("--end-time must be a finite Unix timestamp");
|
|
2963
3337
|
index += 1;
|
|
2964
3338
|
}
|
|
2965
3339
|
continue;
|
|
@@ -2991,7 +3365,11 @@ function parseArgs(argv) {
|
|
|
2991
3365
|
if (arg === "--interval") {
|
|
2992
3366
|
const next = rest[index + 1];
|
|
2993
3367
|
if (next) {
|
|
2994
|
-
|
|
3368
|
+
const value = Number(next);
|
|
3369
|
+
if (Number.isFinite(value) && value > 0)
|
|
3370
|
+
parsed.interval = value;
|
|
3371
|
+
else
|
|
3372
|
+
parsed.parseErrors.push("--interval must be a positive number of seconds");
|
|
2995
3373
|
index += 1;
|
|
2996
3374
|
}
|
|
2997
3375
|
continue;
|
|
@@ -2999,11 +3377,18 @@ function parseArgs(argv) {
|
|
|
2999
3377
|
if (arg === "--cycles") {
|
|
3000
3378
|
const next = rest[index + 1];
|
|
3001
3379
|
if (next) {
|
|
3002
|
-
|
|
3380
|
+
const value = Number(next);
|
|
3381
|
+
if (Number.isInteger(value) && value >= 0)
|
|
3382
|
+
parsed.cycles = value;
|
|
3383
|
+
else
|
|
3384
|
+
parsed.parseErrors.push("--cycles must be a whole number of 0 or greater");
|
|
3003
3385
|
index += 1;
|
|
3004
3386
|
}
|
|
3005
3387
|
continue;
|
|
3006
3388
|
}
|
|
3389
|
+
parsed.parseErrors.push(arg.startsWith("-")
|
|
3390
|
+
? `unknown flag "${arg}"`
|
|
3391
|
+
: `unexpected argument "${arg}"`);
|
|
3007
3392
|
}
|
|
3008
3393
|
return parsed;
|
|
3009
3394
|
}
|
|
@@ -3057,7 +3442,7 @@ async function readSourceRegistry(stateDir, rootPath) {
|
|
|
3057
3442
|
if (parsedSpend.mode === "connected_provider") {
|
|
3058
3443
|
const trust = await verifyConnectedSourceRegistryTrustReceipt(rootPath, exactSpendContents, exactSourceRegistryContents);
|
|
3059
3444
|
if (trust.trusted)
|
|
3060
|
-
return registry;
|
|
3445
|
+
return applyProviderContractGateToSourceRegistry(registry);
|
|
3061
3446
|
}
|
|
3062
3447
|
}
|
|
3063
3448
|
catch {
|
|
@@ -3065,7 +3450,7 @@ async function readSourceRegistry(stateDir, rootPath) {
|
|
|
3065
3450
|
// controlled validation/evidence claims are never promoted without the
|
|
3066
3451
|
// matching external provider-sync receipt.
|
|
3067
3452
|
}
|
|
3068
|
-
return downgradeUntrustedSourceRegistryClaims(registry);
|
|
3453
|
+
return applyProviderContractGateToSourceRegistry(downgradeUntrustedSourceRegistryClaims(registry));
|
|
3069
3454
|
}
|
|
3070
3455
|
catch {
|
|
3071
3456
|
return createLocalFolderSourceRegistry(rootPath);
|
|
@@ -3131,7 +3516,9 @@ function helpText() {
|
|
|
3131
3516
|
"aibill — your AI cost and usage evidence in one private view",
|
|
3132
3517
|
"",
|
|
3133
3518
|
"Run with no command for an instant, zero-key local readout:",
|
|
3134
|
-
" npx aibill Show
|
|
3519
|
+
" npx aibill Show a compact receipt from available local/connected evidence",
|
|
3520
|
+
" npx aibill --full Show the complete diagnose → recommend → apply → verify audit",
|
|
3521
|
+
" npx aibill --sample Show the clearly labeled illustrative demo (never implicit)",
|
|
3135
3522
|
" npx aibill --group-by agent Drill down by source|model|client|project|agent|user|workspace|apiKey",
|
|
3136
3523
|
" npx aibill --plan <id> Declare your plan when auto-detection can't (claude-max-5x|claude-max-20x|claude-pro|chatgpt-plus|chatgpt-pro)",
|
|
3137
3524
|
"",
|
|
@@ -3161,12 +3548,14 @@ function helpText() {
|
|
|
3161
3548
|
" scan [--path <dir>] Scan a local workspace for AI usage signals",
|
|
3162
3549
|
" scan --sample Include deterministic sample spend analysis",
|
|
3163
3550
|
" quickstart [--sample] [--since-days N] Plain-English local readout (default 30 days)",
|
|
3551
|
+
" [--full] Render the complete audit; default is the compact receipt",
|
|
3164
3552
|
" [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: project for local logs; model otherwise",
|
|
3165
3553
|
" report [--sample] [--out <name>] [--since-days N] Generate local Markdown and HTML reports from the same window",
|
|
3166
3554
|
" report-card [--out f.svg] Write your AI Receipt — a redacted, shareable SVG + caption",
|
|
3167
3555
|
" glance [--project <name>] [--plan <id>] [--since-days N] Emit the local, machine-readable Glance snapshot JSON",
|
|
3168
3556
|
" context [--project <name>] [--since-days N] Show hook-aware Context Health in the terminal",
|
|
3169
3557
|
" [--json] Emit the same canonical Context Health object used by MCP and Glance",
|
|
3558
|
+
" Main receipt JSON is not published yet; unsupported --json requests fail instead of returning text.",
|
|
3170
3559
|
" apply [--sample] [--since-days N] Print an evidence-constrained inspection/approval prompt + verification plans",
|
|
3171
3560
|
" apply-artifact Same as `apply` (long form)",
|
|
3172
3561
|
"",
|
|
@@ -3215,7 +3604,7 @@ export async function runMain() {
|
|
|
3215
3604
|
if (isInstantDemo && process.stdout.isTTY && !process.env.NO_COLOR) {
|
|
3216
3605
|
try {
|
|
3217
3606
|
const { default: yoctoSpinner } = await import("yocto-spinner");
|
|
3218
|
-
spinner = yoctoSpinner({ text: "
|
|
3607
|
+
spinner = yoctoSpinner({ text: "Reading local AI evidence…" }).start();
|
|
3219
3608
|
}
|
|
3220
3609
|
catch {
|
|
3221
3610
|
// Spinner is optional; never block the wow on it.
|