ai-spend-agent 0.9.4 → 0.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +10 -0
- package/dist/index.js +464 -173
- package/dist/reportOpener.d.ts +41 -0
- package/dist/reportOpener.js +156 -0
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { type GuidedPromptSource } from "./guidedPrompt.js";
|
|
3
3
|
import { type SignupDnsResolver } from "./signup.js";
|
|
4
|
+
import { decideReportAutoOpen, openReportInBrowser } from "./reportOpener.js";
|
|
4
5
|
export type CliResult = {
|
|
5
6
|
exitCode: number;
|
|
6
7
|
stdout: string;
|
|
@@ -44,6 +45,15 @@ export type CliRuntimeOptions = {
|
|
|
44
45
|
* Embedded/MCP callers never set it (and never emit telemetry).
|
|
45
46
|
*/
|
|
46
47
|
telemetryDisclosure?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Test seams for `report`'s HTML auto-open (0.9.5): decide computes the
|
|
50
|
+
* truthful open/suppress verdict (platform, TTY, CI/SSH, --no-open,
|
|
51
|
+
* AI_SPEND_NO_OPEN); open fires the detached platform opener. Production
|
|
52
|
+
* uses the real implementations; tests inject stubs to pin the opener
|
|
53
|
+
* argv per platform, every suppression path, and summary-line truth.
|
|
54
|
+
*/
|
|
55
|
+
reportOpenDecide?: typeof decideReportAutoOpen;
|
|
56
|
+
reportOpenLaunch?: typeof openReportInBrowser;
|
|
47
57
|
};
|
|
48
58
|
export declare function runCli(argv?: string[], runtime?: CliRuntimeOptions): Promise<CliResult>;
|
|
49
59
|
/**
|
package/dist/index.js
CHANGED
|
@@ -3,13 +3,13 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { realpathSync } from "node:fs";
|
|
4
4
|
import { lstat, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
|
-
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
6
|
+
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
8
|
import { askGuidedQuestion, classifyGuidedAnswer, createInteractivePromptSource, renderForYourAgent } from "./guidedPrompt.js";
|
|
9
9
|
import { assessEmailDeliverability, buildWaitlistRef, normalizeWaitlistEmail, postWaitlistSignup, readSignupState, clearSignupState, sanitizeSignupRefTag, serializeWaitlistPayload, signupAskTimeoutMs, signupCopy, signupStateFilePath, writeSignupState } from "./signup.js";
|
|
10
10
|
import { killTelemetryForThisProcess, readTelemetryState, telemetryDisclosureLine, telemetryStateFilePath, writeTelemetryState } from "./telemetry.js";
|
|
11
11
|
import { parsePlanDraft, renderCleanExit, runIdentitySequence, runPlanSitting, runQualitySitting, runRecordSitting, runRollbackSitting, runStartSitting, shortSittingHint } from "./improveFlow.js";
|
|
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";
|
|
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, generateCutList, 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";
|
|
13
13
|
import { StatuslineInstallerError, installClaudeStatusline, refreshOwnedStatuslineRunner, uninstallClaudeStatusline } from "./statuslineInstaller.js";
|
|
14
14
|
import { readStatuslineCache, renderStatusline } from "./statuslineRuntime.js";
|
|
15
15
|
import { chooseLatestTokenReductionExperiment, loadTokenVerificationState, upsertTokenReductionExperiment } from "./tokenVerificationState.js";
|
|
@@ -17,7 +17,8 @@ import { buildGuidedExperience } from "./guidedExperience.js";
|
|
|
17
17
|
import { buildImproveExperience } from "./improveExperience.js";
|
|
18
18
|
import { appendAcceptedProjectOutcome, appendProjectApprovalEvent, createProjectAccountabilityOwnership, loadProjectAccountabilityState, projectAccountabilityStatePath, upsertConfirmedProjectOwnership } from "./projectAccountabilityState.js";
|
|
19
19
|
import { fetchGitHubAcceptedOutcomeV0 } from "./githubAcceptedOutcome.js";
|
|
20
|
-
import {
|
|
20
|
+
import { decideReportAutoOpen, openReportInBrowser, platformOpenCommand } from "./reportOpener.js";
|
|
21
|
+
import { generateActionPlanMarkdown, generateApplyArtifactMarkdown, generateCommandSummary, generateDemoPackageMarkdown, generateHtmlReport, generateMarkdownReport, generatePlainEnglishSummary, generatePolicyConfigDraftMarkdown, generateReceiptCompanionHtml, generateReportCardCaption, generateReportCardSvg, generateVerificationPlanMarkdown, groupByDimensions, shellPathPointer } from "@agent-finops/report";
|
|
21
22
|
// One shared v2 sharded store instance for BOTH evidence kinds: the v1
|
|
22
23
|
// monolithic qualitative adapter re-probed git privacy on every read (176
|
|
23
24
|
// spawned git processes per warm run with $HOME itself a git repo) and
|
|
@@ -101,7 +102,7 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
|
101
102
|
return reportCommand(args, runtime);
|
|
102
103
|
}
|
|
103
104
|
if (args.command === "report-card") {
|
|
104
|
-
return reportCardCommand(args);
|
|
105
|
+
return reportCardCommand(args, runtime);
|
|
105
106
|
}
|
|
106
107
|
if (args.command === "glance") {
|
|
107
108
|
return glanceCommand(args);
|
|
@@ -165,52 +166,19 @@ async function quickstartCommand(args, runtime = {}) {
|
|
|
165
166
|
stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
|
|
166
167
|
};
|
|
167
168
|
}
|
|
168
|
-
|
|
169
|
+
// ONE evidence bundle, shared with the machine-wide `report` (0.9.6) so the
|
|
170
|
+
// readout and the written artifact can never disagree.
|
|
171
|
+
const evidence = await loadBroadScanEvidence(args, sinceDays);
|
|
172
|
+
const { records, mode, warnings, providerCoverage, actionEvidence, financialCoverageComplete, summaryRecords, summary, detectedPlans, deadContext } = evidence;
|
|
169
173
|
if (records.length === 0) {
|
|
170
174
|
return noEvidenceResult("receipt", warnings, sinceDays, runtime.telemetryDisclosure);
|
|
171
175
|
}
|
|
172
|
-
const
|
|
173
|
-
? selectProviderFinancialHeadlineRecords(records)
|
|
174
|
-
: records;
|
|
175
|
-
const summary = analyzeSpend(summaryRecords);
|
|
176
|
-
// Connected receipts stay billed-primary but never ERASE the estimated
|
|
177
|
-
// axis: local transcript records ride along so subscription rows keep
|
|
178
|
-
// their ~ API-equivalent figures next to billed money (C-lane §1.4). The
|
|
179
|
-
// renderer classifies each record by basis and never blends the totals.
|
|
180
|
-
const receiptRecords = mode === "connected" && (localFinancialRecords?.length ?? 0) > 0
|
|
181
|
-
? [...summaryRecords, ...localFinancialRecords]
|
|
182
|
-
: summaryRecords;
|
|
176
|
+
const receiptRecords = evidence.analysisRecords;
|
|
183
177
|
// For real local-log users the by-project view is the flagship table
|
|
184
178
|
// ("which project burns my plan"); demo/connected keep by-model.
|
|
185
179
|
const groupBy = args.groupBy ?? (mode === "local-logs" ? "project" : "model");
|
|
186
180
|
const color = args.noColor ? false : undefined;
|
|
187
181
|
const outputWidth = terminalOutputWidth();
|
|
188
|
-
// Persona: --plan override wins; otherwise read the plans the coding agents
|
|
189
|
-
// themselves persisted locally (read-only, whitelisted fields, no network).
|
|
190
|
-
let detectedPlans;
|
|
191
|
-
if (args.sample) {
|
|
192
|
-
// An explicit sample run must be deterministic and safe to record/share.
|
|
193
|
-
// Never mix the developer's real local plan into illustrative output.
|
|
194
|
-
detectedPlans = [];
|
|
195
|
-
}
|
|
196
|
-
else if (args.plan) {
|
|
197
|
-
const override = planOverrideFromFlag(args.plan);
|
|
198
|
-
if (!override) {
|
|
199
|
-
return {
|
|
200
|
-
exitCode: 1,
|
|
201
|
-
stdout: "",
|
|
202
|
-
stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
detectedPlans = [override];
|
|
206
|
-
}
|
|
207
|
-
else {
|
|
208
|
-
detectedPlans = await detectLocalPlans({
|
|
209
|
-
// Env overrides keep tests (and unusual installs) isolated from $HOME.
|
|
210
|
-
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
211
|
-
codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
|
|
212
|
-
}).catch(() => []);
|
|
213
|
-
}
|
|
214
182
|
// Surface auto-detected credentials so the user knows their next 2-min step,
|
|
215
183
|
// without ever printing a raw secret.
|
|
216
184
|
// Sample output is designed for demos, docs, and screenshots. Keeping local
|
|
@@ -223,32 +191,6 @@ async function quickstartCommand(args, runtime = {}) {
|
|
|
223
191
|
home: process.env.AI_SPEND_CLAUDE_HOME_DIR
|
|
224
192
|
});
|
|
225
193
|
const nextSteps = quickstartNextSteps(mode, detection.credentials);
|
|
226
|
-
// Dead-context cost, globalized across the user's whole Claude Code setup
|
|
227
|
-
// (all projects' MCP + user-scope skills/agents/commands, vs. every
|
|
228
|
-
// transcript) so it's populated from ANY directory on the first run.
|
|
229
|
-
// Never throws into the readout.
|
|
230
|
-
let deadContext = args.sample
|
|
231
|
-
? undefined
|
|
232
|
-
: await loadDeadContext({
|
|
233
|
-
// Env overrides keep tests (and unusual installs) isolated from $HOME.
|
|
234
|
-
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
235
|
-
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
236
|
-
claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
|
|
237
|
-
codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
|
|
238
|
-
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
239
|
-
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
240
|
-
projectDir: resolve(args.path),
|
|
241
|
-
includeAllProjectMcp: true,
|
|
242
|
-
sinceIso: sinceIsoForDays(sinceDays),
|
|
243
|
-
windowDays: sinceDays,
|
|
244
|
-
codexInvocationFiles
|
|
245
|
-
}).catch(() => undefined);
|
|
246
|
-
// Sample dead-context is shown ONLY on the demo readout. A real readout
|
|
247
|
-
// (local logs / connected billing) never gets fabricated waste injected —
|
|
248
|
-
// a genuinely clean setup earns its congratulation line instead.
|
|
249
|
-
if (mode === "demo" && (!deadContext || !deadContext.hasData)) {
|
|
250
|
-
deadContext = sampleDeadContext();
|
|
251
|
-
}
|
|
252
194
|
const guidedExperience = !args.sample && actionEvidence
|
|
253
195
|
? await buildQuickstartGuidedExperience({
|
|
254
196
|
args,
|
|
@@ -261,6 +203,11 @@ async function quickstartCommand(args, runtime = {}) {
|
|
|
261
203
|
: undefined;
|
|
262
204
|
const summaryText = generatePlainEnglishSummary(summary, {
|
|
263
205
|
records: receiptRecords,
|
|
206
|
+
// The same array the machine-wide report renders into its ACT section.
|
|
207
|
+
cutList: evidence.actionCandidates,
|
|
208
|
+
// …and the same coverage facts the artifact discloses in its banner, so
|
|
209
|
+
// the two surfaces state the same thing about what was actually read.
|
|
210
|
+
...(actionEvidence ? { qualitativeCoverage: summarizeCliQualitativeCoverage(actionEvidence) } : {}),
|
|
264
211
|
groupBy,
|
|
265
212
|
color,
|
|
266
213
|
mode,
|
|
@@ -272,6 +219,11 @@ async function quickstartCommand(args, runtime = {}) {
|
|
|
272
219
|
nextSteps,
|
|
273
220
|
deadContext,
|
|
274
221
|
detectedPlans,
|
|
222
|
+
// 0.9.5: from a broad root the --full view's project-scoped pointers
|
|
223
|
+
// (apply, apply-artifact, watch, connect) carry the machine-wide
|
|
224
|
+
// report's `cd /path/to/project && …` prefix instead of advertising commands
|
|
225
|
+
// that friendly-refuse right where they were printed.
|
|
226
|
+
commandScope: isBroadScanRoot(args.path) ? "machine-wide" : "project",
|
|
275
227
|
// C-lane §1.4: the result card header states the evidence window.
|
|
276
228
|
windowDays: sinceDays,
|
|
277
229
|
width: outputWidth,
|
|
@@ -611,20 +563,31 @@ function quickstartNextSteps(mode, detected) {
|
|
|
611
563
|
// Connect/verify guidance now lives in the readout's APPLY/VERIFY sections;
|
|
612
564
|
// this footer only carries what those can't know (detected local keys) and
|
|
613
565
|
// the report/waitlist pointers.
|
|
566
|
+
// Command steps are STRUCTURED, never hand-padded: every option handed to
|
|
567
|
+
// the readout passes through a sanitizer that collapses whitespace runs, so
|
|
568
|
+
// "npx aibill report write a shareable report" reached the user
|
|
569
|
+
// as "npx aibill report write a shareable report" — no visible boundary
|
|
570
|
+
// between the command and its description (0.9.6).
|
|
614
571
|
const steps = [];
|
|
615
572
|
if (detected.length > 0) {
|
|
616
573
|
const names = detected.map((credential) => `${credential.provider} (${credential.hint})`).join(", ");
|
|
617
574
|
steps.push(`Found local key${detected.length === 1 ? "" : "s"}: ${names}`);
|
|
618
|
-
steps.push(
|
|
575
|
+
steps.push({
|
|
576
|
+
command: `npx aibill connect ${detected[0].provider}`,
|
|
577
|
+
description: "set up the admin connector, then sync provider-reported cost"
|
|
578
|
+
});
|
|
619
579
|
}
|
|
620
580
|
steps.push(mode === "demo"
|
|
621
581
|
// 0.9.4: report --sample runs as printed from ANY directory — broad
|
|
622
582
|
// roots write ./ai-spend-report.{md,html} machine-wide-style, project
|
|
623
583
|
// folders keep .ai-spend-agent/report.* (the old mkdir demo-workspace
|
|
624
584
|
// preamble is no longer needed for the command to run as printed).
|
|
625
|
-
? "npx aibill report --sample
|
|
626
|
-
: "npx aibill report
|
|
627
|
-
steps.push(
|
|
585
|
+
? { command: "npx aibill report --sample", description: "write a clearly labeled demo report right here" }
|
|
586
|
+
: { command: "npx aibill report", description: "write a shareable Markdown + HTML report" });
|
|
587
|
+
steps.push({
|
|
588
|
+
command: "npx aibill --group-by project",
|
|
589
|
+
description: "see which project has the most observed activity"
|
|
590
|
+
});
|
|
628
591
|
steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://asktilden.com");
|
|
629
592
|
if (mode === "demo") {
|
|
630
593
|
// Static pointer only — sample output is built for recordings and
|
|
@@ -823,6 +786,86 @@ async function loadInstantReadData(args) {
|
|
|
823
786
|
: false
|
|
824
787
|
};
|
|
825
788
|
}
|
|
789
|
+
/**
|
|
790
|
+
* Build the one {@link BroadScanEvidence} bundle. Called by the receipt /
|
|
791
|
+
* `--full` readout AND by the machine-wide `report`; nothing else may
|
|
792
|
+
* re-derive these facts.
|
|
793
|
+
*
|
|
794
|
+
* Everything here was previously inline in the receipt path. The machine-wide
|
|
795
|
+
* report used to call {@link loadInstantReadData} directly and keep only
|
|
796
|
+
* `records`, which is exactly how its ACT and VERIFY sections lost the
|
|
797
|
+
* transcript index and degraded to internal-jargon "unknown" copy while the
|
|
798
|
+
* readout from the same directory showed real, ranked candidates.
|
|
799
|
+
*/
|
|
800
|
+
async function loadBroadScanEvidence(args, sinceDays) {
|
|
801
|
+
const instant = await loadInstantReadData(args);
|
|
802
|
+
const { records, mode, warnings, providerCoverage } = instant;
|
|
803
|
+
const summaryRecords = mode === "connected"
|
|
804
|
+
? selectProviderFinancialHeadlineRecords(records)
|
|
805
|
+
: records;
|
|
806
|
+
// Connected receipts stay billed-primary but never ERASE the estimated
|
|
807
|
+
// axis: local transcript records ride along so subscription rows keep
|
|
808
|
+
// their ~ API-equivalent figures next to billed money (C-lane §1.4).
|
|
809
|
+
const analysisRecords = mode === "connected" && (instant.localFinancialRecords?.length ?? 0) > 0
|
|
810
|
+
? [...summaryRecords, ...instant.localFinancialRecords]
|
|
811
|
+
: summaryRecords;
|
|
812
|
+
// Persona: --plan override wins; otherwise read the plans the coding agents
|
|
813
|
+
// themselves persisted locally (read-only, whitelisted fields, no network).
|
|
814
|
+
// An explicit sample run must be deterministic and safe to record/share:
|
|
815
|
+
// never mix the developer's real local plan into illustrative output.
|
|
816
|
+
const planOverride = args.plan ? planOverrideFromFlag(args.plan) : undefined;
|
|
817
|
+
const detectedPlans = args.sample
|
|
818
|
+
? []
|
|
819
|
+
: planOverride
|
|
820
|
+
? [planOverride]
|
|
821
|
+
: await detectLocalPlans({
|
|
822
|
+
// Env overrides keep tests (and unusual installs) isolated from $HOME.
|
|
823
|
+
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
824
|
+
codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
|
|
825
|
+
}).catch(() => []);
|
|
826
|
+
// Dead-context cost, globalized across the user's whole Claude Code setup
|
|
827
|
+
// (all projects' MCP + user-scope skills/agents/commands, vs. every
|
|
828
|
+
// transcript) so it's populated from ANY directory on the first run.
|
|
829
|
+
// Never throws into the readout.
|
|
830
|
+
let deadContext = args.sample
|
|
831
|
+
? undefined
|
|
832
|
+
: await loadDeadContext({
|
|
833
|
+
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
834
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
835
|
+
claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
|
|
836
|
+
codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
|
|
837
|
+
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
838
|
+
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
839
|
+
projectDir: resolve(args.path),
|
|
840
|
+
includeAllProjectMcp: true,
|
|
841
|
+
sinceIso: sinceIsoForDays(sinceDays),
|
|
842
|
+
windowDays: sinceDays,
|
|
843
|
+
codexInvocationFiles: instant.codexInvocationFiles
|
|
844
|
+
}).catch(() => undefined);
|
|
845
|
+
// Sample dead-context is shown ONLY on the demo readout. A real readout
|
|
846
|
+
// (local logs / connected billing) never gets fabricated waste injected —
|
|
847
|
+
// a genuinely clean setup earns its congratulation line instead.
|
|
848
|
+
if (mode === "demo" && (!deadContext || !deadContext.hasData)) {
|
|
849
|
+
deadContext = sampleDeadContext();
|
|
850
|
+
}
|
|
851
|
+
return {
|
|
852
|
+
records,
|
|
853
|
+
mode,
|
|
854
|
+
warnings,
|
|
855
|
+
...(providerCoverage ? { providerCoverage } : {}),
|
|
856
|
+
summaryRecords,
|
|
857
|
+
analysisRecords,
|
|
858
|
+
summary: analyzeSpend(summaryRecords),
|
|
859
|
+
// THE parity anchor: derived once, handed to every surface.
|
|
860
|
+
actionCandidates: generateCutList(analysisRecords),
|
|
861
|
+
detectedPlans,
|
|
862
|
+
...(deadContext ? { deadContext } : {}),
|
|
863
|
+
...(instant.actionEvidence ? { actionEvidence: instant.actionEvidence } : {}),
|
|
864
|
+
...(instant.codexInvocationFiles ? { codexInvocationFiles: instant.codexInvocationFiles } : {}),
|
|
865
|
+
...(instant.localFinancialRecords ? { localFinancialRecords: instant.localFinancialRecords } : {}),
|
|
866
|
+
financialCoverageComplete: instant.financialCoverageComplete === true
|
|
867
|
+
};
|
|
868
|
+
}
|
|
826
869
|
function localFinancialEvidenceComplete(result) {
|
|
827
870
|
const scanCoverageComplete = result.sourceScans.every((scan) => (scan.directoryStatus !== "unreadable" &&
|
|
828
871
|
scan.unreadableFiles === 0 &&
|
|
@@ -850,9 +893,23 @@ async function loadBoundedLocalActionEvidence(sinceIso) {
|
|
|
850
893
|
* qualitative index converges, printing honest per-pass progress.
|
|
851
894
|
*/
|
|
852
895
|
async function indexEvidenceCommand(args, runtime) {
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
896
|
+
// 0.9.6, same fix and same reason as `report` (0.9.4): the report prints
|
|
897
|
+
// `npx aibill index` as its prescribed next step 13 times, and from a broad
|
|
898
|
+
// root — the home folder the machine-wide report is normally written in —
|
|
899
|
+
// the exact-project guard made that pointer exit 1. The report's own next
|
|
900
|
+
// step refused from the directory that wrote the report.
|
|
901
|
+
//
|
|
902
|
+
// The guard was never load-bearing here. `index` reads the env-scoped GLOBAL
|
|
903
|
+
// transcript directories (AI_SPEND_CLAUDE_LOGS_DIR / AI_SPEND_CODEX_LOGS_DIR,
|
|
904
|
+
// defaulting to the agents' own home-level locations) and writes the
|
|
905
|
+
// home-level private cache under ~/.aibill. `args.path` scopes nothing it
|
|
906
|
+
// reads and nothing it writes, so a broad root scans no more than a project
|
|
907
|
+
// root does. Only a bogus --path still gets the friendly guard.
|
|
908
|
+
if (!isBroadScanRoot(args.path)) {
|
|
909
|
+
const rootGuard = await guardExactProjectRoot("index", args.path);
|
|
910
|
+
if (rootGuard)
|
|
911
|
+
return rootGuard;
|
|
912
|
+
}
|
|
856
913
|
const sinceDays = args.sinceDays ?? 30;
|
|
857
914
|
if (!validSinceDays(sinceDays))
|
|
858
915
|
return invalidSinceDaysResult();
|
|
@@ -897,7 +954,7 @@ async function indexEvidenceCommand(args, runtime) {
|
|
|
897
954
|
`${coverage.readCompletely}/${coverage.selectedFiles} selected files read completely · ` +
|
|
898
955
|
`${formatBytes(totalBytes)} streamed across ${passes} pass(es)`
|
|
899
956
|
],
|
|
900
|
-
next: { reason: "your evidence is fully indexed — run the token test", command: improveRuntimeCommand }
|
|
957
|
+
next: { reason: "your evidence is fully indexed — run the token test", command: projectScopedPointer(improveRuntimeCommand, args.path) }
|
|
901
958
|
}));
|
|
902
959
|
}
|
|
903
960
|
if (passBytes === 0 && stillConverging === 0) {
|
|
@@ -909,7 +966,7 @@ async function indexEvidenceCommand(args, runtime) {
|
|
|
909
966
|
`${coverage.readCompletely}/${coverage.selectedFiles} selected files read completely · ` +
|
|
910
967
|
`${coverage.skippedForBudget} file(s) remain outside the read budget`
|
|
911
968
|
],
|
|
912
|
-
next: { reason: "run the token test on the evidence that is indexed", command: improveRuntimeCommand }
|
|
969
|
+
next: { reason: "run the token test on the evidence that is indexed", command: projectScopedPointer(improveRuntimeCommand, args.path) }
|
|
913
970
|
}));
|
|
914
971
|
}
|
|
915
972
|
}
|
|
@@ -950,10 +1007,28 @@ function summarizeCliQualitativeCoverageByAgent(logs) {
|
|
|
950
1007
|
];
|
|
951
1008
|
}));
|
|
952
1009
|
}
|
|
1010
|
+
/**
|
|
1011
|
+
* The report summary's coverage row, in the user-facing voice (0.9.6). It read
|
|
1012
|
+
* "suppressed · qualitative index partial" — a status nobody outside this
|
|
1013
|
+
* codebase can act on, and a word ("suppressed") that was not even accurate
|
|
1014
|
+
* for the ranked candidates, which are financial-derived and never withheld.
|
|
1015
|
+
*
|
|
1016
|
+
* Names the ACTUAL gap: unread transcripts and budget-skipped files are
|
|
1017
|
+
* different problems with different fixes, and a bare "4 of 4 read" hid the
|
|
1018
|
+
* second one entirely.
|
|
1019
|
+
*/
|
|
1020
|
+
function reportQualitativeRowValue(coverage) {
|
|
1021
|
+
const read = coverage?.readCompletely ?? 0;
|
|
1022
|
+
const selected = coverage?.selectedFiles ?? 0;
|
|
1023
|
+
const skipped = coverage?.skippedForBudget ?? 0;
|
|
1024
|
+
return `not drafted · ${read} of ${selected} session transcripts read` +
|
|
1025
|
+
(skipped > 0 ? ` · ${skipped} ${skipped === 1 ? "file" : "files"} skipped by budget` : "");
|
|
1026
|
+
}
|
|
953
1027
|
function renderCliQualitativeCoverage(coverage) {
|
|
954
|
-
return `
|
|
955
|
-
|
|
956
|
-
|
|
1028
|
+
return `SESSION TRANSCRIPTS ${coverage.readCompletely} of ${coverage.selectedFiles} read completely` +
|
|
1029
|
+
(coverage.skippedForBudget > 0
|
|
1030
|
+
? ` · ${coverage.skippedForBudget} ${coverage.skippedForBudget === 1 ? "file was" : "files were"} skipped by budget`
|
|
1031
|
+
: "");
|
|
957
1032
|
}
|
|
958
1033
|
/**
|
|
959
1034
|
* `aibill signup <email> [--ref <token>]` — the explicit, deliberate path to
|
|
@@ -1171,7 +1246,19 @@ function noEvidenceResult(surface, warnings, sinceDays, telemetryDisclosure) {
|
|
|
1171
1246
|
...warnings.map((warning) => `! ${warning}`),
|
|
1172
1247
|
"",
|
|
1173
1248
|
"Next",
|
|
1174
|
-
|
|
1249
|
+
// 0.9.6: this screen used to offer exactly two next steps — a
|
|
1250
|
+
// diagnostic and an email signup — to a visitor who had just been
|
|
1251
|
+
// told there is nothing to show. The only occurrence of the word
|
|
1252
|
+
// "sample" on it was "No sample data was substituted." `--sample`
|
|
1253
|
+
// works, needs no evidence and no email, and is the one thing that
|
|
1254
|
+
// shows a first-time visitor what the tool actually produces, so it
|
|
1255
|
+
// goes FIRST.
|
|
1256
|
+
" npx aibill --sample see a full worked example on bundled demo data",
|
|
1257
|
+
// Honest label (0.9.6): this prints a per-source coverage matrix —
|
|
1258
|
+
// which locations were checked and what each returned. It does not
|
|
1259
|
+
// print install or setup instructions, and promising "setup paths"
|
|
1260
|
+
// sent people looking for something that is not there.
|
|
1261
|
+
" npx aibill doctor --sources see which source locations were checked, and what each returned",
|
|
1175
1262
|
` ${signupCopy.receiptPointer}`
|
|
1176
1263
|
].join("\n")
|
|
1177
1264
|
: "",
|
|
@@ -1834,7 +1921,7 @@ async function statuslineCommand(args, runtime) {
|
|
|
1834
1921
|
if (action === "refresh")
|
|
1835
1922
|
return refreshStatuslineCommand(args, runtime);
|
|
1836
1923
|
if (action === "expand")
|
|
1837
|
-
return expandStatuslineCommand(runtime);
|
|
1924
|
+
return expandStatuslineCommand(args, runtime);
|
|
1838
1925
|
return {
|
|
1839
1926
|
exitCode: 1,
|
|
1840
1927
|
stdout: "",
|
|
@@ -2126,6 +2213,19 @@ async function refreshStatuslineCommand(args, runtime) {
|
|
|
2126
2213
|
}
|
|
2127
2214
|
detectedPlanOverride = [override];
|
|
2128
2215
|
}
|
|
2216
|
+
// `statusline refresh` scans an approved workspace root, so a broad root is
|
|
2217
|
+
// a genuine refusal — but it must SOUND like one (0.9.6). It used to let
|
|
2218
|
+
// resolveSafeScanRoot throw, which the crash wrapper rendered as "aibill hit
|
|
2219
|
+
// an unexpected error: Refusing to scan …" plus "run diagnostics before
|
|
2220
|
+
// retrying" — the tool reporting its own deliberate safety check as a bug.
|
|
2221
|
+
// `statusline refresh` scans an approved workspace root, so a broad root is
|
|
2222
|
+
// a genuine refusal — but it must SOUND like one (0.9.6). It used to let
|
|
2223
|
+
// resolveSafeScanRoot throw, which the crash wrapper rendered as "aibill hit
|
|
2224
|
+
// an unexpected error: Refusing to scan …" plus "run diagnostics before
|
|
2225
|
+
// retrying" — the tool reporting its own deliberate safety check as a bug.
|
|
2226
|
+
const refreshGuard = await guardExactProjectRoot("statusline refresh", args.path);
|
|
2227
|
+
if (refreshGuard)
|
|
2228
|
+
return refreshGuard;
|
|
2129
2229
|
const rootPath = await resolveSafeScanRoot(args.path);
|
|
2130
2230
|
const cacheDirectory = process.env.AIBILL_CACHE_DIR;
|
|
2131
2231
|
await preflightInitCache(cacheDirectory);
|
|
@@ -2193,7 +2293,7 @@ async function refreshStatuslineCommand(args, runtime) {
|
|
|
2193
2293
|
* snapshot; `runways[]` carries both limits), never bypassing the contract.
|
|
2194
2294
|
* Printed command output, not a hook line.
|
|
2195
2295
|
*/
|
|
2196
|
-
async function expandStatuslineCommand(runtime) {
|
|
2296
|
+
async function expandStatuslineCommand(args, runtime) {
|
|
2197
2297
|
const cache = await readStatuslineCache({
|
|
2198
2298
|
cacheDirectory: process.env.AIBILL_CACHE_DIR,
|
|
2199
2299
|
homeDirectory: runtime.homeDirectory
|
|
@@ -2203,8 +2303,8 @@ async function expandStatuslineCommand(runtime) {
|
|
|
2203
2303
|
exitCode: 1,
|
|
2204
2304
|
stdout: "",
|
|
2205
2305
|
stderr: cache.status === "missing"
|
|
2206
|
-
?
|
|
2207
|
-
:
|
|
2306
|
+
? `aibill statusline expand needs a refreshed private cache: run \`${projectScopedPointer(actionRuntimeCommand("statusline refresh"), args.path)}\` first.`
|
|
2307
|
+
: `aibill statusline expand could not read the private cache safely; run \`${projectScopedPointer(actionRuntimeCommand("statusline refresh"), args.path)}\`.`
|
|
2208
2308
|
};
|
|
2209
2309
|
}
|
|
2210
2310
|
const now = runtime.statuslineNow ?? new Date();
|
|
@@ -2213,7 +2313,7 @@ async function expandStatuslineCommand(runtime) {
|
|
|
2213
2313
|
return ok([
|
|
2214
2314
|
"aibill subscriptions · 30d window",
|
|
2215
2315
|
" no detected subscription evidence in the private cache",
|
|
2216
|
-
|
|
2316
|
+
` run \`${projectScopedPointer(actionRuntimeCommand("statusline refresh"), args.path)}\` after using Claude Code or Codex`
|
|
2217
2317
|
].join("\n"));
|
|
2218
2318
|
}
|
|
2219
2319
|
const card = statuslineSnapshotToResultCard(snapshot, now);
|
|
@@ -3769,9 +3869,16 @@ async function reportCommand(args, runtime = {}) {
|
|
|
3769
3869
|
// exact-project requirement was incoherent here: the receipt's own Next
|
|
3770
3870
|
// pointer led from home straight into a refusal. Only a bogus --path
|
|
3771
3871
|
// still gets the friendly guard; project folders behave exactly as
|
|
3772
|
-
// before.
|
|
3872
|
+
// before. 0.9.5: broad roots that cannot HOLD the artifacts (/, /etc,
|
|
3873
|
+
// /Users, …) get the friendly guard voice up front instead of dying at
|
|
3874
|
+
// write time with a wrapped raw error.
|
|
3773
3875
|
const machineWide = isBroadScanRoot(args.path);
|
|
3774
|
-
if (
|
|
3876
|
+
if (machineWide) {
|
|
3877
|
+
const broadGuard = guardUnwritableBroadRoot("report", args);
|
|
3878
|
+
if (broadGuard)
|
|
3879
|
+
return broadGuard;
|
|
3880
|
+
}
|
|
3881
|
+
else {
|
|
3775
3882
|
const rootGuard = await guardExactProjectRoot("report", args.path);
|
|
3776
3883
|
if (rootGuard)
|
|
3777
3884
|
return rootGuard;
|
|
@@ -3864,67 +3971,111 @@ async function reportCommand(args, runtime = {}) {
|
|
|
3864
3971
|
const artifactPaths = reportableExperiment || machineWide
|
|
3865
3972
|
? undefined
|
|
3866
3973
|
: await writeApplyArtifacts(stateDir, reportInput);
|
|
3867
|
-
|
|
3868
|
-
|
|
3974
|
+
// 0.9.5 "agent feel": the HTML report opens itself in the browser via
|
|
3975
|
+
// the platform opener — decided truthfully BEFORE the summary renders,
|
|
3976
|
+
// suppressed for non-TTY/CI/SSH/--no-open/AI_SPEND_NO_OPEN, and fired
|
|
3977
|
+
// detached so a missing or slow opener can never crash, hang, or delay
|
|
3978
|
+
// exit (the telemetry detached-child pattern).
|
|
3979
|
+
const openDecision = (runtime.reportOpenDecide ?? decideReportAutoOpen)({
|
|
3980
|
+
htmlPath,
|
|
3981
|
+
noOpenFlag: args.noOpen === true
|
|
3982
|
+
});
|
|
3983
|
+
const openedInBrowser = (runtime.reportOpenLaunch ?? openReportInBrowser)(openDecision);
|
|
3984
|
+
// 0.9.5 founder polish ("really hard to read… I wonder if we can have
|
|
3985
|
+
// the text aligned"): the same facts, rendered in the receipt's visual
|
|
3986
|
+
// language — header, one shared label column, dot separators, and a Next
|
|
3987
|
+
// block whose commands pad to one description column. Display-only.
|
|
3988
|
+
const rows = [
|
|
3869
3989
|
machineWide
|
|
3870
|
-
?
|
|
3871
|
-
:
|
|
3872
|
-
|
|
3873
|
-
|
|
3990
|
+
? { label: "Scope", value: `machine-wide · all supported local agent evidence on this machine (last ${sinceDays} days) · artifacts in ${rootPath}` }
|
|
3991
|
+
: { label: "Path", value: rootPath },
|
|
3992
|
+
{ label: "Markdown", value: markdownPath },
|
|
3993
|
+
{ label: "HTML", value: htmlPath },
|
|
3874
3994
|
...(machineWide
|
|
3875
3995
|
? []
|
|
3876
3996
|
: reportableExperiment
|
|
3877
3997
|
? [
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3998
|
+
{ label: "Action artifacts", value: `preserved · canonical token test ${reportableExperiment.id} (${reportableExperiment.lifecycle})` },
|
|
3999
|
+
{ label: "Token result", value: `status=${reportableExperiment.evaluation.status}; reductionPercent=${reportExperimentProjection.reductionPercent ?? "unavailable"}; metricEvidence=${reportExperimentProjection.evidenceLabel}; quality=${reportExperimentProjection.qualityLabel}; qualityEvidence=${reportExperimentProjection.qualityEvidence}; matchingEvidence=${reportableExperiment.evaluation.matchingEvidence}` },
|
|
4000
|
+
{ label: "Token test", value: improveRuntimeCommand }
|
|
3881
4001
|
]
|
|
3882
4002
|
: qualitativeActionsSuppressed
|
|
3883
4003
|
? [
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
4004
|
+
{ label: "Action artifacts", value: reportQualitativeRowValue(reportInput.qualitativeCoverage) },
|
|
4005
|
+
{ label: "Coverage artifact", value: artifactPaths.codingPrompt },
|
|
4006
|
+
{ label: "Coverage action plan", value: artifactPaths.actionPlan },
|
|
4007
|
+
{ label: "Coverage policy/config", value: artifactPaths.policyConfigDraft },
|
|
4008
|
+
{ label: "Coverage verification", value: artifactPaths.verificationPlan },
|
|
4009
|
+
{ label: "Coverage package", value: artifactPaths.demoPackage }
|
|
3890
4010
|
]
|
|
3891
4011
|
: artifactPaths
|
|
3892
4012
|
? [
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
4013
|
+
{ label: "Apply artifact", value: artifactPaths.codingPrompt },
|
|
4014
|
+
{ label: "Action plan", value: artifactPaths.actionPlan },
|
|
4015
|
+
{ label: "Policy/config draft", value: artifactPaths.policyConfigDraft },
|
|
4016
|
+
{ label: "Verification plan", value: artifactPaths.verificationPlan },
|
|
4017
|
+
{ label: "Demo package", value: artifactPaths.demoPackage }
|
|
3898
4018
|
]
|
|
3899
4019
|
: []),
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
: reportInput.dataMode === "
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
4020
|
+
{
|
|
4021
|
+
label: "Total",
|
|
4022
|
+
value: reportInput.dataMode === "sample"
|
|
4023
|
+
? `${formatOptionalUsd(reportInput.summary.totalUsd)} · DEMO SAMPLE · illustrative cost/value evidence · not user data`
|
|
4024
|
+
: reportInput.dataMode === "connected_provider" &&
|
|
4025
|
+
!(reportInput.allRecords ?? reportInput.providerRecords ?? []).some((record) => typeof record.amountUsd === "number")
|
|
4026
|
+
? "Unavailable · cost/value evidence · no priced financial evidence; missing/null is not zero"
|
|
4027
|
+
: `${formatOptionalUsd(reportInput.summary.totalUsd)} · cost/value evidence`
|
|
4028
|
+
},
|
|
4029
|
+
{
|
|
4030
|
+
label: "Privacy",
|
|
4031
|
+
value: runtime.telemetryDisclosure === true
|
|
4032
|
+
? `report rendered locally · ${telemetryDisclosureLine}; only explicit sync-provider contacts the selected provider`
|
|
4033
|
+
: "report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider"
|
|
4034
|
+
}
|
|
4035
|
+
];
|
|
4036
|
+
const nextSteps = [
|
|
4037
|
+
// Summary-line truth: only a fired opener may claim it opened; every
|
|
4038
|
+
// suppression path keeps the plain copy-pasteable pointer.
|
|
4039
|
+
// 0.9.6: the pointer must survive a NAIVE PARTIAL READ. The founder saw
|
|
4040
|
+
// `› open <the full absolute path>` with its description on the
|
|
4041
|
+
// next line, read "open" as a label rather than the command, typed bare
|
|
4042
|
+
// `open`, and got macOS's usage dump — "i don't know what im looking
|
|
4043
|
+
// at." shellPathPointer names an artifact in the current directory
|
|
4044
|
+
// relatively (one short unit) and quotes anything absolute so the
|
|
4045
|
+
// command and its argument read — and paste — as one thing.
|
|
4046
|
+
openedInBrowser
|
|
4047
|
+
? { command: `opened ${basename(htmlPath)} in your browser · next time: --no-open to skip` }
|
|
4048
|
+
: {
|
|
4049
|
+
command: shellPathPointer(platformOpenCommand(), htmlPath, process.cwd()),
|
|
4050
|
+
description: `view in your browser — or double-click ${basename(htmlPath)} in your file manager`
|
|
4051
|
+
},
|
|
4052
|
+
{
|
|
4053
|
+
command: shellPathPointer("less", markdownPath, process.cwd()),
|
|
4054
|
+
description: "read it in the terminal"
|
|
4055
|
+
},
|
|
3913
4056
|
machineWide
|
|
3914
4057
|
// apply/improve need one exact project folder — a machine-wide
|
|
3915
4058
|
// report must never point at a command that then refuses (the exact
|
|
3916
4059
|
// trap this mode removes).
|
|
3917
4060
|
? reportInput.dataMode === "sample"
|
|
3918
|
-
? `
|
|
3919
|
-
: `
|
|
4061
|
+
? { command: `cd /path/to/project && ${actionRuntimeCommand("apply --sample")}`, description: "print the non-executable demo boundary from one exact project folder" }
|
|
4062
|
+
: { command: `cd /path/to/project && ${actionRuntimeCommand(`apply --since-days ${sinceDays}`)}`, description: "per-project action plan from one exact project folder" }
|
|
3920
4063
|
: reportableExperiment
|
|
3921
|
-
?
|
|
4064
|
+
? { command: improveRuntimeCommand, description: `review canonical token test ${reportableExperiment.id}` }
|
|
3922
4065
|
: qualitativeActionsSuppressed
|
|
3923
|
-
?
|
|
4066
|
+
? { command: actionRuntimeCommand(`context --json --since-days ${sinceDays}`), description: "inspect the transcript evidence read so far" }
|
|
3924
4067
|
: reportInput.dataMode === "sample"
|
|
3925
|
-
?
|
|
3926
|
-
:
|
|
3927
|
-
]
|
|
4068
|
+
? { command: actionRuntimeCommand("apply --sample"), description: "print the non-executable demo boundary" }
|
|
4069
|
+
: { command: actionRuntimeCommand(`apply --since-days ${sinceDays}`), description: "print the paste-ready coding-agent prompt from this exact evidence window" }
|
|
4070
|
+
];
|
|
4071
|
+
return ok(generateCommandSummary({
|
|
4072
|
+
title: "aibill report",
|
|
4073
|
+
note: "a shareable Markdown + HTML report, written locally",
|
|
4074
|
+
rows,
|
|
4075
|
+
nextSteps,
|
|
4076
|
+
color: args.noColor ? false : undefined,
|
|
4077
|
+
width: terminalOutputWidth()
|
|
4078
|
+
}));
|
|
3928
4079
|
}
|
|
3929
4080
|
catch (error) {
|
|
3930
4081
|
return {
|
|
@@ -3948,14 +4099,21 @@ async function resolveReceiptPath(rootPath, out) {
|
|
|
3948
4099
|
return join(resolved, "ai-spend-receipt.svg");
|
|
3949
4100
|
return extname(resolved) ? resolved : `${resolved}.svg`;
|
|
3950
4101
|
}
|
|
3951
|
-
async function reportCardCommand(args) {
|
|
4102
|
+
async function reportCardCommand(args, runtime = {}) {
|
|
3952
4103
|
// 0.9.4: a broad root (home, /) runs MACHINE-WIDE — identical read-only
|
|
3953
4104
|
// scanning to the bare receipt (loadInstantReadData below), SVG written to
|
|
3954
4105
|
// the current directory. The card renders machine-wide content anyway, so
|
|
3955
4106
|
// an exact-project requirement was incoherent here; only a bogus --path
|
|
3956
|
-
// still gets the friendly guard.
|
|
4107
|
+
// still gets the friendly guard. 0.9.5: broad roots that cannot HOLD the
|
|
4108
|
+
// receipt (/, /etc, /Users, …) get the friendly guard voice up front
|
|
4109
|
+
// instead of dying at write time with a wrapped raw error.
|
|
3957
4110
|
const machineWide = isBroadScanRoot(args.path);
|
|
3958
|
-
if (
|
|
4111
|
+
if (machineWide) {
|
|
4112
|
+
const broadGuard = guardUnwritableBroadRoot("report-card", args);
|
|
4113
|
+
if (broadGuard)
|
|
4114
|
+
return broadGuard;
|
|
4115
|
+
}
|
|
4116
|
+
else if (!args.sample) {
|
|
3959
4117
|
const rootGuard = await guardExactProjectRoot("report-card", args.path);
|
|
3960
4118
|
if (rootGuard)
|
|
3961
4119
|
return rootGuard;
|
|
@@ -3978,34 +4136,80 @@ async function reportCardCommand(args) {
|
|
|
3978
4136
|
const summary = analyzeSpend(headlineRecords);
|
|
3979
4137
|
const outPath = await resolveReceiptPath(rootPath, args.out);
|
|
3980
4138
|
await mkdir(dirname(outPath), { recursive: true });
|
|
3981
|
-
|
|
4139
|
+
const svg = generateReportCardSvg({
|
|
3982
4140
|
summary,
|
|
3983
4141
|
records: headlineRecords,
|
|
3984
4142
|
mode,
|
|
3985
4143
|
...(providerCoverage ? { providerCoverage } : {})
|
|
3986
|
-
})
|
|
3987
|
-
|
|
4144
|
+
});
|
|
4145
|
+
await writeSafeStateText(dirname(outPath), basename(outPath), svg);
|
|
4146
|
+
const caption = generateReportCardCaption({
|
|
4147
|
+
summary,
|
|
4148
|
+
records: headlineRecords,
|
|
4149
|
+
mode,
|
|
4150
|
+
...(providerCoverage ? { providerCoverage } : {})
|
|
4151
|
+
});
|
|
4152
|
+
// 0.9.6: the receipt now SHOWS itself. 0.9.5 wrote the SVG and stopped,
|
|
4153
|
+
// leaving the user to go find and open it by hand ("not automatically
|
|
4154
|
+
// showing on a html or opening the file: making it inefficient").
|
|
4155
|
+
//
|
|
4156
|
+
// We auto-open a companion .html rather than the .svg itself: platform
|
|
4157
|
+
// openers hand a .svg to whatever claims that extension, which on a
|
|
4158
|
+
// developer machine is frequently an editor, so "show me my receipt"
|
|
4159
|
+
// could open a wall of XML. An .html is claimed by a browser everywhere.
|
|
4160
|
+
// The .svg remains the canonical shareable artifact; the companion just
|
|
4161
|
+
// renders it next to the caption, embedding both verbatim so it inherits
|
|
4162
|
+
// the card's redaction guarantees exactly.
|
|
4163
|
+
const companionPath = `${outPath.replace(/\.svg$/iu, "")}.html`;
|
|
4164
|
+
await writeSafeStateText(dirname(companionPath), basename(companionPath), generateReceiptCompanionHtml({ svg, caption }));
|
|
4165
|
+
// Same decision function, same suppression matrix, same metacharacter
|
|
4166
|
+
// refusal, same detached launch as `report` — one opener, two commands.
|
|
4167
|
+
const openDecision = (runtime.reportOpenDecide ?? decideReportAutoOpen)({
|
|
4168
|
+
htmlPath: companionPath,
|
|
4169
|
+
noOpenFlag: args.noOpen === true
|
|
4170
|
+
});
|
|
4171
|
+
const openedInBrowser = (runtime.reportOpenLaunch ?? openReportInBrowser)(openDecision);
|
|
4172
|
+
const dataRow = mode === "demo"
|
|
3988
4173
|
? args.sample
|
|
3989
|
-
? "
|
|
3990
|
-
: "
|
|
4174
|
+
? "DEMO sample data — explicit illustrative mode; no local transcripts or persisted spend state were read"
|
|
4175
|
+
: "DEMO sample data — no supported local Claude Code/Codex evidence was found; use --sample to reproduce this demo explicitly"
|
|
3991
4176
|
: mode === "local-logs"
|
|
3992
|
-
? "
|
|
3993
|
-
: "
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
"",
|
|
3999
|
-
"
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4177
|
+
? "local Claude Code/Codex logs priced at API-equivalent rates"
|
|
4178
|
+
: "connected local spend state with provider-reported cost kept separate from API-equivalent estimates";
|
|
4179
|
+
// 0.9.5 founder polish: same facts, receipt-language layout — header,
|
|
4180
|
+
// one label column, and the caption set off as its own block.
|
|
4181
|
+
return ok(generateCommandSummary({
|
|
4182
|
+
title: "aibill report-card",
|
|
4183
|
+
badge: "Your AI Receipt",
|
|
4184
|
+
note: "a shareable, redacted spend card (no client/project/user names)",
|
|
4185
|
+
rows: [
|
|
4186
|
+
{ label: "Receipt", value: outPath },
|
|
4187
|
+
{ label: "Preview", value: companionPath },
|
|
4188
|
+
{ label: "Data", value: dataRow },
|
|
4189
|
+
{ label: "Privacy", value: "rendered locally; only totals, generic candidate categories, and evidence labels are included" }
|
|
4190
|
+
],
|
|
4191
|
+
sections: [{
|
|
4192
|
+
heading: "Caption to share",
|
|
4193
|
+
body: [caption]
|
|
4194
|
+
}],
|
|
4195
|
+
nextSteps: [
|
|
4196
|
+
// Summary-line truth: only a fired opener may claim it opened; every
|
|
4197
|
+
// suppression path keeps the plain copy-pasteable pointer.
|
|
4198
|
+
openedInBrowser
|
|
4199
|
+
? { command: `opened ${basename(companionPath)} in your browser · next time: --no-open to skip` }
|
|
4200
|
+
: {
|
|
4201
|
+
command: shellPathPointer(platformOpenCommand(), companionPath, process.cwd()),
|
|
4202
|
+
description: `view the receipt — or double-click ${basename(companionPath)} in your file manager`
|
|
4203
|
+
},
|
|
4204
|
+
// NOT a command, so it must not LOOK like one: `post ai-receipt.svg`
|
|
4205
|
+
// sitting under "Next" beside two real commands is exactly the trap
|
|
4206
|
+
// that made the founder type a bare `open`. Description-less lines
|
|
4207
|
+
// render verbatim, so this one states a fact instead.
|
|
4208
|
+
{ command: `${basename(outPath)} is the file to share — post that one` }
|
|
4209
|
+
],
|
|
4210
|
+
color: args.noColor ? false : undefined,
|
|
4211
|
+
width: terminalOutputWidth()
|
|
4212
|
+
}));
|
|
4009
4213
|
}
|
|
4010
4214
|
catch (error) {
|
|
4011
4215
|
return {
|
|
@@ -4032,12 +4236,76 @@ async function reportCardCommand(args) {
|
|
|
4032
4236
|
* (improve, apply, verify, watch, connect, reset, …) keep the guard.
|
|
4033
4237
|
*/
|
|
4034
4238
|
function isBroadScanRoot(requestedPath) {
|
|
4239
|
+
return broadScanRootKind(requestedPath) !== undefined;
|
|
4240
|
+
}
|
|
4241
|
+
/**
|
|
4242
|
+
* A project-scoped command, printed so it WORKS from where it was printed.
|
|
4243
|
+
*
|
|
4244
|
+
* The founder's most-repeated complaint class (0.9.4 report, 0.9.6 index, and
|
|
4245
|
+
* these): a surface running machine-wide prints a bare project-scoped command,
|
|
4246
|
+
* the user copies it, and it friendly-refuses in the very directory that
|
|
4247
|
+
* printed it. Commands that genuinely need one project carry their own `cd`
|
|
4248
|
+
* from a broad root — a PATH placeholder, never `<project>`, which the shell
|
|
4249
|
+
* would read as redirection.
|
|
4250
|
+
*/
|
|
4251
|
+
function projectScopedPointer(command, requestedPath) {
|
|
4252
|
+
return isBroadScanRoot(requestedPath) ? `cd /path/to/project && ${command}` : command;
|
|
4253
|
+
}
|
|
4254
|
+
/** Classifies WHICH broad-root category a machine-wide path falls in. */
|
|
4255
|
+
function broadScanRootKind(requestedPath) {
|
|
4035
4256
|
const rootPath = resolve(requestedPath);
|
|
4036
4257
|
const home = homedir();
|
|
4037
4258
|
const guardHome = home && home.trim().length > 0
|
|
4038
4259
|
? home
|
|
4039
4260
|
: join(rootPath, "aibill-impossible-home-sentinel");
|
|
4040
|
-
|
|
4261
|
+
const reason = unsafeScanRootReason(rootPath, guardHome);
|
|
4262
|
+
if (reason === undefined)
|
|
4263
|
+
return undefined;
|
|
4264
|
+
if (reason.includes("filesystem root"))
|
|
4265
|
+
return "filesystem-root";
|
|
4266
|
+
if (reason.includes("home directory is too broad"))
|
|
4267
|
+
return "home";
|
|
4268
|
+
if (reason.includes("contains your home directory"))
|
|
4269
|
+
return "contains-home";
|
|
4270
|
+
return "system-directory";
|
|
4271
|
+
}
|
|
4272
|
+
/**
|
|
4273
|
+
* 0.9.5: machine-wide report/report-card write their artifacts INTO the
|
|
4274
|
+
* requested root. That works from the home directory, but /, /etc, or a
|
|
4275
|
+
* folder that contains home cannot hold them — the run used to die at write
|
|
4276
|
+
* time with a wrapped raw error ("Couldn't build a report: EROFS…",
|
|
4277
|
+
* "Refusing to use /etc…"). Those roots get the friendly guard voice BEFORE
|
|
4278
|
+
* anything is scanned or written. Returns undefined when the root is home
|
|
4279
|
+
* itself (machine-wide proceeds) or when an explicit absolute --out points
|
|
4280
|
+
* the artifacts somewhere else entirely.
|
|
4281
|
+
*/
|
|
4282
|
+
function guardUnwritableBroadRoot(commandName, args) {
|
|
4283
|
+
const kind = broadScanRootKind(args.path);
|
|
4284
|
+
if (kind === undefined || kind === "home")
|
|
4285
|
+
return undefined;
|
|
4286
|
+
// An absolute --out lands outside the broad root; only rootPath-relative
|
|
4287
|
+
// artifacts make this location a write problem.
|
|
4288
|
+
if (args.out !== undefined && isAbsolute(args.out))
|
|
4289
|
+
return undefined;
|
|
4290
|
+
const artifactNoun = commandName === "report" ? "its report files" : "the receipt";
|
|
4291
|
+
const explanation = kind === "filesystem-root"
|
|
4292
|
+
? `You pointed it at the filesystem root, which can't hold ${artifactNoun}.`
|
|
4293
|
+
: kind === "contains-home"
|
|
4294
|
+
? `You pointed it at a folder that contains your home directory, which can't hold ${artifactNoun}.`
|
|
4295
|
+
: `You pointed it at a system directory, which can't hold ${artifactNoun}.`;
|
|
4296
|
+
return {
|
|
4297
|
+
exitCode: 1,
|
|
4298
|
+
stdout: "",
|
|
4299
|
+
stderr: [
|
|
4300
|
+
`aibill ${commandName} writes ${artifactNoun} into the folder it points at.`,
|
|
4301
|
+
explanation,
|
|
4302
|
+
"",
|
|
4303
|
+
"Run it from your home directory for a machine-wide view, or from one exact project folder",
|
|
4304
|
+
` e.g. cd ~ && ${actionRuntimeCommand(commandName)}`,
|
|
4305
|
+
"",
|
|
4306
|
+
"Nothing was read, created, or changed."
|
|
4307
|
+
].join("\n")
|
|
4308
|
+
};
|
|
4041
4309
|
}
|
|
4042
4310
|
async function guardExactProjectRoot(commandName, requestedPath) {
|
|
4043
4311
|
const rootPath = resolve(requestedPath);
|
|
@@ -5802,7 +6070,7 @@ async function tokenVerificationCommand(args) {
|
|
|
5802
6070
|
}
|
|
5803
6071
|
const observation = await loadTokenVerificationObservation(rootPath, experiment);
|
|
5804
6072
|
if (!observation.qualitativeCoverageComplete) {
|
|
5805
|
-
return tokenVerificationUsageError("
|
|
6073
|
+
return tokenVerificationUsageError("this experiment's agent still has unread session transcripts; no post-change sessions or reduction percentage were accepted. Run `npx aibill index` to finish reading them, then verify again");
|
|
5806
6074
|
}
|
|
5807
6075
|
const unlabelled = refreshTokenReductionExperimentV0(experiment, {
|
|
5808
6076
|
sessionVitals: observation.sessionVitals,
|
|
@@ -5956,36 +6224,50 @@ function applyEvidenceAcquisitionLines(input) {
|
|
|
5956
6224
|
return lines;
|
|
5957
6225
|
}
|
|
5958
6226
|
/**
|
|
5959
|
-
* Machine-wide report input
|
|
5960
|
-
*
|
|
5961
|
-
*
|
|
5962
|
-
*
|
|
6227
|
+
* Machine-wide report input — built from {@link loadBroadScanEvidence}, the
|
|
6228
|
+
* SAME call the `--full` readout makes from the same directory (0.9.6).
|
|
6229
|
+
*
|
|
6230
|
+
* 0.9.4 shipped this as a parallel thin builder over `loadInstantReadData`,
|
|
6231
|
+
* keeping only the financial records. Everything that gates an action claim —
|
|
6232
|
+
* the bounded transcript index above all — was dropped, so the report package
|
|
6233
|
+
* fell back to `qualitativeCoverage: "unknown"` and every ACT/VERIFY branch
|
|
6234
|
+
* degraded, while `--full` from the same home showed real ranked candidates.
|
|
6235
|
+
* The evidence is not project-scoped and never was; only the builder was
|
|
6236
|
+
* withholding it.
|
|
6237
|
+
*
|
|
6238
|
+
* No project state is read or created. Everything below comes from the shared
|
|
6239
|
+
* bundle; nothing is re-derived here.
|
|
5963
6240
|
*/
|
|
5964
6241
|
async function buildMachineWideReportInput(args, sinceDays) {
|
|
5965
|
-
const
|
|
5966
|
-
if (records.length === 0) {
|
|
6242
|
+
const evidence = await loadBroadScanEvidence(args, sinceDays);
|
|
6243
|
+
if (evidence.records.length === 0) {
|
|
5967
6244
|
// Same honest empty-state voice the receipt/report-card use — an empty
|
|
5968
6245
|
// report file would just look broken.
|
|
5969
|
-
return { kind: "no_evidence", warnings };
|
|
6246
|
+
return { kind: "no_evidence", warnings: evidence.warnings };
|
|
5970
6247
|
}
|
|
5971
|
-
const
|
|
5972
|
-
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
5973
|
-
codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
|
|
5974
|
-
}).catch(() => []);
|
|
5975
|
-
const headlineRecords = mode === "connected"
|
|
5976
|
-
? selectProviderFinancialHeadlineRecords(records)
|
|
5977
|
-
: records;
|
|
6248
|
+
const { records, mode, actionEvidence } = evidence;
|
|
5978
6249
|
return {
|
|
5979
6250
|
kind: "input",
|
|
5980
6251
|
input: {
|
|
5981
6252
|
generatedAt: new Date().toISOString(),
|
|
5982
|
-
summary:
|
|
6253
|
+
summary: evidence.summary,
|
|
5983
6254
|
allRecords: records,
|
|
5984
6255
|
dataMode: mode === "connected" ? "connected_provider" : "local_logs",
|
|
5985
6256
|
evidenceWindowDays: sinceDays,
|
|
5986
|
-
detectedPlans,
|
|
6257
|
+
detectedPlans: evidence.detectedPlans,
|
|
6258
|
+
// The ranked set the readout prints, verbatim — not a second derivation.
|
|
6259
|
+
actionCandidates: evidence.actionCandidates,
|
|
6260
|
+
analysisScope: "machine-wide",
|
|
6261
|
+
// The bounded transcript index the 0.9.4 builder dropped. This is what
|
|
6262
|
+
// un-degrades ACT, VERIFY, plan context, and the configuration section.
|
|
6263
|
+
qualitativeCoverage: summarizeCliQualitativeCoverage(actionEvidence),
|
|
6264
|
+
qualitativeCoverageByAgent: summarizeCliQualitativeCoverageByAgent(actionEvidence),
|
|
6265
|
+
...(evidence.deadContext ? { deadContext: evidence.deadContext } : {}),
|
|
5987
6266
|
...(mode === "connected" ? { providerRecords: records } : {}),
|
|
5988
|
-
...(
|
|
6267
|
+
...(mode === "connected" && (evidence.localFinancialRecords?.length ?? 0) > 0
|
|
6268
|
+
? { localFinancialRecords: evidence.localFinancialRecords }
|
|
6269
|
+
: {}),
|
|
6270
|
+
...(evidence.providerCoverage ? { providerCoverage: evidence.providerCoverage } : {})
|
|
5989
6271
|
}
|
|
5990
6272
|
};
|
|
5991
6273
|
}
|
|
@@ -6418,6 +6700,10 @@ function parseArgs(argv) {
|
|
|
6418
6700
|
parsed.noColor = true;
|
|
6419
6701
|
continue;
|
|
6420
6702
|
}
|
|
6703
|
+
if (arg === "--no-open") {
|
|
6704
|
+
parsed.noOpen = true;
|
|
6705
|
+
continue;
|
|
6706
|
+
}
|
|
6421
6707
|
if (arg === "--json") {
|
|
6422
6708
|
parsed.json = true;
|
|
6423
6709
|
continue;
|
|
@@ -7032,8 +7318,13 @@ function helpText(telemetryDisclosure) {
|
|
|
7032
7318
|
" quickstart [--sample] [--since-days N] Plain-English local readout (default 30 days)",
|
|
7033
7319
|
" [--full] Render the complete audit; default is the compact receipt",
|
|
7034
7320
|
" [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: project for local logs; model otherwise",
|
|
7035
|
-
" report [--sample] [--out <name>] [--since-days N] Generate local Markdown and HTML reports
|
|
7321
|
+
" report [--sample] [--out <name>] [--since-days N] Generate local Markdown and HTML reports and open the HTML in your browser",
|
|
7322
|
+
" [--no-open] Skip the automatic browser open (also AI_SPEND_NO_OPEN=1; auto-open is TTY-only and never fires in CI or SSH sessions)",
|
|
7036
7323
|
" report-card [--out f.svg] Write your AI Receipt — a redacted, shareable SVG + caption",
|
|
7324
|
+
// C3: report-card writes TWO files. `--out index.svg` silently replaced an
|
|
7325
|
+
// existing index.html next to it; the second artifact is now disclosed
|
|
7326
|
+
// where the user chooses the name.
|
|
7327
|
+
" [--out f.svg] Also writes the companion viewer f.html alongside it, overwriting any existing file of that name",
|
|
7037
7328
|
" glance [--project <name>] [--plan <id>] [--since-days N] Emit the local, machine-readable Glance snapshot JSON",
|
|
7038
7329
|
" context [--project <name>] [--since-days N] Show hook-aware Context Health in the terminal",
|
|
7039
7330
|
" [--json] Emit the same canonical Context Health object used by MCP and Glance",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export type ReportOpenDecision = {
|
|
3
|
+
open: true;
|
|
4
|
+
command: string;
|
|
5
|
+
args: string[];
|
|
6
|
+
} | {
|
|
7
|
+
open: false;
|
|
8
|
+
reason: "no-open-flag" | "env-switch" | "not-a-tty" | "ci" | "ssh" | "unsafe-path" | "no-opener";
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The command a USER would type to open a file on this platform (0.9.6).
|
|
12
|
+
*
|
|
13
|
+
* The printed "open this report" pointers hardcoded macOS `open`, so every
|
|
14
|
+
* Linux and Windows user was told to run a command they do not have — while
|
|
15
|
+
* {@link decideReportAutoOpen} right below already branched per platform to
|
|
16
|
+
* decide what to spawn. One platform decision, used by both.
|
|
17
|
+
*
|
|
18
|
+
* This is the TYPED form, which is not always the spawned form: auto-open uses
|
|
19
|
+
* `rundll32 url.dll,FileProtocolHandler` on Windows precisely to keep cmd.exe
|
|
20
|
+
* out of the chain, but nobody types that — `start` is the builtin a Windows
|
|
21
|
+
* user actually uses, and by then they are typing into a shell anyway.
|
|
22
|
+
*/
|
|
23
|
+
export declare function platformOpenCommand(platform?: NodeJS.Platform): string;
|
|
24
|
+
export declare function decideReportAutoOpen(input: {
|
|
25
|
+
htmlPath: string;
|
|
26
|
+
noOpenFlag: boolean;
|
|
27
|
+
env?: NodeJS.ProcessEnv;
|
|
28
|
+
platform?: NodeJS.Platform;
|
|
29
|
+
stdoutIsTty?: boolean;
|
|
30
|
+
/** Test seam for the linux xdg-open PATH probe. */
|
|
31
|
+
hasCommandImpl?: (command: string, env: NodeJS.ProcessEnv) => boolean;
|
|
32
|
+
}): ReportOpenDecision;
|
|
33
|
+
/**
|
|
34
|
+
* Fire-and-forget launch of an affirmative decision. Returns true when the
|
|
35
|
+
* opener was handed to the OS (the summary may then say "opened …");
|
|
36
|
+
* returns false — never throws — on any spawn failure.
|
|
37
|
+
*/
|
|
38
|
+
export declare function openReportInBrowser(decision: ReportOpenDecision, options?: {
|
|
39
|
+
spawnImpl?: typeof spawn;
|
|
40
|
+
}): boolean;
|
|
41
|
+
//# sourceMappingURL=reportOpener.d.ts.map
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { accessSync, constants } from "node:fs";
|
|
3
|
+
import { delimiter, join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* 0.9.5 "agent feel": after `report` writes its artifacts, the HTML report
|
|
6
|
+
* opens in the user's browser automatically — through the platform opener,
|
|
7
|
+
* never a hardcoded browser:
|
|
8
|
+
*
|
|
9
|
+
* darwin → open <html>
|
|
10
|
+
* linux → xdg-open <html> (only when xdg-open is actually on PATH)
|
|
11
|
+
* win32 → rundll32 url.dll,FileProtocolHandler <html>
|
|
12
|
+
*
|
|
13
|
+
* The decision to open is computed SYNCHRONOUSLY and truthfully before the
|
|
14
|
+
* summary renders, so the summary's Next block can say what actually
|
|
15
|
+
* happened. Auto-open is suppressed — silently, keeping the plain
|
|
16
|
+
* `open <path>` pointer — whenever any of these hold:
|
|
17
|
+
*
|
|
18
|
+
* - stdout is not a TTY (pipes, redirection, scripts)
|
|
19
|
+
* - CI is set (any non-empty value)
|
|
20
|
+
* - an SSH session (SSH_CONNECTION or SSH_TTY set): the browser would
|
|
21
|
+
* open on the wrong machine
|
|
22
|
+
* - the user asked not to: `--no-open` flag or AI_SPEND_NO_OPEN env
|
|
23
|
+
* (any non-empty value, same convention as AI_SPEND_NO_TELEMETRY)
|
|
24
|
+
* - the resolved path contains a shell metacharacter (see below)
|
|
25
|
+
* - the platform has no known opener (or linux without xdg-open)
|
|
26
|
+
*
|
|
27
|
+
* The spawn itself follows the telemetry detached-child pattern: detached,
|
|
28
|
+
* stdio ignored, unref'd, every failure (including async ENOENT) swallowed —
|
|
29
|
+
* the CLI must never crash, hang, or delay exit because an opener is
|
|
30
|
+
* missing or slow.
|
|
31
|
+
*
|
|
32
|
+
* SECURITY (win32 command-injection, fixed 0.9.5): the earlier
|
|
33
|
+
* `cmd /c start "" <path>` opener passed the path through cmd.exe, which
|
|
34
|
+
* re-parses `& ^ % ( ) < > |` even when spawned with shell:false — a
|
|
35
|
+
* space-free path like `C:\code\proj&calc` (all legal filename chars)
|
|
36
|
+
* would make cmd execute `calc`, and `%VAR%` would expand (info leak). The
|
|
37
|
+
* cwd-derived machine-wide path AND an absolute `--out` both reach here.
|
|
38
|
+
* Two independent defenses now stand:
|
|
39
|
+
* 1. UNSAFE_PATH_METACHARACTERS refuses auto-open (falling back to the
|
|
40
|
+
* plain pointer) for ANY path carrying those characters or a quote,
|
|
41
|
+
* on every platform — a metacharacter path is a reasonable thing to
|
|
42
|
+
* decline to shell-open anywhere.
|
|
43
|
+
* 2. The win32 opener no longer touches a shell: rundll32 hands the path
|
|
44
|
+
* straight to url.dll's FileProtocolHandler with a discrete argv, so
|
|
45
|
+
* even a metacharacter path that slipped past (1) cannot reach cmd.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* cmd.exe re-parsing set plus the double-quote (which can break out of
|
|
49
|
+
* libuv's own arg quoting). A path containing any of these is never handed
|
|
50
|
+
* to a platform opener; auto-open falls back to the plain pointer instead.
|
|
51
|
+
*/
|
|
52
|
+
const UNSAFE_PATH_METACHARACTERS = /[&^%()<>|"]/u;
|
|
53
|
+
/**
|
|
54
|
+
* The command a USER would type to open a file on this platform (0.9.6).
|
|
55
|
+
*
|
|
56
|
+
* The printed "open this report" pointers hardcoded macOS `open`, so every
|
|
57
|
+
* Linux and Windows user was told to run a command they do not have — while
|
|
58
|
+
* {@link decideReportAutoOpen} right below already branched per platform to
|
|
59
|
+
* decide what to spawn. One platform decision, used by both.
|
|
60
|
+
*
|
|
61
|
+
* This is the TYPED form, which is not always the spawned form: auto-open uses
|
|
62
|
+
* `rundll32 url.dll,FileProtocolHandler` on Windows precisely to keep cmd.exe
|
|
63
|
+
* out of the chain, but nobody types that — `start` is the builtin a Windows
|
|
64
|
+
* user actually uses, and by then they are typing into a shell anyway.
|
|
65
|
+
*/
|
|
66
|
+
export function platformOpenCommand(platform = process.platform) {
|
|
67
|
+
if (platform === "darwin")
|
|
68
|
+
return "open";
|
|
69
|
+
// `start ""`, not bare `start`. cmd's `start` reads a QUOTED first argument
|
|
70
|
+
// as the new window's title, so `start "C:\...\ai-receipt.html"` opens an
|
|
71
|
+
// empty console window titled with the path and never opens the file. The
|
|
72
|
+
// empty title is the canonical fix, and every path we print is quoted.
|
|
73
|
+
if (platform === "win32")
|
|
74
|
+
return 'start ""';
|
|
75
|
+
return "xdg-open";
|
|
76
|
+
}
|
|
77
|
+
export function decideReportAutoOpen(input) {
|
|
78
|
+
const env = input.env ?? process.env;
|
|
79
|
+
const platform = input.platform ?? process.platform;
|
|
80
|
+
const stdoutIsTty = input.stdoutIsTty ?? Boolean(process.stdout.isTTY);
|
|
81
|
+
const hasCommand = input.hasCommandImpl ?? commandOnPath;
|
|
82
|
+
if (input.noOpenFlag)
|
|
83
|
+
return { open: false, reason: "no-open-flag" };
|
|
84
|
+
if (env.AI_SPEND_NO_OPEN)
|
|
85
|
+
return { open: false, reason: "env-switch" };
|
|
86
|
+
if (!stdoutIsTty)
|
|
87
|
+
return { open: false, reason: "not-a-tty" };
|
|
88
|
+
if (env.CI)
|
|
89
|
+
return { open: false, reason: "ci" };
|
|
90
|
+
if (env.SSH_CONNECTION || env.SSH_TTY)
|
|
91
|
+
return { open: false, reason: "ssh" };
|
|
92
|
+
// Defense (1): never shell-open a path carrying a cmd metacharacter or a
|
|
93
|
+
// quote — on any platform. This alone neutralizes the win32 vector.
|
|
94
|
+
if (UNSAFE_PATH_METACHARACTERS.test(input.htmlPath)) {
|
|
95
|
+
return { open: false, reason: "unsafe-path" };
|
|
96
|
+
}
|
|
97
|
+
if (platform === "darwin") {
|
|
98
|
+
return { open: true, command: "open", args: [input.htmlPath] };
|
|
99
|
+
}
|
|
100
|
+
if (platform === "win32") {
|
|
101
|
+
// Defense (2): rundll32 → url.dll,FileProtocolHandler opens the path
|
|
102
|
+
// with NO shell in the chain — cmd.exe never sees it, so its
|
|
103
|
+
// `& ^ % ( ) < > |` re-parsing (which shell:false does not prevent for
|
|
104
|
+
// `cmd /c start`) cannot fire even if defense (1) ever missed a char.
|
|
105
|
+
return {
|
|
106
|
+
open: true,
|
|
107
|
+
command: "rundll32",
|
|
108
|
+
args: ["url.dll,FileProtocolHandler", input.htmlPath]
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (platform === "linux" && hasCommand("xdg-open", env)) {
|
|
112
|
+
return { open: true, command: "xdg-open", args: [input.htmlPath] };
|
|
113
|
+
}
|
|
114
|
+
return { open: false, reason: "no-opener" };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Fire-and-forget launch of an affirmative decision. Returns true when the
|
|
118
|
+
* opener was handed to the OS (the summary may then say "opened …");
|
|
119
|
+
* returns false — never throws — on any spawn failure.
|
|
120
|
+
*/
|
|
121
|
+
export function openReportInBrowser(decision, options = {}) {
|
|
122
|
+
if (!decision.open)
|
|
123
|
+
return false;
|
|
124
|
+
try {
|
|
125
|
+
const spawnImpl = options.spawnImpl ?? spawn;
|
|
126
|
+
const child = spawnImpl(decision.command, decision.args, {
|
|
127
|
+
detached: true,
|
|
128
|
+
stdio: "ignore"
|
|
129
|
+
});
|
|
130
|
+
// Async spawn errors (a vanished opener) surface on the child, not the
|
|
131
|
+
// call — swallow them so they can never crash the exiting CLI.
|
|
132
|
+
child.on?.("error", () => { });
|
|
133
|
+
child.unref();
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Synchronous PATH probe (linux xdg-open) — cheap, no child process. */
|
|
141
|
+
function commandOnPath(command, env) {
|
|
142
|
+
const pathValue = env.PATH ?? "";
|
|
143
|
+
for (const dir of pathValue.split(delimiter)) {
|
|
144
|
+
if (!dir)
|
|
145
|
+
continue;
|
|
146
|
+
try {
|
|
147
|
+
accessSync(join(dir, command), constants.X_OK);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// keep looking
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
//# sourceMappingURL=reportOpener.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-spend-agent",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.6",
|
|
4
4
|
"funding": "https://asktilden.com",
|
|
5
5
|
"description": "Local-first financial accountability CLI: Claude Code/Codex attribution, provenance, and next actions, plus experimental Gemini CLI cost evidence.",
|
|
6
6
|
"type": "module",
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"prepack": "npm run build"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@agent-finops/core": "0.9.
|
|
59
|
-
"@agent-finops/report": "0.9.
|
|
58
|
+
"@agent-finops/core": "0.9.6",
|
|
59
|
+
"@agent-finops/report": "0.9.6",
|
|
60
60
|
"yocto-spinner": "^1.2.0"
|
|
61
61
|
}
|
|
62
62
|
}
|