ai-spend-agent 0.9.4 → 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 +183 -68
- 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,
|
|
@@ -3769,9 +3775,16 @@ async function reportCommand(args, runtime = {}) {
|
|
|
3769
3775
|
// exact-project requirement was incoherent here: the receipt's own Next
|
|
3770
3776
|
// pointer led from home straight into a refusal. Only a bogus --path
|
|
3771
3777
|
// still gets the friendly guard; project folders behave exactly as
|
|
3772
|
-
// before.
|
|
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.
|
|
3773
3781
|
const machineWide = isBroadScanRoot(args.path);
|
|
3774
|
-
if (
|
|
3782
|
+
if (machineWide) {
|
|
3783
|
+
const broadGuard = guardUnwritableBroadRoot("report", args);
|
|
3784
|
+
if (broadGuard)
|
|
3785
|
+
return broadGuard;
|
|
3786
|
+
}
|
|
3787
|
+
else {
|
|
3775
3788
|
const rootGuard = await guardExactProjectRoot("report", args.path);
|
|
3776
3789
|
if (rootGuard)
|
|
3777
3790
|
return rootGuard;
|
|
@@ -3864,67 +3877,98 @@ async function reportCommand(args, runtime = {}) {
|
|
|
3864
3877
|
const artifactPaths = reportableExperiment || machineWide
|
|
3865
3878
|
? undefined
|
|
3866
3879
|
: await writeApplyArtifacts(stateDir, reportInput);
|
|
3867
|
-
|
|
3868
|
-
|
|
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 = [
|
|
3869
3895
|
machineWide
|
|
3870
|
-
?
|
|
3871
|
-
:
|
|
3872
|
-
|
|
3873
|
-
|
|
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 },
|
|
3874
3900
|
...(machineWide
|
|
3875
3901
|
? []
|
|
3876
3902
|
: reportableExperiment
|
|
3877
3903
|
? [
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
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 }
|
|
3881
3907
|
]
|
|
3882
3908
|
: qualitativeActionsSuppressed
|
|
3883
3909
|
? [
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
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 }
|
|
3890
3916
|
]
|
|
3891
3917
|
: artifactPaths
|
|
3892
3918
|
? [
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
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 }
|
|
3898
3924
|
]
|
|
3899
3925
|
: []),
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
: reportInput.dataMode === "
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
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" },
|
|
3913
3949
|
machineWide
|
|
3914
3950
|
// apply/improve need one exact project folder — a machine-wide
|
|
3915
3951
|
// report must never point at a command that then refuses (the exact
|
|
3916
3952
|
// trap this mode removes).
|
|
3917
3953
|
? reportInput.dataMode === "sample"
|
|
3918
|
-
? `
|
|
3919
|
-
: `
|
|
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" }
|
|
3920
3956
|
: reportableExperiment
|
|
3921
|
-
?
|
|
3957
|
+
? { command: improveRuntimeCommand, description: `review canonical token test ${reportableExperiment.id}` }
|
|
3922
3958
|
: qualitativeActionsSuppressed
|
|
3923
|
-
?
|
|
3959
|
+
? { command: actionRuntimeCommand(`context --json --since-days ${sinceDays}`), description: "complete bounded qualitative evidence before any action" }
|
|
3924
3960
|
: reportInput.dataMode === "sample"
|
|
3925
|
-
?
|
|
3926
|
-
:
|
|
3927
|
-
]
|
|
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
|
+
}));
|
|
3928
3972
|
}
|
|
3929
3973
|
catch (error) {
|
|
3930
3974
|
return {
|
|
@@ -3953,9 +3997,16 @@ async function reportCardCommand(args) {
|
|
|
3953
3997
|
// scanning to the bare receipt (loadInstantReadData below), SVG written to
|
|
3954
3998
|
// the current directory. The card renders machine-wide content anyway, so
|
|
3955
3999
|
// an exact-project requirement was incoherent here; only a bogus --path
|
|
3956
|
-
// still gets the friendly guard.
|
|
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.
|
|
3957
4003
|
const machineWide = isBroadScanRoot(args.path);
|
|
3958
|
-
if (
|
|
4004
|
+
if (machineWide) {
|
|
4005
|
+
const broadGuard = guardUnwritableBroadRoot("report-card", args);
|
|
4006
|
+
if (broadGuard)
|
|
4007
|
+
return broadGuard;
|
|
4008
|
+
}
|
|
4009
|
+
else if (!args.sample) {
|
|
3959
4010
|
const rootGuard = await guardExactProjectRoot("report-card", args.path);
|
|
3960
4011
|
if (rootGuard)
|
|
3961
4012
|
return rootGuard;
|
|
@@ -3984,28 +4035,36 @@ async function reportCardCommand(args) {
|
|
|
3984
4035
|
mode,
|
|
3985
4036
|
...(providerCoverage ? { providerCoverage } : {})
|
|
3986
4037
|
}));
|
|
3987
|
-
const
|
|
4038
|
+
const dataRow = mode === "demo"
|
|
3988
4039
|
? args.sample
|
|
3989
|
-
? "
|
|
3990
|
-
: "
|
|
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"
|
|
3991
4042
|
: mode === "local-logs"
|
|
3992
|
-
? "
|
|
3993
|
-
: "
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
"",
|
|
3999
|
-
"
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
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
|
+
}));
|
|
4009
4068
|
}
|
|
4010
4069
|
catch (error) {
|
|
4011
4070
|
return {
|
|
@@ -4032,12 +4091,63 @@ async function reportCardCommand(args) {
|
|
|
4032
4091
|
* (improve, apply, verify, watch, connect, reset, …) keep the guard.
|
|
4033
4092
|
*/
|
|
4034
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) {
|
|
4035
4098
|
const rootPath = resolve(requestedPath);
|
|
4036
4099
|
const home = homedir();
|
|
4037
4100
|
const guardHome = home && home.trim().length > 0
|
|
4038
4101
|
? home
|
|
4039
4102
|
: join(rootPath, "aibill-impossible-home-sentinel");
|
|
4040
|
-
|
|
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
|
+
};
|
|
4041
4151
|
}
|
|
4042
4152
|
async function guardExactProjectRoot(commandName, requestedPath) {
|
|
4043
4153
|
const rootPath = resolve(requestedPath);
|
|
@@ -6418,6 +6528,10 @@ function parseArgs(argv) {
|
|
|
6418
6528
|
parsed.noColor = true;
|
|
6419
6529
|
continue;
|
|
6420
6530
|
}
|
|
6531
|
+
if (arg === "--no-open") {
|
|
6532
|
+
parsed.noOpen = true;
|
|
6533
|
+
continue;
|
|
6534
|
+
}
|
|
6421
6535
|
if (arg === "--json") {
|
|
6422
6536
|
parsed.json = true;
|
|
6423
6537
|
continue;
|
|
@@ -7032,7 +7146,8 @@ function helpText(telemetryDisclosure) {
|
|
|
7032
7146
|
" quickstart [--sample] [--since-days N] Plain-English local readout (default 30 days)",
|
|
7033
7147
|
" [--full] Render the complete audit; default is the compact receipt",
|
|
7034
7148
|
" [--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
|
|
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)",
|
|
7036
7151
|
" report-card [--out f.svg] Write your AI Receipt — a redacted, shareable SVG + caption",
|
|
7037
7152
|
" glance [--project <name>] [--plan <id>] [--since-days N] Emit the local, machine-readable Glance snapshot JSON",
|
|
7038
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
|
}
|