ai-spend-agent 0.9.3 → 0.9.5
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 +313 -99
- package/dist/reportOpener.d.ts +27 -0
- package/dist/reportOpener.js +132 -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,7 +3,7 @@ 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";
|
|
@@ -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 } from "./reportOpener.js";
|
|
21
|
+
import { generateActionPlanMarkdown, generateApplyArtifactMarkdown, generateCommandSummary, generateDemoPackageMarkdown, generateHtmlReport, generateMarkdownReport, generatePlainEnglishSummary, generatePolicyConfigDraftMarkdown, generateReportCardCaption, generateReportCardSvg, generateVerificationPlanMarkdown, groupByDimensions } 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
|
|
@@ -272,6 +273,11 @@ async function quickstartCommand(args, runtime = {}) {
|
|
|
272
273
|
nextSteps,
|
|
273
274
|
deadContext,
|
|
274
275
|
detectedPlans,
|
|
276
|
+
// 0.9.5: from a broad root the --full view's project-scoped pointers
|
|
277
|
+
// (apply, apply-artifact, watch, connect) carry the machine-wide
|
|
278
|
+
// report's `cd <project> && …` prefix instead of advertising commands
|
|
279
|
+
// that friendly-refuse right where they were printed.
|
|
280
|
+
commandScope: isBroadScanRoot(args.path) ? "machine-wide" : "project",
|
|
275
281
|
// C-lane §1.4: the result card header states the evidence window.
|
|
276
282
|
windowDays: sinceDays,
|
|
277
283
|
width: outputWidth,
|
|
@@ -618,9 +624,11 @@ function quickstartNextSteps(mode, detected) {
|
|
|
618
624
|
steps.push(`npx aibill connect ${detected[0].provider} set up the admin connector, then sync provider-reported cost`);
|
|
619
625
|
}
|
|
620
626
|
steps.push(mode === "demo"
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
|
|
627
|
+
// 0.9.4: report --sample runs as printed from ANY directory — broad
|
|
628
|
+
// roots write ./ai-spend-report.{md,html} machine-wide-style, project
|
|
629
|
+
// folders keep .ai-spend-agent/report.* (the old mkdir demo-workspace
|
|
630
|
+
// preamble is no longer needed for the command to run as printed).
|
|
631
|
+
? "npx aibill report --sample write a clearly labeled demo report right here"
|
|
624
632
|
: "npx aibill report write a shareable Markdown + HTML report");
|
|
625
633
|
steps.push("npx aibill --group-by project see which project has the most observed activity");
|
|
626
634
|
steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://asktilden.com");
|
|
@@ -1154,7 +1162,9 @@ function noEvidenceResult(surface, warnings, sinceDays, telemetryDisclosure) {
|
|
|
1154
1162
|
? "Watch has no financial baseline yet; no zero total or sample activity was recorded."
|
|
1155
1163
|
: surface === "report-card"
|
|
1156
1164
|
? "No receipt was written because there is no supported financial evidence to summarize."
|
|
1157
|
-
:
|
|
1165
|
+
: surface === "report"
|
|
1166
|
+
? "No report was written because there is no supported financial evidence to summarize."
|
|
1167
|
+
: `No supported AI usage evidence was found in the last ${sinceDays} days.`;
|
|
1158
1168
|
return {
|
|
1159
1169
|
exitCode: surface === "receipt" ? 0 : 1,
|
|
1160
1170
|
stdout: surface === "receipt"
|
|
@@ -3758,27 +3768,53 @@ async function confirmMappingCommand(args) {
|
|
|
3758
3768
|
].join("\n"));
|
|
3759
3769
|
}
|
|
3760
3770
|
async function reportCommand(args, runtime = {}) {
|
|
3761
|
-
//
|
|
3762
|
-
//
|
|
3763
|
-
//
|
|
3764
|
-
//
|
|
3765
|
-
//
|
|
3766
|
-
//
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3771
|
+
// 0.9.4: a broad root (home, /) runs MACHINE-WIDE — the same read-only
|
|
3772
|
+
// transcript scanning as the bare receipt, no project state created, both
|
|
3773
|
+
// report files written to the current directory. The report renders
|
|
3774
|
+
// machine-wide content anyway (it lists every project), so the
|
|
3775
|
+
// exact-project requirement was incoherent here: the receipt's own Next
|
|
3776
|
+
// pointer led from home straight into a refusal. Only a bogus --path
|
|
3777
|
+
// still gets the friendly guard; project folders behave exactly as
|
|
3778
|
+
// before. 0.9.5: broad roots that cannot HOLD the artifacts (/, /etc,
|
|
3779
|
+
// /Users, …) get the friendly guard voice up front instead of dying at
|
|
3780
|
+
// write time with a wrapped raw error.
|
|
3781
|
+
const machineWide = isBroadScanRoot(args.path);
|
|
3782
|
+
if (machineWide) {
|
|
3783
|
+
const broadGuard = guardUnwritableBroadRoot("report", args);
|
|
3784
|
+
if (broadGuard)
|
|
3785
|
+
return broadGuard;
|
|
3786
|
+
}
|
|
3787
|
+
else {
|
|
3788
|
+
const rootGuard = await guardExactProjectRoot("report", args.path);
|
|
3789
|
+
if (rootGuard)
|
|
3790
|
+
return rootGuard;
|
|
3791
|
+
}
|
|
3770
3792
|
const rootPath = resolve(args.path);
|
|
3771
3793
|
try {
|
|
3772
3794
|
const sinceDays = args.sinceDays ?? 30;
|
|
3773
3795
|
if (!validSinceDays(sinceDays))
|
|
3774
3796
|
return invalidSinceDaysResult();
|
|
3775
|
-
|
|
3797
|
+
// Machine-wide mode NEVER creates project state at the broad root — the
|
|
3798
|
+
// only writes are the two report files below (plus ~/.aibill home state
|
|
3799
|
+
// owned by other subsystems).
|
|
3800
|
+
const stateDir = machineWide ? undefined : await resolveSafeStateDirectory(rootPath, { create: true });
|
|
3776
3801
|
// Like Apply, an explicit sample report is a strict privacy boundary. It
|
|
3777
3802
|
// must not inspect local transcripts, account metadata, or persisted state.
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3803
|
+
let reportInput;
|
|
3804
|
+
if (args.sample) {
|
|
3805
|
+
reportInput = await buildExplicitSampleReportInput(rootPath);
|
|
3806
|
+
}
|
|
3807
|
+
else if (machineWide) {
|
|
3808
|
+
const machineWideInput = await buildMachineWideReportInput(args, sinceDays);
|
|
3809
|
+
if (machineWideInput.kind === "no_evidence") {
|
|
3810
|
+
return noEvidenceResult("report", machineWideInput.warnings, sinceDays, runtime.telemetryDisclosure);
|
|
3811
|
+
}
|
|
3812
|
+
reportInput = machineWideInput.input;
|
|
3813
|
+
}
|
|
3814
|
+
else {
|
|
3815
|
+
reportInput = await buildReportInput(stateDir, rootPath, sinceDays);
|
|
3816
|
+
}
|
|
3817
|
+
const persistedPreferredExperiment = args.sample || machineWide
|
|
3782
3818
|
? undefined
|
|
3783
3819
|
: chooseLatestTokenReductionExperiment((await loadTokenVerificationState(rootPath)).experiments);
|
|
3784
3820
|
const preferredExperiment = persistedPreferredExperiment &&
|
|
@@ -3820,69 +3856,119 @@ async function reportCommand(args, runtime = {}) {
|
|
|
3820
3856
|
: { ...reportInput, telemetryDisclosure };
|
|
3821
3857
|
const qualitativeActionsSuppressed = reportInput.dataMode !== "sample" &&
|
|
3822
3858
|
reportInput.qualitativeCoverage?.status !== "complete";
|
|
3823
|
-
|
|
3859
|
+
// Machine-wide artifacts land in the CURRENT directory under the
|
|
3860
|
+
// ai-spend-* family name; project mode keeps .ai-spend-agent/report.*.
|
|
3861
|
+
const outBase = args.out
|
|
3862
|
+
? resolve(rootPath, args.out)
|
|
3863
|
+
: machineWide
|
|
3864
|
+
? join(rootPath, "ai-spend-report")
|
|
3865
|
+
: join(stateDir, "report");
|
|
3824
3866
|
const markdownPath = `${outBase}.md`;
|
|
3825
3867
|
const htmlPath = `${outBase}.html`;
|
|
3826
|
-
await writeLocalReportFile(markdownPath, generateMarkdownReport(reportRenderInput), stateDir);
|
|
3827
|
-
await writeLocalReportFile(htmlPath, generateHtmlReport(reportRenderInput), stateDir);
|
|
3868
|
+
await writeLocalReportFile(markdownPath, generateMarkdownReport(reportRenderInput), stateDir ?? rootPath);
|
|
3869
|
+
await writeLocalReportFile(htmlPath, generateHtmlReport(reportRenderInput), stateDir ?? rootPath);
|
|
3828
3870
|
// A preferred canonical experiment owns this project's action/result
|
|
3829
3871
|
// lineage even after completion or rollback. A report may refresh its
|
|
3830
3872
|
// read-only projection, but never overwrite the frozen handoff with a
|
|
3831
3873
|
// fresh or contradictory candidate. Coverage gaps receive only explicit
|
|
3832
3874
|
// non-executable gap artifacts from the report package.
|
|
3833
|
-
|
|
3875
|
+
// Apply artifacts are project-scoped handoffs — machine-wide runs skip
|
|
3876
|
+
// them (apply itself still requires one exact project folder).
|
|
3877
|
+
const artifactPaths = reportableExperiment || machineWide
|
|
3834
3878
|
? undefined
|
|
3835
3879
|
: await writeApplyArtifacts(stateDir, reportInput);
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3880
|
+
// 0.9.5 "agent feel": the HTML report opens itself in the browser via
|
|
3881
|
+
// the platform opener — decided truthfully BEFORE the summary renders,
|
|
3882
|
+
// suppressed for non-TTY/CI/SSH/--no-open/AI_SPEND_NO_OPEN, and fired
|
|
3883
|
+
// detached so a missing or slow opener can never crash, hang, or delay
|
|
3884
|
+
// exit (the telemetry detached-child pattern).
|
|
3885
|
+
const openDecision = (runtime.reportOpenDecide ?? decideReportAutoOpen)({
|
|
3886
|
+
htmlPath,
|
|
3887
|
+
noOpenFlag: args.noOpen === true
|
|
3888
|
+
});
|
|
3889
|
+
const openedInBrowser = (runtime.reportOpenLaunch ?? openReportInBrowser)(openDecision);
|
|
3890
|
+
// 0.9.5 founder polish ("really hard to read… I wonder if we can have
|
|
3891
|
+
// the text aligned"): the same facts, rendered in the receipt's visual
|
|
3892
|
+
// language — header, one shared label column, dot separators, and a Next
|
|
3893
|
+
// block whose commands pad to one description column. Display-only.
|
|
3894
|
+
const rows = [
|
|
3895
|
+
machineWide
|
|
3896
|
+
? { label: "Scope", value: `machine-wide · all supported local agent evidence on this machine (last ${sinceDays} days) · artifacts in ${rootPath}` }
|
|
3897
|
+
: { label: "Path", value: rootPath },
|
|
3898
|
+
{ label: "Markdown", value: markdownPath },
|
|
3899
|
+
{ label: "HTML", value: htmlPath },
|
|
3900
|
+
...(machineWide
|
|
3901
|
+
? []
|
|
3902
|
+
: reportableExperiment
|
|
3848
3903
|
? [
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
`coverage policy/config: ${artifactPaths.policyConfigDraft}`,
|
|
3853
|
-
`coverage verification: ${artifactPaths.verificationPlan}`,
|
|
3854
|
-
`coverage package: ${artifactPaths.demoPackage}`
|
|
3904
|
+
{ label: "Action artifacts", value: `preserved · canonical token test ${reportableExperiment.id} (${reportableExperiment.lifecycle})` },
|
|
3905
|
+
{ 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}` },
|
|
3906
|
+
{ label: "Token test", value: improveRuntimeCommand }
|
|
3855
3907
|
]
|
|
3856
|
-
:
|
|
3908
|
+
: qualitativeActionsSuppressed
|
|
3857
3909
|
? [
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3910
|
+
{ label: "Action artifacts", value: `suppressed · qualitative index ${reportInput.qualitativeCoverage?.status ?? "unknown"}` },
|
|
3911
|
+
{ label: "Coverage artifact", value: artifactPaths.codingPrompt },
|
|
3912
|
+
{ label: "Coverage action plan", value: artifactPaths.actionPlan },
|
|
3913
|
+
{ label: "Coverage policy/config", value: artifactPaths.policyConfigDraft },
|
|
3914
|
+
{ label: "Coverage verification", value: artifactPaths.verificationPlan },
|
|
3915
|
+
{ label: "Coverage package", value: artifactPaths.demoPackage }
|
|
3863
3916
|
]
|
|
3864
|
-
:
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3917
|
+
: artifactPaths
|
|
3918
|
+
? [
|
|
3919
|
+
{ label: "Apply artifact", value: artifactPaths.codingPrompt },
|
|
3920
|
+
{ label: "Action plan", value: artifactPaths.actionPlan },
|
|
3921
|
+
{ label: "Policy/config draft", value: artifactPaths.policyConfigDraft },
|
|
3922
|
+
{ label: "Verification plan", value: artifactPaths.verificationPlan },
|
|
3923
|
+
{ label: "Demo package", value: artifactPaths.demoPackage }
|
|
3924
|
+
]
|
|
3925
|
+
: []),
|
|
3926
|
+
{
|
|
3927
|
+
label: "Total",
|
|
3928
|
+
value: reportInput.dataMode === "sample"
|
|
3929
|
+
? `${formatOptionalUsd(reportInput.summary.totalUsd)} · DEMO SAMPLE · illustrative cost/value evidence · not user data`
|
|
3930
|
+
: reportInput.dataMode === "connected_provider" &&
|
|
3931
|
+
!(reportInput.allRecords ?? reportInput.providerRecords ?? []).some((record) => typeof record.amountUsd === "number")
|
|
3932
|
+
? "Unavailable · cost/value evidence · no priced financial evidence; missing/null is not zero"
|
|
3933
|
+
: `${formatOptionalUsd(reportInput.summary.totalUsd)} · cost/value evidence`
|
|
3934
|
+
},
|
|
3935
|
+
{
|
|
3936
|
+
label: "Privacy",
|
|
3937
|
+
value: runtime.telemetryDisclosure === true
|
|
3938
|
+
? `report rendered locally · ${telemetryDisclosureLine}; only explicit sync-provider contacts the selected provider`
|
|
3939
|
+
: "report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider"
|
|
3940
|
+
}
|
|
3941
|
+
];
|
|
3942
|
+
const nextSteps = [
|
|
3943
|
+
// Summary-line truth: only a fired opener may claim it opened; every
|
|
3944
|
+
// suppression path keeps the plain copy-pasteable pointer.
|
|
3945
|
+
openedInBrowser
|
|
3946
|
+
? { command: `opened ${basename(htmlPath)} in your browser · next time: --no-open to skip` }
|
|
3947
|
+
: { command: `open ${htmlPath}`, description: "view the full report in your browser" },
|
|
3948
|
+
{ command: `less ${markdownPath}`, description: "read it in the terminal" },
|
|
3949
|
+
machineWide
|
|
3950
|
+
// apply/improve need one exact project folder — a machine-wide
|
|
3951
|
+
// report must never point at a command that then refuses (the exact
|
|
3952
|
+
// trap this mode removes).
|
|
3953
|
+
? reportInput.dataMode === "sample"
|
|
3954
|
+
? { command: `cd <project> && ${actionRuntimeCommand("apply --sample")}`, description: "print the non-executable demo boundary from one exact project folder" }
|
|
3955
|
+
: { command: `cd <project> && ${actionRuntimeCommand(`apply --since-days ${sinceDays}`)}`, description: "per-project action plan from one exact project folder" }
|
|
3956
|
+
: reportableExperiment
|
|
3957
|
+
? { command: improveRuntimeCommand, description: `review canonical token test ${reportableExperiment.id}` }
|
|
3958
|
+
: qualitativeActionsSuppressed
|
|
3959
|
+
? { command: actionRuntimeCommand(`context --json --since-days ${sinceDays}`), description: "complete bounded qualitative evidence before any action" }
|
|
3960
|
+
: reportInput.dataMode === "sample"
|
|
3961
|
+
? { command: actionRuntimeCommand("apply --sample"), description: "print the non-executable demo boundary" }
|
|
3962
|
+
: { command: actionRuntimeCommand(`apply --since-days ${sinceDays}`), description: "print the paste-ready coding-agent prompt from this exact evidence window" }
|
|
3963
|
+
];
|
|
3964
|
+
return ok(generateCommandSummary({
|
|
3965
|
+
title: "aibill report",
|
|
3966
|
+
note: "a shareable Markdown + HTML report, written locally",
|
|
3967
|
+
rows,
|
|
3968
|
+
nextSteps,
|
|
3969
|
+
color: args.noColor ? false : undefined,
|
|
3970
|
+
width: terminalOutputWidth()
|
|
3971
|
+
}));
|
|
3886
3972
|
}
|
|
3887
3973
|
catch (error) {
|
|
3888
3974
|
return {
|
|
@@ -3907,19 +3993,32 @@ async function resolveReceiptPath(rootPath, out) {
|
|
|
3907
3993
|
return extname(resolved) ? resolved : `${resolved}.svg`;
|
|
3908
3994
|
}
|
|
3909
3995
|
async function reportCardCommand(args) {
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3996
|
+
// 0.9.4: a broad root (home, /) runs MACHINE-WIDE — identical read-only
|
|
3997
|
+
// scanning to the bare receipt (loadInstantReadData below), SVG written to
|
|
3998
|
+
// the current directory. The card renders machine-wide content anyway, so
|
|
3999
|
+
// an exact-project requirement was incoherent here; only a bogus --path
|
|
4000
|
+
// still gets the friendly guard. 0.9.5: broad roots that cannot HOLD the
|
|
4001
|
+
// receipt (/, /etc, /Users, …) get the friendly guard voice up front
|
|
4002
|
+
// instead of dying at write time with a wrapped raw error.
|
|
4003
|
+
const machineWide = isBroadScanRoot(args.path);
|
|
4004
|
+
if (machineWide) {
|
|
4005
|
+
const broadGuard = guardUnwritableBroadRoot("report-card", args);
|
|
4006
|
+
if (broadGuard)
|
|
4007
|
+
return broadGuard;
|
|
4008
|
+
}
|
|
4009
|
+
else if (!args.sample) {
|
|
3914
4010
|
const rootGuard = await guardExactProjectRoot("report-card", args.path);
|
|
3915
4011
|
if (rootGuard)
|
|
3916
4012
|
return rootGuard;
|
|
3917
4013
|
}
|
|
3918
4014
|
try {
|
|
3919
|
-
//
|
|
3920
|
-
//
|
|
3921
|
-
//
|
|
3922
|
-
|
|
4015
|
+
// Sample mode reads no workspace data and machine-wide mode reads only
|
|
4016
|
+
// the agent transcript dirs — neither scans the current directory, so
|
|
4017
|
+
// both write the receipt from wherever the user stands. Output still
|
|
4018
|
+
// goes through the safe-write/symlink checks below.
|
|
4019
|
+
const rootPath = args.sample || machineWide
|
|
4020
|
+
? resolve(args.path)
|
|
4021
|
+
: await resolveSafeScanRoot(args.path);
|
|
3923
4022
|
const { records, mode, providerCoverage, warnings } = await loadInstantReadData(args);
|
|
3924
4023
|
if (records.length === 0) {
|
|
3925
4024
|
return noEvidenceResult("report-card", warnings, args.sinceDays ?? 30);
|
|
@@ -3936,28 +4035,36 @@ async function reportCardCommand(args) {
|
|
|
3936
4035
|
mode,
|
|
3937
4036
|
...(providerCoverage ? { providerCoverage } : {})
|
|
3938
4037
|
}));
|
|
3939
|
-
const
|
|
4038
|
+
const dataRow = mode === "demo"
|
|
3940
4039
|
? args.sample
|
|
3941
|
-
? "
|
|
3942
|
-
: "
|
|
4040
|
+
? "DEMO sample data — explicit illustrative mode; no local transcripts or persisted spend state were read"
|
|
4041
|
+
: "DEMO sample data — no supported local Claude Code/Codex evidence was found; use --sample to reproduce this demo explicitly"
|
|
3943
4042
|
: mode === "local-logs"
|
|
3944
|
-
? "
|
|
3945
|
-
: "
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
"",
|
|
3951
|
-
"
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
4043
|
+
? "local Claude Code/Codex logs priced at API-equivalent rates"
|
|
4044
|
+
: "connected local spend state with provider-reported cost kept separate from API-equivalent estimates";
|
|
4045
|
+
// 0.9.5 founder polish: same facts, receipt-language layout — header,
|
|
4046
|
+
// one label column, and the caption set off as its own block.
|
|
4047
|
+
return ok(generateCommandSummary({
|
|
4048
|
+
title: "aibill report-card",
|
|
4049
|
+
badge: "Your AI Receipt",
|
|
4050
|
+
note: "a shareable, redacted spend card (no client/project/user names)",
|
|
4051
|
+
rows: [
|
|
4052
|
+
{ label: "Receipt", value: outPath },
|
|
4053
|
+
{ label: "Data", value: dataRow },
|
|
4054
|
+
{ label: "Privacy", value: "rendered locally; only totals, generic candidate categories, and evidence labels are included" }
|
|
4055
|
+
],
|
|
4056
|
+
sections: [{
|
|
4057
|
+
heading: "Caption to share",
|
|
4058
|
+
body: [generateReportCardCaption({
|
|
4059
|
+
summary,
|
|
4060
|
+
records: headlineRecords,
|
|
4061
|
+
mode,
|
|
4062
|
+
...(providerCoverage ? { providerCoverage } : {})
|
|
4063
|
+
})]
|
|
4064
|
+
}],
|
|
4065
|
+
color: args.noColor ? false : undefined,
|
|
4066
|
+
width: terminalOutputWidth()
|
|
4067
|
+
}));
|
|
3961
4068
|
}
|
|
3962
4069
|
catch (error) {
|
|
3963
4070
|
return {
|
|
@@ -3974,6 +4081,74 @@ async function reportCardCommand(args) {
|
|
|
3974
4081
|
* exist (a location problem is not a breadth problem, but the fix is the
|
|
3975
4082
|
* same). Returns undefined when the root is an acceptable exact project.
|
|
3976
4083
|
*/
|
|
4084
|
+
/**
|
|
4085
|
+
* True when the requested root is a machine-wide location (home, filesystem
|
|
4086
|
+
* root, a system directory, or anything containing home). report/report-card
|
|
4087
|
+
* treat this as MACHINE-WIDE MODE — the same read-only, transcript-dir
|
|
4088
|
+
* scanning the bare receipt performs — instead of refusing (0.9.4 founder
|
|
4089
|
+
* fix: the receipt's own Next pointer led from home into a refusal that
|
|
4090
|
+
* read as "the commands don't work"). Genuinely project-scoped commands
|
|
4091
|
+
* (improve, apply, verify, watch, connect, reset, …) keep the guard.
|
|
4092
|
+
*/
|
|
4093
|
+
function isBroadScanRoot(requestedPath) {
|
|
4094
|
+
return broadScanRootKind(requestedPath) !== undefined;
|
|
4095
|
+
}
|
|
4096
|
+
/** Classifies WHICH broad-root category a machine-wide path falls in. */
|
|
4097
|
+
function broadScanRootKind(requestedPath) {
|
|
4098
|
+
const rootPath = resolve(requestedPath);
|
|
4099
|
+
const home = homedir();
|
|
4100
|
+
const guardHome = home && home.trim().length > 0
|
|
4101
|
+
? home
|
|
4102
|
+
: join(rootPath, "aibill-impossible-home-sentinel");
|
|
4103
|
+
const reason = unsafeScanRootReason(rootPath, guardHome);
|
|
4104
|
+
if (reason === undefined)
|
|
4105
|
+
return undefined;
|
|
4106
|
+
if (reason.includes("filesystem root"))
|
|
4107
|
+
return "filesystem-root";
|
|
4108
|
+
if (reason.includes("home directory is too broad"))
|
|
4109
|
+
return "home";
|
|
4110
|
+
if (reason.includes("contains your home directory"))
|
|
4111
|
+
return "contains-home";
|
|
4112
|
+
return "system-directory";
|
|
4113
|
+
}
|
|
4114
|
+
/**
|
|
4115
|
+
* 0.9.5: machine-wide report/report-card write their artifacts INTO the
|
|
4116
|
+
* requested root. That works from the home directory, but /, /etc, or a
|
|
4117
|
+
* folder that contains home cannot hold them — the run used to die at write
|
|
4118
|
+
* time with a wrapped raw error ("Couldn't build a report: EROFS…",
|
|
4119
|
+
* "Refusing to use /etc…"). Those roots get the friendly guard voice BEFORE
|
|
4120
|
+
* anything is scanned or written. Returns undefined when the root is home
|
|
4121
|
+
* itself (machine-wide proceeds) or when an explicit absolute --out points
|
|
4122
|
+
* the artifacts somewhere else entirely.
|
|
4123
|
+
*/
|
|
4124
|
+
function guardUnwritableBroadRoot(commandName, args) {
|
|
4125
|
+
const kind = broadScanRootKind(args.path);
|
|
4126
|
+
if (kind === undefined || kind === "home")
|
|
4127
|
+
return undefined;
|
|
4128
|
+
// An absolute --out lands outside the broad root; only rootPath-relative
|
|
4129
|
+
// artifacts make this location a write problem.
|
|
4130
|
+
if (args.out !== undefined && isAbsolute(args.out))
|
|
4131
|
+
return undefined;
|
|
4132
|
+
const artifactNoun = commandName === "report" ? "its report files" : "the receipt";
|
|
4133
|
+
const explanation = kind === "filesystem-root"
|
|
4134
|
+
? `You pointed it at the filesystem root, which can't hold ${artifactNoun}.`
|
|
4135
|
+
: kind === "contains-home"
|
|
4136
|
+
? `You pointed it at a folder that contains your home directory, which can't hold ${artifactNoun}.`
|
|
4137
|
+
: `You pointed it at a system directory, which can't hold ${artifactNoun}.`;
|
|
4138
|
+
return {
|
|
4139
|
+
exitCode: 1,
|
|
4140
|
+
stdout: "",
|
|
4141
|
+
stderr: [
|
|
4142
|
+
`aibill ${commandName} writes ${artifactNoun} into the folder it points at.`,
|
|
4143
|
+
explanation,
|
|
4144
|
+
"",
|
|
4145
|
+
"Run it from your home directory for a machine-wide view, or from one exact project folder",
|
|
4146
|
+
` e.g. cd ~ && ${actionRuntimeCommand(commandName)}`,
|
|
4147
|
+
"",
|
|
4148
|
+
"Nothing was read, created, or changed."
|
|
4149
|
+
].join("\n")
|
|
4150
|
+
};
|
|
4151
|
+
}
|
|
3977
4152
|
async function guardExactProjectRoot(commandName, requestedPath) {
|
|
3978
4153
|
const rootPath = resolve(requestedPath);
|
|
3979
4154
|
// An unset/empty $HOME (containers) must never make the current directory
|
|
@@ -5890,6 +6065,40 @@ function applyEvidenceAcquisitionLines(input) {
|
|
|
5890
6065
|
}
|
|
5891
6066
|
return lines;
|
|
5892
6067
|
}
|
|
6068
|
+
/**
|
|
6069
|
+
* Machine-wide report input (0.9.4): the bare receipt's own data path —
|
|
6070
|
+
* loadInstantReadData over the agent transcript dirs (read-only) — rendered
|
|
6071
|
+
* through the report package. No project state is read or created; plan
|
|
6072
|
+
* detection is home-scoped metadata, exactly as the receipt reads it.
|
|
6073
|
+
*/
|
|
6074
|
+
async function buildMachineWideReportInput(args, sinceDays) {
|
|
6075
|
+
const { records, mode, providerCoverage, warnings } = await loadInstantReadData(args);
|
|
6076
|
+
if (records.length === 0) {
|
|
6077
|
+
// Same honest empty-state voice the receipt/report-card use — an empty
|
|
6078
|
+
// report file would just look broken.
|
|
6079
|
+
return { kind: "no_evidence", warnings };
|
|
6080
|
+
}
|
|
6081
|
+
const detectedPlans = await detectLocalPlans({
|
|
6082
|
+
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
6083
|
+
codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
|
|
6084
|
+
}).catch(() => []);
|
|
6085
|
+
const headlineRecords = mode === "connected"
|
|
6086
|
+
? selectProviderFinancialHeadlineRecords(records)
|
|
6087
|
+
: records;
|
|
6088
|
+
return {
|
|
6089
|
+
kind: "input",
|
|
6090
|
+
input: {
|
|
6091
|
+
generatedAt: new Date().toISOString(),
|
|
6092
|
+
summary: analyzeSpend(headlineRecords),
|
|
6093
|
+
allRecords: records,
|
|
6094
|
+
dataMode: mode === "connected" ? "connected_provider" : "local_logs",
|
|
6095
|
+
evidenceWindowDays: sinceDays,
|
|
6096
|
+
detectedPlans,
|
|
6097
|
+
...(mode === "connected" ? { providerRecords: records } : {}),
|
|
6098
|
+
...(providerCoverage ? { providerCoverage } : {})
|
|
6099
|
+
}
|
|
6100
|
+
};
|
|
6101
|
+
}
|
|
5893
6102
|
async function buildExplicitSampleReportInput(rootPath) {
|
|
5894
6103
|
const records = await loadSampleUsageData();
|
|
5895
6104
|
return {
|
|
@@ -6319,6 +6528,10 @@ function parseArgs(argv) {
|
|
|
6319
6528
|
parsed.noColor = true;
|
|
6320
6529
|
continue;
|
|
6321
6530
|
}
|
|
6531
|
+
if (arg === "--no-open") {
|
|
6532
|
+
parsed.noOpen = true;
|
|
6533
|
+
continue;
|
|
6534
|
+
}
|
|
6322
6535
|
if (arg === "--json") {
|
|
6323
6536
|
parsed.json = true;
|
|
6324
6537
|
continue;
|
|
@@ -6933,7 +7146,8 @@ function helpText(telemetryDisclosure) {
|
|
|
6933
7146
|
" quickstart [--sample] [--since-days N] Plain-English local readout (default 30 days)",
|
|
6934
7147
|
" [--full] Render the complete audit; default is the compact receipt",
|
|
6935
7148
|
" [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: project for local logs; model otherwise",
|
|
6936
|
-
" report [--sample] [--out <name>] [--since-days N] Generate local Markdown and HTML reports
|
|
7149
|
+
" report [--sample] [--out <name>] [--since-days N] Generate local Markdown and HTML reports and open the HTML in your browser",
|
|
7150
|
+
" [--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)",
|
|
6937
7151
|
" report-card [--out f.svg] Write your AI Receipt — a redacted, shareable SVG + caption",
|
|
6938
7152
|
" glance [--project <name>] [--plan <id>] [--since-days N] Emit the local, machine-readable Glance snapshot JSON",
|
|
6939
7153
|
" context [--project <name>] [--since-days N] Show hook-aware Context Health in the terminal",
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
export declare function decideReportAutoOpen(input: {
|
|
11
|
+
htmlPath: string;
|
|
12
|
+
noOpenFlag: boolean;
|
|
13
|
+
env?: NodeJS.ProcessEnv;
|
|
14
|
+
platform?: NodeJS.Platform;
|
|
15
|
+
stdoutIsTty?: boolean;
|
|
16
|
+
/** Test seam for the linux xdg-open PATH probe. */
|
|
17
|
+
hasCommandImpl?: (command: string, env: NodeJS.ProcessEnv) => boolean;
|
|
18
|
+
}): ReportOpenDecision;
|
|
19
|
+
/**
|
|
20
|
+
* Fire-and-forget launch of an affirmative decision. Returns true when the
|
|
21
|
+
* opener was handed to the OS (the summary may then say "opened …");
|
|
22
|
+
* returns false — never throws — on any spawn failure.
|
|
23
|
+
*/
|
|
24
|
+
export declare function openReportInBrowser(decision: ReportOpenDecision, options?: {
|
|
25
|
+
spawnImpl?: typeof spawn;
|
|
26
|
+
}): boolean;
|
|
27
|
+
//# sourceMappingURL=reportOpener.d.ts.map
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
export function decideReportAutoOpen(input) {
|
|
54
|
+
const env = input.env ?? process.env;
|
|
55
|
+
const platform = input.platform ?? process.platform;
|
|
56
|
+
const stdoutIsTty = input.stdoutIsTty ?? Boolean(process.stdout.isTTY);
|
|
57
|
+
const hasCommand = input.hasCommandImpl ?? commandOnPath;
|
|
58
|
+
if (input.noOpenFlag)
|
|
59
|
+
return { open: false, reason: "no-open-flag" };
|
|
60
|
+
if (env.AI_SPEND_NO_OPEN)
|
|
61
|
+
return { open: false, reason: "env-switch" };
|
|
62
|
+
if (!stdoutIsTty)
|
|
63
|
+
return { open: false, reason: "not-a-tty" };
|
|
64
|
+
if (env.CI)
|
|
65
|
+
return { open: false, reason: "ci" };
|
|
66
|
+
if (env.SSH_CONNECTION || env.SSH_TTY)
|
|
67
|
+
return { open: false, reason: "ssh" };
|
|
68
|
+
// Defense (1): never shell-open a path carrying a cmd metacharacter or a
|
|
69
|
+
// quote — on any platform. This alone neutralizes the win32 vector.
|
|
70
|
+
if (UNSAFE_PATH_METACHARACTERS.test(input.htmlPath)) {
|
|
71
|
+
return { open: false, reason: "unsafe-path" };
|
|
72
|
+
}
|
|
73
|
+
if (platform === "darwin") {
|
|
74
|
+
return { open: true, command: "open", args: [input.htmlPath] };
|
|
75
|
+
}
|
|
76
|
+
if (platform === "win32") {
|
|
77
|
+
// Defense (2): rundll32 → url.dll,FileProtocolHandler opens the path
|
|
78
|
+
// with NO shell in the chain — cmd.exe never sees it, so its
|
|
79
|
+
// `& ^ % ( ) < > |` re-parsing (which shell:false does not prevent for
|
|
80
|
+
// `cmd /c start`) cannot fire even if defense (1) ever missed a char.
|
|
81
|
+
return {
|
|
82
|
+
open: true,
|
|
83
|
+
command: "rundll32",
|
|
84
|
+
args: ["url.dll,FileProtocolHandler", input.htmlPath]
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (platform === "linux" && hasCommand("xdg-open", env)) {
|
|
88
|
+
return { open: true, command: "xdg-open", args: [input.htmlPath] };
|
|
89
|
+
}
|
|
90
|
+
return { open: false, reason: "no-opener" };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Fire-and-forget launch of an affirmative decision. Returns true when the
|
|
94
|
+
* opener was handed to the OS (the summary may then say "opened …");
|
|
95
|
+
* returns false — never throws — on any spawn failure.
|
|
96
|
+
*/
|
|
97
|
+
export function openReportInBrowser(decision, options = {}) {
|
|
98
|
+
if (!decision.open)
|
|
99
|
+
return false;
|
|
100
|
+
try {
|
|
101
|
+
const spawnImpl = options.spawnImpl ?? spawn;
|
|
102
|
+
const child = spawnImpl(decision.command, decision.args, {
|
|
103
|
+
detached: true,
|
|
104
|
+
stdio: "ignore"
|
|
105
|
+
});
|
|
106
|
+
// Async spawn errors (a vanished opener) surface on the child, not the
|
|
107
|
+
// call — swallow them so they can never crash the exiting CLI.
|
|
108
|
+
child.on?.("error", () => { });
|
|
109
|
+
child.unref();
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Synchronous PATH probe (linux xdg-open) — cheap, no child process. */
|
|
117
|
+
function commandOnPath(command, env) {
|
|
118
|
+
const pathValue = env.PATH ?? "";
|
|
119
|
+
for (const dir of pathValue.split(delimiter)) {
|
|
120
|
+
if (!dir)
|
|
121
|
+
continue;
|
|
122
|
+
try {
|
|
123
|
+
accessSync(join(dir, command), constants.X_OK);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// keep looking
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
//# 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.5",
|
|
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.5",
|
|
59
|
+
"@agent-finops/report": "0.9.5",
|
|
60
60
|
"yocto-spinner": "^1.2.0"
|
|
61
61
|
}
|
|
62
62
|
}
|