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 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 { generateActionPlanMarkdown, generateApplyArtifactMarkdown, generateDemoPackageMarkdown, generateHtmlReport, generateMarkdownReport, generatePlainEnglishSummary, generatePolicyConfigDraftMarkdown, generateReportCardCaption, generateReportCardSvg, generateVerificationPlanMarkdown, groupByDimensions } from "@agent-finops/report";
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 (!machineWide) {
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
- return ok([
3868
- "aibill report",
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
- ? `scope: machine-wide · all supported local agent evidence on this machine (last ${sinceDays} days) · artifacts in ${rootPath}`
3871
- : `path: ${rootPath}`,
3872
- `markdown: ${markdownPath}`,
3873
- `html: ${htmlPath}`,
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
- `action artifacts: preserved · canonical token test ${reportableExperiment.id} (${reportableExperiment.lifecycle})`,
3879
- `token result: status=${reportableExperiment.evaluation.status}; reductionPercent=${reportExperimentProjection.reductionPercent ?? "unavailable"}; metricEvidence=${reportExperimentProjection.evidenceLabel}; quality=${reportExperimentProjection.qualityLabel}; qualityEvidence=${reportExperimentProjection.qualityEvidence}; matchingEvidence=${reportableExperiment.evaluation.matchingEvidence}`,
3880
- `token test: ${improveRuntimeCommand}`
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
- `action artifacts: suppressed · qualitative index ${reportInput.qualitativeCoverage?.status ?? "unknown"}`,
3885
- `coverage artifact: ${artifactPaths.codingPrompt}`,
3886
- `coverage action plan: ${artifactPaths.actionPlan}`,
3887
- `coverage policy/config: ${artifactPaths.policyConfigDraft}`,
3888
- `coverage verification: ${artifactPaths.verificationPlan}`,
3889
- `coverage package: ${artifactPaths.demoPackage}`
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
- `apply artifact: ${artifactPaths.codingPrompt}`,
3894
- `action plan: ${artifactPaths.actionPlan}`,
3895
- `policy/config draft: ${artifactPaths.policyConfigDraft}`,
3896
- `verification plan: ${artifactPaths.verificationPlan}`,
3897
- `demo package: ${artifactPaths.demoPackage}`
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
- reportInput.dataMode === "sample"
3901
- ? `DEMO SAMPLE · illustrative cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)} · not user data`
3902
- : reportInput.dataMode === "connected_provider" &&
3903
- !(reportInput.allRecords ?? reportInput.providerRecords ?? []).some((record) => typeof record.amountUsd === "number")
3904
- ? "cost/value evidence total: Unavailable · no priced financial evidence; missing/null is not zero"
3905
- : `cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)}`,
3906
- runtime.telemetryDisclosure === true
3907
- ? `privacy: report rendered locally · ${telemetryDisclosureLine}; only explicit sync-provider contacts the selected provider`
3908
- : "privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
3909
- "",
3910
- "next:",
3911
- ` open ${htmlPath} view the full report in your browser`,
3912
- ` less ${markdownPath} read it in the terminal`,
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
- ? ` cd <project> && ${actionRuntimeCommand("apply --sample")} print the non-executable demo boundary from one exact project folder`
3919
- : ` cd <project> && ${actionRuntimeCommand(`apply --since-days ${sinceDays}`)} per-project action plan from one exact project folder`
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
- ? ` ${improveRuntimeCommand} review canonical token test ${reportableExperiment.id}`
3957
+ ? { command: improveRuntimeCommand, description: `review canonical token test ${reportableExperiment.id}` }
3922
3958
  : qualitativeActionsSuppressed
3923
- ? ` ${actionRuntimeCommand(`context --json --since-days ${sinceDays}`)} complete bounded qualitative evidence before any action`
3959
+ ? { command: actionRuntimeCommand(`context --json --since-days ${sinceDays}`), description: "complete bounded qualitative evidence before any action" }
3924
3960
  : reportInput.dataMode === "sample"
3925
- ? ` ${actionRuntimeCommand("apply --sample")} print the non-executable demo boundary`
3926
- : ` ${actionRuntimeCommand(`apply --since-days ${sinceDays}`)} print the paste-ready coding-agent prompt from this exact evidence window`
3927
- ].join("\n"));
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 (!args.sample && !machineWide) {
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 dataLine = mode === "demo"
4038
+ const dataRow = mode === "demo"
3988
4039
  ? args.sample
3989
- ? "data: DEMO sample data — explicit illustrative mode; no local transcripts or persisted spend state were read."
3990
- : "data: DEMO sample data — no supported local Claude Code/Codex evidence was found; use --sample to reproduce this demo explicitly."
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
- ? "data: local Claude Code/Codex logs priced at API-equivalent rates."
3993
- : "data: connected local spend state with provider-reported cost kept separate from API-equivalent estimates.";
3994
- return ok([
3995
- "Your AI Receipt a shareable, redacted spend card (no client/project/user names).",
3996
- `receipt: ${outPath}`,
3997
- dataLine,
3998
- "",
3999
- "Caption to share:",
4000
- generateReportCardCaption({
4001
- summary,
4002
- records: headlineRecords,
4003
- mode,
4004
- ...(providerCoverage ? { providerCoverage } : {})
4005
- }),
4006
- "",
4007
- "privacy: rendered locally; only totals, generic candidate categories, and evidence labels are included."
4008
- ].join("\n"));
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
- return unsafeScanRootReason(rootPath, guardHome) !== undefined;
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 from the same window",
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.4",
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.4",
59
- "@agent-finops/report": "0.9.4",
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
  }