ai-spend-agent 0.9.1 → 0.9.2
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/improveFlow.js +5 -3
- package/dist/index.d.ts +11 -0
- package/dist/index.js +396 -54
- package/dist/signup.d.ts +272 -0
- package/dist/signup.js +681 -0
- package/dist/statuslineRuntime.js +3 -1
- package/dist/telemetry.d.ts +149 -0
- package/dist/telemetry.js +390 -0
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { realpathSync } from "node:fs";
|
|
3
|
-
import { mkdir, readFile, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { lstat, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
4
5
|
import { homedir } from "node:os";
|
|
5
6
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
6
7
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
8
|
import { askGuidedQuestion, classifyGuidedAnswer, createInteractivePromptSource, renderForYourAgent } from "./guidedPrompt.js";
|
|
9
|
+
import { assessEmailDeliverability, buildWaitlistRef, normalizeWaitlistEmail, postWaitlistSignup, readSignupState, clearSignupState, sanitizeSignupRefTag, serializeWaitlistPayload, signupCopy, signupStateFilePath, writeSignupState } from "./signup.js";
|
|
10
|
+
import { killTelemetryForThisProcess, readTelemetryState, telemetryDisclosureLine, telemetryStateFilePath, writeTelemetryState } from "./telemetry.js";
|
|
8
11
|
import { parsePlanDraft, renderCleanExit, runIdentitySequence, runPlanSitting, runQualitySitting, runRecordSitting, runRollbackSitting, runStartSitting, shortSittingHint } from "./improveFlow.js";
|
|
9
|
-
import { analyzeSpend, APPROVAL_EVENT_V0_KIND, buildContextHealth, buildActionVerificationProjectionV0, buildProjectEconomicsProjectionV0, buildTokenReductionBaselineV0, aibillCommandV0, aibillImproveCommandV0, decodeAgentDraftTokenV1, IMPROVE_USER_SAFETY_LINE_V1, looksLikeAgentDraftToken, screenAgentDraftSentence, attributeUsageRecords, buildUsageGlance, buildActivitySnapshot, buildResultCard, buildResultCardProjectLine, formatBilledUsdExact, formatCommittedPerMonth, resultCardSchema, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, invalidateConnectedSpendTrustReceipt, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, SAFE_QUALITATIVE_SCAN_POLICY, summarizeProviderFinancials, providerFinancialCompleteness, providerAccountKey, tagProviderAccountRecords, retainProviderRecordsForNewSync, providerAccountSlices, formatProviderAccountSlices, intersectProviderCoverageIntervals, duplicateProviderAccountSliceWarnings, providerSliceReplacementNotices, writeSafeStateText, verifyConnectedSpendTrustReceipt, verifyConnectedSourceRegistryTrustReceipt, writeConnectedSpendTrustReceipt, loadDeadContext, sampleDeadContext, sanitizeLocalActivityText, latestObservedWorkingDirectory, downgradeSampleUsageEvidence, isBundledSampleUsage, hasCompleteQualitativeCoverage, hasExactSelectedQualitativeEvidence, loadLocalAgentActionEvidence, extractSessionVitalsV0, loadLocalAgentFinancialUsage, localAgentFormatDescriptors, localAgentFormatLabel, localAgentFormatSupports, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProjectIndexAdapters, createActionVerificationReference, createProjectEconomicsReference, createProjectEconomicsPlannedActionRefV0, PROJECT_ECONOMICS_V0_VERSION, createProviderConnectorStub, createProviderConnection, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, normalizeSourceRegistry, downgradeUntrustedSourceRegistryClaims, buildSourceStatuses, applyProviderContractGate, applyProviderContractGateToSourceRegistry, slugifySourceId, financialEvidenceForRecords, formatSourceStatuses, markTokenReductionAppliedV0, invalidateTokenReductionExperimentV0, markTokenReductionRolledBackV0, readActivitySnapshot, recordActivitySnapshotRefreshFailure, refreshTokenReductionExperimentV0, resolveWasteFindingTargetV0, selectBestWasteFindingV0, sourceStatusDefinitions, writeActivitySnapshot } from "@agent-finops/core";
|
|
12
|
+
import { analyzeSpend, APPROVAL_EVENT_V0_KIND, buildContextHealth, buildActionVerificationProjectionV0, buildProjectEconomicsProjectionV0, buildTokenReductionBaselineV0, aibillCommandV0, aibillImproveCommandV0, decodeAgentDraftTokenV1, IMPROVE_USER_SAFETY_LINE_V1, looksLikeAgentDraftToken, screenAgentDraftSentence, attributeUsageRecords, buildUsageGlance, buildActivitySnapshot, buildResultCard, buildResultCardProjectLine, formatBilledUsdExact, formatCommittedPerMonth, resultCardSchema, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, invalidateConnectedSpendTrustReceipt, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, SAFE_QUALITATIVE_SCAN_POLICY, summarizeProviderFinancials, providerFinancialCompleteness, providerAccountKey, tagProviderAccountRecords, retainProviderRecordsForNewSync, providerAccountSlices, formatProviderAccountSlices, intersectProviderCoverageIntervals, duplicateProviderAccountSliceWarnings, providerSliceReplacementNotices, writeSafeStateText, verifyConnectedSpendTrustReceipt, verifyConnectedSourceRegistryTrustReceipt, writeConnectedSpendTrustReceipt, loadDeadContext, sampleDeadContext, sanitizeLocalActivityText, latestObservedWorkingDirectory, downgradeSampleUsageEvidence, isBundledSampleUsage, hasCompleteQualitativeCoverage, hasExactSelectedQualitativeEvidence, loadLocalAgentActionEvidence, extractSessionVitalsV0, loadLocalAgentFinancialUsage, localAgentFormatDescriptors, localAgentFormatLabel, localAgentFormatSupports, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProjectIndexAdapters, createActionVerificationReference, createProjectEconomicsReference, createProjectEconomicsPlannedActionRefV0, PROJECT_ECONOMICS_V0_VERSION, createProviderConnectorStub, createProviderConnection, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, normalizeSourceRegistry, downgradeUntrustedSourceRegistryClaims, buildSourceStatuses, applyProviderContractGate, applyProviderContractGateToSourceRegistry, slugifySourceId, financialEvidenceForRecords, formatSourceStatuses, markTokenReductionAppliedV0, invalidateTokenReductionExperimentV0, markTokenReductionRolledBackV0, activitySnapshotCachePath, readActivitySnapshot, recordActivitySnapshotRefreshFailure, refreshTokenReductionExperimentV0, resolveWasteFindingTargetV0, selectBestWasteFindingV0, sourceStatusDefinitions, writeActivitySnapshot } from "@agent-finops/core";
|
|
10
13
|
import { StatuslineInstallerError, installClaudeStatusline, refreshOwnedStatuslineRunner, uninstallClaudeStatusline } from "./statuslineInstaller.js";
|
|
11
14
|
import { readStatuslineCache, renderStatusline } from "./statuslineRuntime.js";
|
|
12
15
|
import { chooseLatestTokenReductionExperiment, loadTokenVerificationState, upsertTokenReductionExperiment } from "./tokenVerificationState.js";
|
|
@@ -30,7 +33,7 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
|
30
33
|
return ok(await cliVersion());
|
|
31
34
|
}
|
|
32
35
|
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
33
|
-
return ok(helpText());
|
|
36
|
+
return ok(helpText(runtime.telemetryDisclosure));
|
|
34
37
|
}
|
|
35
38
|
const args = parseArgs(argv);
|
|
36
39
|
if (args.parseErrors.length > 0) {
|
|
@@ -68,7 +71,7 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
|
68
71
|
return quickstartCommand(args, runtime);
|
|
69
72
|
}
|
|
70
73
|
if (args.command === "doctor") {
|
|
71
|
-
return doctorCommand(args);
|
|
74
|
+
return doctorCommand(args, runtime);
|
|
72
75
|
}
|
|
73
76
|
if (args.command === "reset") {
|
|
74
77
|
return resetCommand(args);
|
|
@@ -79,6 +82,12 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
|
79
82
|
if (args.command === "statusline") {
|
|
80
83
|
return statuslineCommand(args, runtime);
|
|
81
84
|
}
|
|
85
|
+
if (args.command === "signup") {
|
|
86
|
+
return signupCommand(args, runtime);
|
|
87
|
+
}
|
|
88
|
+
if (args.command === "telemetry") {
|
|
89
|
+
return telemetryCommand(args, runtime);
|
|
90
|
+
}
|
|
82
91
|
if (args.command === "scan") {
|
|
83
92
|
return scanCommand(args);
|
|
84
93
|
}
|
|
@@ -89,7 +98,7 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
|
89
98
|
return watchCommand(args);
|
|
90
99
|
}
|
|
91
100
|
if (args.command === "report") {
|
|
92
|
-
return reportCommand(args);
|
|
101
|
+
return reportCommand(args, runtime);
|
|
93
102
|
}
|
|
94
103
|
if (args.command === "report-card") {
|
|
95
104
|
return reportCardCommand(args);
|
|
@@ -98,7 +107,7 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
|
98
107
|
return glanceCommand(args);
|
|
99
108
|
}
|
|
100
109
|
if (args.command === "context" || args.command === "context-health") {
|
|
101
|
-
return contextHealthCommand(args);
|
|
110
|
+
return contextHealthCommand(args, runtime);
|
|
102
111
|
}
|
|
103
112
|
if (args.command === "apply-artifact" || args.command === "apply") {
|
|
104
113
|
return applyArtifactCommand(args);
|
|
@@ -142,7 +151,7 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
|
142
151
|
return {
|
|
143
152
|
exitCode: 1,
|
|
144
153
|
stdout: "",
|
|
145
|
-
stderr: `Unknown command: ${sanitizeSecretishError(args.command)}\n${helpText()}`
|
|
154
|
+
stderr: `Unknown command: ${sanitizeSecretishError(args.command)}\n${helpText(runtime.telemetryDisclosure)}`
|
|
146
155
|
};
|
|
147
156
|
}
|
|
148
157
|
async function quickstartCommand(args, runtime = {}) {
|
|
@@ -158,7 +167,7 @@ async function quickstartCommand(args, runtime = {}) {
|
|
|
158
167
|
}
|
|
159
168
|
const { records, mode, warnings, providerCoverage, codexInvocationFiles, actionEvidence, localFinancialRecords, financialCoverageComplete } = await loadInstantReadData(args);
|
|
160
169
|
if (records.length === 0) {
|
|
161
|
-
return noEvidenceResult("receipt", warnings, sinceDays);
|
|
170
|
+
return noEvidenceResult("receipt", warnings, sinceDays, runtime.telemetryDisclosure);
|
|
162
171
|
}
|
|
163
172
|
const summaryRecords = mode === "connected"
|
|
164
173
|
? selectProviderFinancialHeadlineRecords(records)
|
|
@@ -255,6 +264,10 @@ async function quickstartCommand(args, runtime = {}) {
|
|
|
255
264
|
groupBy,
|
|
256
265
|
color,
|
|
257
266
|
mode,
|
|
267
|
+
// Receipt-line truth: with telemetry enabled+noticed the receipt's
|
|
268
|
+
// privacy claim discloses the command counts instead of "nothing
|
|
269
|
+
// uploaded".
|
|
270
|
+
...(runtime.telemetryDisclosure === true ? { telemetryDisclosureLine } : {}),
|
|
258
271
|
...(providerCoverage ? { providerCoverage } : {}),
|
|
259
272
|
nextSteps,
|
|
260
273
|
deadContext,
|
|
@@ -519,7 +532,7 @@ async function glanceCommand(args) {
|
|
|
519
532
|
});
|
|
520
533
|
return ok(JSON.stringify(snapshot));
|
|
521
534
|
}
|
|
522
|
-
async function contextHealthCommand(args) {
|
|
535
|
+
async function contextHealthCommand(args, runtime = {}) {
|
|
523
536
|
const sinceDays = args.sinceDays ?? 30;
|
|
524
537
|
if (!validSinceDays(sinceDays))
|
|
525
538
|
return invalidSinceDaysResult();
|
|
@@ -544,9 +557,9 @@ async function contextHealthCommand(args) {
|
|
|
544
557
|
const qualitativeCoverage = summarizeCliQualitativeCoverage(logs);
|
|
545
558
|
return ok(args.json
|
|
546
559
|
? JSON.stringify({ ...health, qualitativeCoverage })
|
|
547
|
-
: `${renderContextHealth(health)}\n\n${renderCliQualitativeCoverage(qualitativeCoverage)}`);
|
|
560
|
+
: `${renderContextHealth(health, runtime.telemetryDisclosure)}\n\n${renderCliQualitativeCoverage(qualitativeCoverage)}`);
|
|
548
561
|
}
|
|
549
|
-
function renderContextHealth(health) {
|
|
562
|
+
function renderContextHealth(health, telemetryDisclosure) {
|
|
550
563
|
const status = health.status.replace("_", " ").toUpperCase();
|
|
551
564
|
const activation = health.activation;
|
|
552
565
|
const dead = health.deadContext;
|
|
@@ -591,7 +604,7 @@ function renderContextHealth(health) {
|
|
|
591
604
|
lines.push(` - ${evidence.summary} [${evidence.confidence}; ${evidence.source}]`);
|
|
592
605
|
}
|
|
593
606
|
}
|
|
594
|
-
lines.push("", "Data: local agent configuration + local Claude Code/Codex transcripts; hook commands were not run.",
|
|
607
|
+
lines.push("", "Data: local agent configuration + local Claude Code/Codex transcripts; hook commands were not run.", `Privacy: ${uploadsNothingLine(telemetryDisclosure, "this CLI run uploads nothing.")}`);
|
|
595
608
|
return lines.join("\n");
|
|
596
609
|
}
|
|
597
610
|
function quickstartNextSteps(mode, detected) {
|
|
@@ -605,10 +618,17 @@ function quickstartNextSteps(mode, detected) {
|
|
|
605
618
|
steps.push(`npx aibill connect ${detected[0].provider} set up the admin connector, then sync provider-reported cost`);
|
|
606
619
|
}
|
|
607
620
|
steps.push(mode === "demo"
|
|
608
|
-
|
|
621
|
+
// Every printed command must run as printed: the demo workspace does
|
|
622
|
+
// not exist yet, so the command creates it first (shipped-audit fix).
|
|
623
|
+
? "mkdir -p ./demo-workspace && npx aibill report --sample --path ./demo-workspace write a clearly labeled demo report in an explicitly narrow workspace"
|
|
609
624
|
: "npx aibill report write a shareable Markdown + HTML report");
|
|
610
625
|
steps.push("npx aibill --group-by project see which project has the most observed activity");
|
|
611
626
|
steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://asktilden.com");
|
|
627
|
+
if (mode === "demo") {
|
|
628
|
+
// Static pointer only — sample output is built for recordings and
|
|
629
|
+
// screenshots, so it never prompts (capture design moments map).
|
|
630
|
+
steps.push(signupCopy.samplePointer);
|
|
631
|
+
}
|
|
612
632
|
return steps;
|
|
613
633
|
}
|
|
614
634
|
async function readPersistedSpend(rootPath, options = {}) {
|
|
@@ -933,7 +953,183 @@ function renderCliQualitativeCoverage(coverage) {
|
|
|
933
953
|
`${coverage.readCompletely}/${coverage.selectedFiles} selected files read completely · ` +
|
|
934
954
|
`${coverage.skippedForBudget} eligible files skipped by budget`;
|
|
935
955
|
}
|
|
936
|
-
|
|
956
|
+
/**
|
|
957
|
+
* `aibill signup <email> [--ref <token>]` — the explicit, deliberate path to
|
|
958
|
+
* the launch list. Sends EXACTLY {email, ref} to the deployed waitlist route
|
|
959
|
+
* after showing the literal payload JSON and receiving a typed `y` on a real
|
|
960
|
+
* terminal. Never sends without the confirm; never retries, queues, or
|
|
961
|
+
* persists the typed email on failure. `--forget` clears local signup state;
|
|
962
|
+
* `--never` records never-ask without sending anything.
|
|
963
|
+
*/
|
|
964
|
+
async function signupCommand(args, runtime) {
|
|
965
|
+
const stateFile = signupStateFilePath(runtime.homeDirectory);
|
|
966
|
+
if (args.signupForget) {
|
|
967
|
+
const cleared = await clearSignupState(stateFile);
|
|
968
|
+
return cleared
|
|
969
|
+
? ok(signupCopy.forgetLine)
|
|
970
|
+
: { exitCode: 1, stdout: "", stderr: "local signup state could not be cleared; check ~/.aibill permissions" };
|
|
971
|
+
}
|
|
972
|
+
const priorRead = await readSignupState(stateFile);
|
|
973
|
+
const priorAskCount = priorRead.kind === "ok" ? priorRead.state.askCount : 0;
|
|
974
|
+
if (args.signupNever) {
|
|
975
|
+
const written = await writeSignupState(stateFile, { version: 1, status: "never", askCount: priorAskCount });
|
|
976
|
+
return written
|
|
977
|
+
? ok(signupCopy.neverLine)
|
|
978
|
+
: { exitCode: 1, stdout: "", stderr: "never-ask could not be persisted; check ~/.aibill permissions" };
|
|
979
|
+
}
|
|
980
|
+
if (!args.signupEmail) {
|
|
981
|
+
return {
|
|
982
|
+
exitCode: 1,
|
|
983
|
+
stdout: "",
|
|
984
|
+
stderr: [
|
|
985
|
+
"signup needs an email: npx aibill signup you@work.com [--ref <token>]",
|
|
986
|
+
"Optional: signup --never (never ask again) · signup --forget (clear local signup state)"
|
|
987
|
+
].join("\n")
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
// Consent requires a human at a real terminal; there is deliberately no
|
|
991
|
+
// --yes flag and no non-TTY path (QA 2).
|
|
992
|
+
if (runtime.interactive !== true || !runtime.prompt) {
|
|
993
|
+
return { exitCode: 1, stdout: "", stderr: signupCopy.nonInteractiveLine };
|
|
994
|
+
}
|
|
995
|
+
const email = normalizeWaitlistEmail(args.signupEmail);
|
|
996
|
+
if (email === undefined) {
|
|
997
|
+
return { exitCode: 1, stdout: "", stderr: signupCopy.invalidEmailLine };
|
|
998
|
+
}
|
|
999
|
+
// Deliverability (MX with A fallback, 1.5s budget, fail-open on DNS
|
|
1000
|
+
// trouble): only a provable cannot-receive domain or a throwaway inbox is
|
|
1001
|
+
// refused — never a slow or offline resolver.
|
|
1002
|
+
const deliverability = await assessEmailDeliverability(email, {
|
|
1003
|
+
...(runtime.signupDns ? { resolver: runtime.signupDns } : {})
|
|
1004
|
+
});
|
|
1005
|
+
if (deliverability === "no_mx") {
|
|
1006
|
+
return { exitCode: 1, stdout: "", stderr: signupCopy.noMxLine };
|
|
1007
|
+
}
|
|
1008
|
+
if (deliverability === "disposable") {
|
|
1009
|
+
return { exitCode: 1, stdout: "", stderr: signupCopy.disposableLine };
|
|
1010
|
+
}
|
|
1011
|
+
if (priorRead.kind === "ok" && priorRead.state.status === "subscribed" && priorRead.state.email === email) {
|
|
1012
|
+
// Cosmetic-local dedupe; the route itself is idempotent (201 on duplicate).
|
|
1013
|
+
return ok(signupCopy.alreadyLine);
|
|
1014
|
+
}
|
|
1015
|
+
const payload = { email, ref: buildWaitlistRef("signup", args.signupRef) };
|
|
1016
|
+
const consent = (await runtime.prompt(`${signupCopy.scopeLine}\n${signupCopy.consentQuestion(serializeWaitlistPayload(payload))}`)).trim().toLowerCase();
|
|
1017
|
+
if (consent !== "y" && consent !== "yes") {
|
|
1018
|
+
return ok(signupCopy.nothingSentLine);
|
|
1019
|
+
}
|
|
1020
|
+
const outcome = await postWaitlistSignup(payload, { fetchImpl: runtime.waitlistFetch });
|
|
1021
|
+
if (outcome === "sent") {
|
|
1022
|
+
await writeSignupState(stateFile, { version: 1, status: "subscribed", askCount: priorAskCount, email });
|
|
1023
|
+
return ok(signupCopy.sentLine);
|
|
1024
|
+
}
|
|
1025
|
+
return {
|
|
1026
|
+
exitCode: 1,
|
|
1027
|
+
stdout: "",
|
|
1028
|
+
stderr: outcome === "invalid_email"
|
|
1029
|
+
? signupCopy.invalidEmailLine
|
|
1030
|
+
: outcome === "rate_limited"
|
|
1031
|
+
? signupCopy.rateLimitedLine
|
|
1032
|
+
: signupCopy.unreachableLine
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* `aibill telemetry [on|off]` — inspect or switch anonymous command-count
|
|
1037
|
+
* telemetry. Status shows the EXACT last payload verbatim; state is
|
|
1038
|
+
* fail-closed (corrupt/readonly ⇒ off).
|
|
1039
|
+
*/
|
|
1040
|
+
async function telemetryCommand(args, runtime) {
|
|
1041
|
+
const filePath = telemetryStateFilePath(runtime.homeDirectory);
|
|
1042
|
+
const read = await readTelemetryState(filePath);
|
|
1043
|
+
const action = args.telemetryAction;
|
|
1044
|
+
if (action !== undefined && action !== "on" && action !== "off") {
|
|
1045
|
+
return {
|
|
1046
|
+
exitCode: 1,
|
|
1047
|
+
stdout: "",
|
|
1048
|
+
stderr: `Unknown telemetry action: ${sanitizeSecretishError(action)}\nUse: aibill telemetry [on|off]`
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
if (action === "off") {
|
|
1052
|
+
const state = {
|
|
1053
|
+
version: 1,
|
|
1054
|
+
installId: read.kind === "ok" ? read.state.installId : randomUUID(),
|
|
1055
|
+
enabled: false,
|
|
1056
|
+
...(read.kind === "ok" && read.state.noticedAt !== undefined ? { noticedAt: read.state.noticedAt } : {}),
|
|
1057
|
+
...(read.kind === "ok" && read.state.lastPayload !== undefined ? { lastPayload: read.state.lastPayload } : {})
|
|
1058
|
+
};
|
|
1059
|
+
if (!await writeTelemetryState(filePath, state)) {
|
|
1060
|
+
// FAIL CLOSED (QA M1): the off could not be persisted and the stored
|
|
1061
|
+
// state may still say enabled — silence THIS process and point at the
|
|
1062
|
+
// env kill-switches for a durable off.
|
|
1063
|
+
killTelemetryForThisProcess();
|
|
1064
|
+
return {
|
|
1065
|
+
exitCode: 1,
|
|
1066
|
+
stdout: "",
|
|
1067
|
+
stderr: [
|
|
1068
|
+
"telemetry off could not be persisted — nothing more will be sent by this run.",
|
|
1069
|
+
"For a durable off, set AI_SPEND_NO_TELEMETRY=1 or DO_NOT_TRACK=1, then fix ~/.aibill permissions."
|
|
1070
|
+
].join("\n")
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
killTelemetryForThisProcess();
|
|
1074
|
+
return ok("telemetry off · nothing is sent");
|
|
1075
|
+
}
|
|
1076
|
+
if (action === "on") {
|
|
1077
|
+
// Turning telemetry on explicitly IS the notice moment: the user is
|
|
1078
|
+
// reading this command's output, so noticedAt is stamped now and
|
|
1079
|
+
// events begin on the next run.
|
|
1080
|
+
const state = {
|
|
1081
|
+
version: 1,
|
|
1082
|
+
installId: read.kind === "ok" ? read.state.installId : randomUUID(),
|
|
1083
|
+
enabled: true,
|
|
1084
|
+
noticedAt: new Date().toISOString(),
|
|
1085
|
+
...(read.kind === "ok" && read.state.lastPayload !== undefined ? { lastPayload: read.state.lastPayload } : {})
|
|
1086
|
+
};
|
|
1087
|
+
if (!await writeTelemetryState(filePath, state)) {
|
|
1088
|
+
return {
|
|
1089
|
+
exitCode: 1,
|
|
1090
|
+
stdout: "",
|
|
1091
|
+
stderr: "telemetry state could not be written — telemetry remains off (unreadable state fails closed)"
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
return ok([
|
|
1095
|
+
"telemetry on · anonymous command counts only",
|
|
1096
|
+
`counted: command name, version, os, arch, ci flag, duration bucket, ok flag, timestamp`,
|
|
1097
|
+
"never: arguments, paths, file contents, project names, or your email",
|
|
1098
|
+
"events start with your next run · see payloads anytime: aibill telemetry"
|
|
1099
|
+
].join("\n"));
|
|
1100
|
+
}
|
|
1101
|
+
const lines = ["aibill telemetry"];
|
|
1102
|
+
if (read.kind === "unreadable") {
|
|
1103
|
+
lines.push("status: off (state unreadable — telemetry fails closed)");
|
|
1104
|
+
}
|
|
1105
|
+
else if (read.kind === "fresh") {
|
|
1106
|
+
lines.push("status: not yet noticed · nothing has ever been sent");
|
|
1107
|
+
lines.push("a one-time notice prints after your next interactive run; events begin only after it");
|
|
1108
|
+
}
|
|
1109
|
+
else if (!read.state.enabled) {
|
|
1110
|
+
lines.push("status: off · nothing is sent");
|
|
1111
|
+
}
|
|
1112
|
+
else if (read.state.noticedAt === undefined) {
|
|
1113
|
+
lines.push("status: on but not yet noticed · nothing has been sent");
|
|
1114
|
+
}
|
|
1115
|
+
else {
|
|
1116
|
+
lines.push(`status: on · noticed ${read.state.noticedAt}`);
|
|
1117
|
+
}
|
|
1118
|
+
lines.push("counted: command name, version, os, arch, ci flag, duration bucket, ok flag, timestamp", "never: arguments, paths, file contents, project names, or your email");
|
|
1119
|
+
if (read.kind === "ok" && read.state.lastPayload !== undefined) {
|
|
1120
|
+
lines.push("last payload sent (verbatim):", read.state.lastPayload);
|
|
1121
|
+
}
|
|
1122
|
+
else {
|
|
1123
|
+
lines.push("last payload sent: none");
|
|
1124
|
+
}
|
|
1125
|
+
lines.push("switch: aibill telemetry on · aibill telemetry off");
|
|
1126
|
+
return ok(lines.join("\n"));
|
|
1127
|
+
}
|
|
1128
|
+
/** The run-level privacy claim: literal truth in both telemetry states. */
|
|
1129
|
+
function uploadsNothingLine(telemetryDisclosure, original) {
|
|
1130
|
+
return telemetryDisclosure === true ? telemetryDisclosureLine : original;
|
|
1131
|
+
}
|
|
1132
|
+
function noEvidenceResult(surface, warnings, sinceDays, telemetryDisclosure) {
|
|
937
1133
|
const surfaceLine = surface === "watch"
|
|
938
1134
|
? "Watch has no financial baseline yet; no zero total or sample activity was recorded."
|
|
939
1135
|
: surface === "report-card"
|
|
@@ -947,11 +1143,12 @@ function noEvidenceResult(surface, warnings, sinceDays) {
|
|
|
947
1143
|
"",
|
|
948
1144
|
surfaceLine,
|
|
949
1145
|
"Looked for: Claude Code, Codex, and Gemini CLI local history.",
|
|
950
|
-
"Nothing was uploaded. No sample data was substituted
|
|
1146
|
+
`${uploadsNothingLine(telemetryDisclosure, "Nothing was uploaded.")} No sample data was substituted.`,
|
|
951
1147
|
...warnings.map((warning) => `! ${warning}`),
|
|
952
1148
|
"",
|
|
953
1149
|
"Next",
|
|
954
|
-
" npx aibill doctor --sources see the exact evidence gap and setup paths"
|
|
1150
|
+
" npx aibill doctor --sources see the exact evidence gap and setup paths",
|
|
1151
|
+
` ${signupCopy.receiptPointer}`
|
|
955
1152
|
].join("\n")
|
|
956
1153
|
: "",
|
|
957
1154
|
stderr: surface === "receipt"
|
|
@@ -1008,7 +1205,7 @@ function dataModeBanner(mode, records) {
|
|
|
1008
1205
|
return "DATA MODE: connected provider billing";
|
|
1009
1206
|
return "DATA MODE: demo sample (illustrative — not your real spend)";
|
|
1010
1207
|
}
|
|
1011
|
-
async function doctorCommand(args) {
|
|
1208
|
+
async function doctorCommand(args, runtime = {}) {
|
|
1012
1209
|
if (args.sources) {
|
|
1013
1210
|
return doctorSourcesCommand(args);
|
|
1014
1211
|
}
|
|
@@ -1073,7 +1270,9 @@ async function doctorCommand(args) {
|
|
|
1073
1270
|
"aibill doctor",
|
|
1074
1271
|
`node version: ${process.version}`,
|
|
1075
1272
|
`cli version: ${await cliVersion()}`,
|
|
1076
|
-
|
|
1273
|
+
runtime.telemetryDisclosure === true
|
|
1274
|
+
? "local-first mode: enabled (evidence stays local · anonymous command counts shared · aibill telemetry off)"
|
|
1275
|
+
: "local-first mode: enabled (no cloud upload, no telemetry)",
|
|
1077
1276
|
`path: ${rootPath}`,
|
|
1078
1277
|
`state directory: ${stateDir}`,
|
|
1079
1278
|
`state mode: ${stateMode}`,
|
|
@@ -1632,7 +1831,9 @@ async function installStatuslineCommand(args, runtime) {
|
|
|
1632
1831
|
? "aibill statusline is already installed in Claude user settings."
|
|
1633
1832
|
: "aibill statusline installed in Claude user settings.",
|
|
1634
1833
|
"Claude Code: run /status to verify the active setting and every managed source.",
|
|
1635
|
-
"The renderer reads only the private aibill cache; it never reads Claude's session stdin as financial evidence."
|
|
1834
|
+
"The renderer reads only the private aibill cache; it never reads Claude's session stdin as financial evidence.",
|
|
1835
|
+
// Static pointer only — never a prompt (capture design moments map).
|
|
1836
|
+
signupCopy.statuslinePointer
|
|
1636
1837
|
].join("\n"));
|
|
1637
1838
|
}
|
|
1638
1839
|
catch (error) {
|
|
@@ -1761,7 +1962,8 @@ async function initCommand(args, runtime = {}) {
|
|
|
1761
1962
|
trustedProviderState: persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === true,
|
|
1762
1963
|
untrustedProviderState: persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted !== true,
|
|
1763
1964
|
scanError,
|
|
1764
|
-
cacheStatus
|
|
1965
|
+
cacheStatus,
|
|
1966
|
+
...(runtime.telemetryDisclosure !== undefined ? { telemetryDisclosure: runtime.telemetryDisclosure } : {})
|
|
1765
1967
|
});
|
|
1766
1968
|
if (!args.statusline) {
|
|
1767
1969
|
return ok(`${receipt}\noptional Claude Code status line: npx aibill statusline install`);
|
|
@@ -2198,15 +2400,37 @@ function expansionFreshness(snapshot, now) {
|
|
|
2198
2400
|
: seconds < 48 * 3_600
|
|
2199
2401
|
? `${Math.floor(seconds / 3_600)}h`
|
|
2200
2402
|
: `${Math.floor(seconds / 86_400)}d`;
|
|
2201
|
-
|
|
2403
|
+
// Same freshness vocabulary as the statusline runner: state the cache age
|
|
2404
|
+
// as fact instead of calling minutes-old data "stale".
|
|
2405
|
+
return ageMs > 5 * 60 * 1_000 ? `cache ${age} old` : `updated ${age}`;
|
|
2202
2406
|
}
|
|
2203
2407
|
async function preflightInitCache(cacheDirectory) {
|
|
2204
2408
|
const existing = await readActivitySnapshot({ cacheDirectory });
|
|
2205
2409
|
if (existing.status !== "error")
|
|
2206
2410
|
return;
|
|
2411
|
+
// A pre-created but EMPTY cache directory holds nothing to preserve —
|
|
2412
|
+
// typically a user-made dir with default (non-0700) permissions. Init's
|
|
2413
|
+
// own create path re-validates and tightens it to 0700, so proceeding is
|
|
2414
|
+
// safe; only a directory with actual contents aborts (shipped-audit fix).
|
|
2415
|
+
if (existing.code === "unsafe_directory" && await isEmptyRealDirectory(dirname(activitySnapshotCachePath({
|
|
2416
|
+
...(cacheDirectory ? { cacheDirectory } : {})
|
|
2417
|
+
})))) {
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2207
2420
|
throw new Error(`Existing private activity cache is ${existing.code.replaceAll("_", " ")}; ` +
|
|
2208
2421
|
"it was preserved and init stopped. Remove the cache explicitly before rebuilding it.");
|
|
2209
2422
|
}
|
|
2423
|
+
async function isEmptyRealDirectory(path) {
|
|
2424
|
+
try {
|
|
2425
|
+
const info = await lstat(path);
|
|
2426
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
2427
|
+
return false;
|
|
2428
|
+
return (await readdir(path)).length === 0;
|
|
2429
|
+
}
|
|
2430
|
+
catch {
|
|
2431
|
+
return false;
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2210
2434
|
async function preserveOrCreateInitRegistry(stateDir, rootPath, asOf, existing) {
|
|
2211
2435
|
if (existing) {
|
|
2212
2436
|
// Leave the valid contract byte-for-byte alone. Rewriting would drop
|
|
@@ -2417,9 +2641,12 @@ function formatInitReceipt(input) {
|
|
|
2417
2641
|
...diagnosticLines,
|
|
2418
2642
|
input.scanError ? ` ! scan failed: ${input.scanError}` : "",
|
|
2419
2643
|
"",
|
|
2420
|
-
`status cache: ${input.cacheStatus} · private local aggregate · nothing uploaded`,
|
|
2644
|
+
`status cache: ${input.cacheStatus} · private local aggregate · ${uploadsNothingLine(input.telemetryDisclosure, "nothing uploaded")}`,
|
|
2421
2645
|
"state: .ai-spend-agent",
|
|
2422
2646
|
"manifest: written last",
|
|
2647
|
+
// Static pointer only, ABOVE the strict single next-command exit line —
|
|
2648
|
+
// init's `next:` line stays last (capture design moments map).
|
|
2649
|
+
signupCopy.initPointer,
|
|
2423
2650
|
"next: npx aibill doctor --sources"
|
|
2424
2651
|
].filter((line) => line !== "").join("\n");
|
|
2425
2652
|
}
|
|
@@ -2872,7 +3099,9 @@ async function listSourcesCommand(args) {
|
|
|
2872
3099
|
// not first-run blockers.
|
|
2873
3100
|
const selfServeProviders = new Set(["openai", "anthropic"]);
|
|
2874
3101
|
const adminUpgradeProviders = {
|
|
2875
|
-
|
|
3102
|
+
// Cursor's Admin API is gated to Enterprise teams (cursor.com/docs/api);
|
|
3103
|
+
// Business/Teams keys are rejected with 401/403.
|
|
3104
|
+
cursor: "requires a Cursor TEAM-ADMIN key (Enterprise teams only)",
|
|
2876
3105
|
"github-copilot": "requires a GitHub BILLING-ADMIN token (org/enterprise)",
|
|
2877
3106
|
copilot: "requires a GitHub BILLING-ADMIN token (org/enterprise)"
|
|
2878
3107
|
};
|
|
@@ -3469,7 +3698,7 @@ async function confirmMappingCommand(args) {
|
|
|
3469
3698
|
`confidence: ${mapping.confidence}`
|
|
3470
3699
|
].join("\n"));
|
|
3471
3700
|
}
|
|
3472
|
-
async function reportCommand(args) {
|
|
3701
|
+
async function reportCommand(args, runtime = {}) {
|
|
3473
3702
|
const rootPath = resolve(args.path);
|
|
3474
3703
|
try {
|
|
3475
3704
|
const sinceDays = args.sinceDays ?? 30;
|
|
@@ -3506,9 +3735,11 @@ async function reportCommand(args) {
|
|
|
3506
3735
|
const reportExperimentProjection = reportableExperiment
|
|
3507
3736
|
? buildActionVerificationProjectionV0(reportableExperiment)
|
|
3508
3737
|
: undefined;
|
|
3738
|
+
const telemetryDisclosure = runtime.telemetryDisclosure === true;
|
|
3509
3739
|
const reportRenderInput = reportableExperiment
|
|
3510
3740
|
? {
|
|
3511
3741
|
...reportInput,
|
|
3742
|
+
telemetryDisclosure,
|
|
3512
3743
|
tokenExperiment: {
|
|
3513
3744
|
id: reportableExperiment.id,
|
|
3514
3745
|
lifecycle: reportableExperiment.lifecycle,
|
|
@@ -3518,7 +3749,7 @@ async function reportCommand(args) {
|
|
|
3518
3749
|
nextCommand: improveRuntimeCommand
|
|
3519
3750
|
}
|
|
3520
3751
|
}
|
|
3521
|
-
: reportInput;
|
|
3752
|
+
: { ...reportInput, telemetryDisclosure };
|
|
3522
3753
|
const qualitativeActionsSuppressed = reportInput.dataMode !== "sample" &&
|
|
3523
3754
|
reportInput.qualitativeCoverage?.status !== "complete";
|
|
3524
3755
|
const outBase = args.out ? resolve(rootPath, args.out) : join(stateDir, "report");
|
|
@@ -3569,7 +3800,9 @@ async function reportCommand(args) {
|
|
|
3569
3800
|
!(reportInput.allRecords ?? reportInput.providerRecords ?? []).some((record) => typeof record.amountUsd === "number")
|
|
3570
3801
|
? "cost/value evidence total: Unavailable · no priced financial evidence; missing/null is not zero"
|
|
3571
3802
|
: `cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)}`,
|
|
3572
|
-
|
|
3803
|
+
runtime.telemetryDisclosure === true
|
|
3804
|
+
? "privacy: report rendered locally · anonymous command counts shared · aibill telemetry off; only explicit sync-provider contacts the selected provider"
|
|
3805
|
+
: "privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
|
|
3573
3806
|
"",
|
|
3574
3807
|
"next:",
|
|
3575
3808
|
` open ${htmlPath} view the full report in your browser`,
|
|
@@ -5599,14 +5832,6 @@ async function buildExplicitSampleReportInput(rootPath) {
|
|
|
5599
5832
|
};
|
|
5600
5833
|
}
|
|
5601
5834
|
async function buildReportInput(stateDir, rootPath, sinceDays = 30, options = {}) {
|
|
5602
|
-
// Known residual (QA m6, shipped documented in 0.9.1): in connected mode
|
|
5603
|
-
// this input stays billed-only — the saved report's evidence total omits
|
|
5604
|
-
// the local API-equivalent axis that the terminal receipt shows (it
|
|
5605
|
-
// under-discloses, never overclaims, and improve's waste lane is
|
|
5606
|
-
// unaffected because it derives from action evidence, not these records).
|
|
5607
|
-
// 0.9.2 (ROADMAP): ride localFinancialRecords into the report's evidence
|
|
5608
|
-
// section per-basis, exactly as the receipt does.
|
|
5609
|
-
//
|
|
5610
5835
|
// One anchor for logs, Context Health, the paste-ready prompt, and every
|
|
5611
5836
|
// supporting Apply artifact. This prevents millisecond window drift between
|
|
5612
5837
|
// files generated by the same command.
|
|
@@ -5696,13 +5921,17 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30, options = {}
|
|
|
5696
5921
|
freshLocalCalls = freshActionLogs.calls;
|
|
5697
5922
|
freshCodexInvocationFiles = freshActionLogs.codexInvocationFiles;
|
|
5698
5923
|
}
|
|
5924
|
+
// Financial and qualitative readers have separate truth contracts. Always
|
|
5925
|
+
// read the bounded/streaming financial axis once: local mode uses it as the
|
|
5926
|
+
// headline, while connected mode carries it into the saved report as a
|
|
5927
|
+
// separate API-equivalent comparison that is never added to billed cost.
|
|
5928
|
+
const freshFinancialLogs = await loadLocalAgentFinancialUsage({ financialIndex: cliFinancialIndex,
|
|
5929
|
+
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
5930
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
5931
|
+
geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
|
|
5932
|
+
sinceIso
|
|
5933
|
+
}).catch(() => undefined);
|
|
5699
5934
|
if (needsFreshLogs) {
|
|
5700
|
-
const freshFinancialLogs = await loadLocalAgentFinancialUsage({ financialIndex: cliFinancialIndex,
|
|
5701
|
-
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
5702
|
-
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
5703
|
-
geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
|
|
5704
|
-
sinceIso
|
|
5705
|
-
}).catch(() => undefined);
|
|
5706
5935
|
if (freshFinancialLogs && freshFinancialLogs.records.length > 0) {
|
|
5707
5936
|
const records = freshFinancialLogs.records;
|
|
5708
5937
|
const summary = analyzeSpend(records);
|
|
@@ -5845,6 +6074,9 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30, options = {}
|
|
|
5845
6074
|
allRecords: spendState.mode === "connected_provider"
|
|
5846
6075
|
? selectProviderFinancialHeadlineRecords(spendState.records ?? [])
|
|
5847
6076
|
: spendState.records ?? [],
|
|
6077
|
+
...(spendState.mode === "connected_provider" && (freshFinancialLogs?.records.length ?? 0) > 0
|
|
6078
|
+
? { localFinancialRecords: freshFinancialLogs.records }
|
|
6079
|
+
: {}),
|
|
5848
6080
|
dataMode: spendState.mode,
|
|
5849
6081
|
discovery,
|
|
5850
6082
|
mappings: mappings ?? [],
|
|
@@ -5929,6 +6161,12 @@ function parseArgs(argv) {
|
|
|
5929
6161
|
parsed.provider = rest[0];
|
|
5930
6162
|
rest.shift();
|
|
5931
6163
|
}
|
|
6164
|
+
if (command === "signup" && rest[0] && !rest[0].startsWith("-")) {
|
|
6165
|
+
parsed.signupEmail = rest.shift();
|
|
6166
|
+
}
|
|
6167
|
+
if (command === "telemetry" && rest[0] && !rest[0].startsWith("--")) {
|
|
6168
|
+
parsed.telemetryAction = rest.shift();
|
|
6169
|
+
}
|
|
5932
6170
|
if (command === "verify") {
|
|
5933
6171
|
const first = rest[0];
|
|
5934
6172
|
if (first === "inspect" || first === "start" || first === "mark-applied" ||
|
|
@@ -5959,7 +6197,8 @@ function parseArgs(argv) {
|
|
|
5959
6197
|
"--interval", "--cycles", "--canary", "--quality", "--change-digest",
|
|
5960
6198
|
"--rollback-digest", "--canary-digest", "--approved-at", "--applied-at",
|
|
5961
6199
|
"--pr", "--business-outcome",
|
|
5962
|
-
"--draft", "--record-applied-at", "--record-canary"
|
|
6200
|
+
"--draft", "--record-applied-at", "--record-canary",
|
|
6201
|
+
"--ref"
|
|
5963
6202
|
]);
|
|
5964
6203
|
const numericValueFlags = new Set([
|
|
5965
6204
|
"--since-days", "--confidence", "--start-time", "--end-time", "--interval", "--cycles", "--pr"
|
|
@@ -6110,6 +6349,30 @@ function parseArgs(argv) {
|
|
|
6110
6349
|
parsed.ignoreState = true;
|
|
6111
6350
|
continue;
|
|
6112
6351
|
}
|
|
6352
|
+
if (arg === "--forget") {
|
|
6353
|
+
parsed.signupForget = true;
|
|
6354
|
+
continue;
|
|
6355
|
+
}
|
|
6356
|
+
if (arg === "--never") {
|
|
6357
|
+
parsed.signupNever = true;
|
|
6358
|
+
continue;
|
|
6359
|
+
}
|
|
6360
|
+
if (arg === "--ref") {
|
|
6361
|
+
const next = rest[index + 1];
|
|
6362
|
+
if (next) {
|
|
6363
|
+
// Allowlist sanitization happens here so nothing outside
|
|
6364
|
+
// [a-z0-9-]{1,24} can ever reach the payload builder (QA 8).
|
|
6365
|
+
const tag = sanitizeSignupRefTag(next);
|
|
6366
|
+
if (tag === undefined) {
|
|
6367
|
+
parsed.parseErrors.push("--ref must be 1-24 lowercase letters, digits, or dashes (e.g. --ref starfund)");
|
|
6368
|
+
}
|
|
6369
|
+
else {
|
|
6370
|
+
parsed.signupRef = tag;
|
|
6371
|
+
}
|
|
6372
|
+
index += 1;
|
|
6373
|
+
}
|
|
6374
|
+
continue;
|
|
6375
|
+
}
|
|
6113
6376
|
if (arg === "--plan") {
|
|
6114
6377
|
const next = rest[index + 1];
|
|
6115
6378
|
if (next) {
|
|
@@ -6534,7 +6797,7 @@ function isNodeError(error, code) {
|
|
|
6534
6797
|
function ok(stdout) {
|
|
6535
6798
|
return { exitCode: 0, stdout, stderr: "" };
|
|
6536
6799
|
}
|
|
6537
|
-
function helpText() {
|
|
6800
|
+
function helpText(telemetryDisclosure) {
|
|
6538
6801
|
return [
|
|
6539
6802
|
"aibill — your AI cost and usage evidence in one private view",
|
|
6540
6803
|
"",
|
|
@@ -6544,11 +6807,11 @@ function helpText() {
|
|
|
6544
6807
|
" npx aibill --sample Show the clearly labeled illustrative demo (never implicit)",
|
|
6545
6808
|
" npx aibill --group-by agent Drill down by source|model|client|project|agent|user|workspace|apiKey",
|
|
6546
6809
|
" npx aibill --plan <id> Declare your plan when auto-detection can't (claude-max-5x|claude-max-20x|claude-pro|chatgpt-plus|chatgpt-pro)",
|
|
6547
|
-
` ${improveRuntimeCommand}
|
|
6548
|
-
` ${actionRuntimeCommand("index")}
|
|
6549
|
-
` ${actionRuntimeCommand("identify")}
|
|
6550
|
-
` ${actionRuntimeCommand("outcome github")}
|
|
6551
|
-
` ${actionRuntimeCommand("accountability")}
|
|
6810
|
+
` ${improveRuntimeCommand} Test one personalized change and measure whether token usage fell`,
|
|
6811
|
+
` ${actionRuntimeCommand("index")} Read very large agent histories to completion so results stop saying "indexing"`,
|
|
6812
|
+
` ${actionRuntimeCommand("identify")} Confirm the human owner, team, client/cost center, and approval role`,
|
|
6813
|
+
` ${actionRuntimeCommand("outcome github")} Attach one merged PR whose observed status checks passed`,
|
|
6814
|
+
` ${actionRuntimeCommand("accountability")} Answer owner → outcome → approval → measured-result for this project`,
|
|
6552
6815
|
"",
|
|
6553
6816
|
"Add official provider-reported cost (ADMIN/owner-gated):",
|
|
6554
6817
|
" npx aibill connect openai Requires an org-owner Admin credential reference",
|
|
@@ -6571,6 +6834,9 @@ function helpText() {
|
|
|
6571
6834
|
" [--replace] Explicitly replace an existing statusLine while preserving it for uninstall",
|
|
6572
6835
|
" statusline uninstall Remove only the owned setting and restore its preserved predecessor",
|
|
6573
6836
|
" statusline expand Print every subscription with committed price, runways, and 7d API-equivalent",
|
|
6837
|
+
" signup <email> [--ref <token>] Join the launch list · email only · the exact payload is shown before send",
|
|
6838
|
+
" [--never] Never ask again (nothing is sent) [--forget] Clear local signup state",
|
|
6839
|
+
" telemetry [on|off] Show anonymous command-count status + the exact last payload · switch it",
|
|
6574
6840
|
" doctor [--sources] Launch diagnostics; --sources shows validation, evidence, freshness, and errors",
|
|
6575
6841
|
" reset [--path <dir>] Clear persisted spend state (so sample state can't mask real logs)",
|
|
6576
6842
|
" --ignore-state On the default/quickstart run, ignore persisted spend.json for this run",
|
|
@@ -6604,7 +6870,9 @@ function helpText() {
|
|
|
6604
6870
|
"Cron (production watch): add a crontab entry such as:",
|
|
6605
6871
|
" 0 * * * * cd /path/to/workspace && npx --yes aibill watch --interval 3600 --cycles 1 >> aibill-watch.log 2>&1",
|
|
6606
6872
|
"",
|
|
6607
|
-
|
|
6873
|
+
telemetryDisclosure === true
|
|
6874
|
+
? "Privacy: local analysis and reports upload nothing; anonymous command counts shared · aibill telemetry off. Only explicit sync-provider contacts the selected provider through an env: reference."
|
|
6875
|
+
: "Privacy: local analysis and reports upload nothing. Only explicit sync-provider contacts the selected provider through an env: reference.",
|
|
6608
6876
|
"aibill never sits in the inference path and never stores, prints, or proxies provider credentials."
|
|
6609
6877
|
].join("\n");
|
|
6610
6878
|
}
|
|
@@ -6640,10 +6908,40 @@ export async function runMain() {
|
|
|
6640
6908
|
const argv = process.argv.slice(2);
|
|
6641
6909
|
const command = argv[0];
|
|
6642
6910
|
const isInstantDemo = !command || command === "quickstart" || command === "demo";
|
|
6643
|
-
|
|
6644
|
-
|
|
6911
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
6912
|
+
const startedAtMs = Date.now();
|
|
6913
|
+
// Bin-entry-only telemetry (notice-before-first-byte; see telemetry.ts).
|
|
6914
|
+
// Embedded runCli callers and the MCP server never construct this, so
|
|
6915
|
+
// they can never emit an event.
|
|
6916
|
+
let telemetry;
|
|
6917
|
+
try {
|
|
6918
|
+
const { openCliTelemetry } = await import("./telemetry.js");
|
|
6919
|
+
telemetry = await openCliTelemetry();
|
|
6920
|
+
}
|
|
6921
|
+
catch {
|
|
6922
|
+
// Telemetry must never break the CLI.
|
|
6923
|
+
}
|
|
6924
|
+
// The during-scan signup ask (capture design, placement addendum
|
|
6925
|
+
// 2026-08-24): on a qualifying interactive first run the ask fills the
|
|
6926
|
+
// scan wait — it prints its own wait line, so the spinner stays off. A
|
|
6927
|
+
// run that does not qualify (or whose signup state disallows the ask)
|
|
6928
|
+
// takes the exact fast path below, byte-identical.
|
|
6929
|
+
let preAsk;
|
|
6930
|
+
if (interactive && !process.env.CI && !process.env.AI_SPEND_NO_PROMPT) {
|
|
6931
|
+
try {
|
|
6932
|
+
const signup = await import("./signup.js");
|
|
6933
|
+
if (signup.qualifiesForPreReceiptSignupAsk(argv)) {
|
|
6934
|
+
preAsk = await signup.openPreReceiptSignupAskInTerminal();
|
|
6935
|
+
}
|
|
6936
|
+
}
|
|
6937
|
+
catch {
|
|
6938
|
+
// The ask must never break the receipt path.
|
|
6939
|
+
}
|
|
6940
|
+
}
|
|
6941
|
+
// Show a spinner only for the work-heavy instant-demo path, only on a
|
|
6942
|
+
// real TTY so piped output stays clean, and never over the open ask.
|
|
6645
6943
|
let spinner;
|
|
6646
|
-
if (isInstantDemo && process.stdout.isTTY && !process.env.NO_COLOR) {
|
|
6944
|
+
if (isInstantDemo && !preAsk && process.stdout.isTTY && !process.env.NO_COLOR) {
|
|
6647
6945
|
try {
|
|
6648
6946
|
const { default: yoctoSpinner } = await import("yocto-spinner");
|
|
6649
6947
|
spinner = yoctoSpinner({ text: "Reading local AI evidence…" }).start();
|
|
@@ -6653,13 +6951,14 @@ export async function runMain() {
|
|
|
6653
6951
|
}
|
|
6654
6952
|
}
|
|
6655
6953
|
let result;
|
|
6954
|
+
let askOutcome = { kind: "no_ask" };
|
|
6656
6955
|
let promptInterface;
|
|
6657
6956
|
let guidedInterface;
|
|
6658
6957
|
try {
|
|
6659
|
-
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
6660
6958
|
let guidedIoShared;
|
|
6661
|
-
|
|
6959
|
+
const runPipeline = () => runCli(argv, {
|
|
6662
6960
|
interactive,
|
|
6961
|
+
...(telemetry?.disclosureActive === true ? { telemetryDisclosure: true } : {}),
|
|
6663
6962
|
...(interactive ? {
|
|
6664
6963
|
prompt: async (question) => {
|
|
6665
6964
|
if (!promptInterface) {
|
|
@@ -6688,6 +6987,21 @@ export async function runMain() {
|
|
|
6688
6987
|
}
|
|
6689
6988
|
} : {})
|
|
6690
6989
|
});
|
|
6990
|
+
if (preAsk) {
|
|
6991
|
+
// Receipt renders only when BOTH the human's answer (bounded by the
|
|
6992
|
+
// ask's own timeouts) and the pipeline have resolved; a pipeline
|
|
6993
|
+
// error still waits for the bounded ask, then re-throws into the
|
|
6994
|
+
// error voice below — never a hung prompt over a dead pipeline.
|
|
6995
|
+
const { orchestratePreReceiptAsk } = await import("./signup.js");
|
|
6996
|
+
const orchestrated = await orchestratePreReceiptAsk({ session: preAsk.session, runPipeline });
|
|
6997
|
+
askOutcome = orchestrated.outcome;
|
|
6998
|
+
if (!orchestrated.pipeline.ok)
|
|
6999
|
+
throw orchestrated.pipeline.error;
|
|
7000
|
+
result = orchestrated.pipeline.value;
|
|
7001
|
+
}
|
|
7002
|
+
else {
|
|
7003
|
+
result = await runPipeline();
|
|
7004
|
+
}
|
|
6691
7005
|
}
|
|
6692
7006
|
catch (error) {
|
|
6693
7007
|
// The product's error voice, never a raw stack trace — and never an
|
|
@@ -6698,7 +7012,7 @@ export async function runMain() {
|
|
|
6698
7012
|
stdout: "",
|
|
6699
7013
|
stderr: [
|
|
6700
7014
|
`aibill hit an unexpected error: ${message}`,
|
|
6701
|
-
"Nothing was uploaded. The command stopped without completing; run diagnostics before retrying
|
|
7015
|
+
`${telemetry?.disclosureActive === true ? telemetryDisclosureLine : "Nothing was uploaded."} The command stopped without completing; run diagnostics before retrying.`,
|
|
6702
7016
|
"Try `npx aibill doctor` for diagnostics, or open an issue: https://github.com/futurastudio/ai-spend-agent/issues"
|
|
6703
7017
|
].join("\n")
|
|
6704
7018
|
};
|
|
@@ -6708,6 +7022,11 @@ export async function runMain() {
|
|
|
6708
7022
|
guidedInterface?.close();
|
|
6709
7023
|
spinner?.stop();
|
|
6710
7024
|
}
|
|
7025
|
+
// A read that ended without the user pressing Enter (timeout / Ctrl-C)
|
|
7026
|
+
// left the prompt line open; start the receipt on a fresh line.
|
|
7027
|
+
if (preAsk?.needsFreshLine()) {
|
|
7028
|
+
process.stdout.write("\n");
|
|
7029
|
+
}
|
|
6711
7030
|
if (result.stdout) {
|
|
6712
7031
|
console.log(result.stdout);
|
|
6713
7032
|
}
|
|
@@ -6715,6 +7034,29 @@ export async function runMain() {
|
|
|
6715
7034
|
console.error(result.stderr);
|
|
6716
7035
|
}
|
|
6717
7036
|
process.exitCode = result.exitCode;
|
|
7037
|
+
// Consent renders strictly AFTER the receipt: scope line, the literal
|
|
7038
|
+
// payload JSON, a typed y, ONE POST. Nothing here can change the
|
|
7039
|
+
// receipt's bytes or exit code.
|
|
7040
|
+
if (preAsk && askOutcome.kind === "email") {
|
|
7041
|
+
await preAsk.runConsent(askOutcome);
|
|
7042
|
+
}
|
|
7043
|
+
preAsk?.close();
|
|
7044
|
+
// Telemetry LAST: on the first interactive run this prints the one-time
|
|
7045
|
+
// notice (and stamps it); on later noticed runs it fires ONE
|
|
7046
|
+
// fire-and-forget event with a hard 1500ms abort. Never blocks the
|
|
7047
|
+
// receipt, never changes the exit code.
|
|
7048
|
+
try {
|
|
7049
|
+
await telemetry?.finish({
|
|
7050
|
+
argv,
|
|
7051
|
+
ok: result.exitCode === 0,
|
|
7052
|
+
durationMs: Date.now() - startedAtMs,
|
|
7053
|
+
interactive,
|
|
7054
|
+
version: await cliVersion()
|
|
7055
|
+
});
|
|
7056
|
+
}
|
|
7057
|
+
catch {
|
|
7058
|
+
// Telemetry must never break the CLI.
|
|
7059
|
+
}
|
|
6718
7060
|
}
|
|
6719
7061
|
if (invokedAsMain) {
|
|
6720
7062
|
await runMain();
|