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 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,
@@ -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
- // Every printed command must run as printed: the demo workspace does
622
- // not exist yet, so the command creates it first (shipped-audit fix).
623
- ? "mkdir -p ./demo-workspace && npx aibill report --sample --path ./demo-workspace write a clearly labeled demo report in an explicitly narrow workspace"
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
- : `No supported AI usage evidence was found in the last ${sinceDays} days.`;
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
- // NEW-B3 + adversary F2: guard UNCONDITIONALLY. Even --sample writes
3762
- // project state into the root (resolveSafeStateDirectory below), so a
3763
- // broad root must refuse with the friendly guidance the sample gate
3764
- // alone still leaked the raw "Refusing to scan" from home. report-card
3765
- // --sample differs: it writes only the SVG artifact and keeps its
3766
- // deliberate home exemption.
3767
- const rootGuard = await guardExactProjectRoot("report", args.path);
3768
- if (rootGuard)
3769
- return rootGuard;
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
- const stateDir = await resolveSafeStateDirectory(rootPath, { create: true });
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
- const reportInput = args.sample
3779
- ? await buildExplicitSampleReportInput(rootPath)
3780
- : await buildReportInput(stateDir, rootPath, sinceDays);
3781
- const persistedPreferredExperiment = args.sample
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
- const outBase = args.out ? resolve(rootPath, args.out) : join(stateDir, "report");
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
- const artifactPaths = reportableExperiment
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
- return ok([
3837
- "aibill report",
3838
- `path: ${rootPath}`,
3839
- `markdown: ${markdownPath}`,
3840
- `html: ${htmlPath}`,
3841
- ...(reportableExperiment
3842
- ? [
3843
- `action artifacts: preserved · canonical token test ${reportableExperiment.id} (${reportableExperiment.lifecycle})`,
3844
- `token result: status=${reportableExperiment.evaluation.status}; reductionPercent=${reportExperimentProjection.reductionPercent ?? "unavailable"}; metricEvidence=${reportExperimentProjection.evidenceLabel}; quality=${reportExperimentProjection.qualityLabel}; qualityEvidence=${reportExperimentProjection.qualityEvidence}; matchingEvidence=${reportableExperiment.evaluation.matchingEvidence}`,
3845
- `token test: ${improveRuntimeCommand}`
3846
- ]
3847
- : qualitativeActionsSuppressed
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
- `action artifacts: suppressed · qualitative index ${reportInput.qualitativeCoverage?.status ?? "unknown"}`,
3850
- `coverage artifact: ${artifactPaths.codingPrompt}`,
3851
- `coverage action plan: ${artifactPaths.actionPlan}`,
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
- : artifactPaths
3908
+ : qualitativeActionsSuppressed
3857
3909
  ? [
3858
- `apply artifact: ${artifactPaths.codingPrompt}`,
3859
- `action plan: ${artifactPaths.actionPlan}`,
3860
- `policy/config draft: ${artifactPaths.policyConfigDraft}`,
3861
- `verification plan: ${artifactPaths.verificationPlan}`,
3862
- `demo 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 }
3863
3916
  ]
3864
- : []),
3865
- reportInput.dataMode === "sample"
3866
- ? `DEMO SAMPLE · illustrative cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)} · not user data`
3867
- : reportInput.dataMode === "connected_provider" &&
3868
- !(reportInput.allRecords ?? reportInput.providerRecords ?? []).some((record) => typeof record.amountUsd === "number")
3869
- ? "cost/value evidence total: Unavailable · no priced financial evidence; missing/null is not zero"
3870
- : `cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)}`,
3871
- runtime.telemetryDisclosure === true
3872
- ? `privacy: report rendered locally · ${telemetryDisclosureLine}; only explicit sync-provider contacts the selected provider`
3873
- : "privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
3874
- "",
3875
- "next:",
3876
- ` open ${htmlPath} view the full report in your browser`,
3877
- ` less ${markdownPath} read it in the terminal`,
3878
- reportableExperiment
3879
- ? ` ${improveRuntimeCommand} review canonical token test ${reportableExperiment.id}`
3880
- : qualitativeActionsSuppressed
3881
- ? ` ${actionRuntimeCommand(`context --json --since-days ${sinceDays}`)} complete bounded qualitative evidence before any action`
3882
- : reportInput.dataMode === "sample"
3883
- ? ` ${actionRuntimeCommand("apply --sample")} print the non-executable demo boundary`
3884
- : ` ${actionRuntimeCommand(`apply --since-days ${sinceDays}`)} print the paste-ready coding-agent prompt from this exact evidence window`
3885
- ].join("\n"));
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
- if (!args.sample) {
3911
- // NEW-B3 + founder repro (`npx aibill report-card` from home): the raw
3912
- // scan refusal used to surface wrapped in "Couldn't write the report
3913
- // card:". The friendly guard renders clean, BEFORE the try/wrapper.
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
- // Explicit sample mode reads no workspace data, so a broad-root scan guard
3920
- // would reject a harmless receipt written from the user's home directory.
3921
- // Output still goes through the safe-write/symlink checks below.
3922
- const rootPath = args.sample ? resolve(args.path) : await resolveSafeScanRoot(args.path);
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 dataLine = mode === "demo"
4038
+ const dataRow = mode === "demo"
3940
4039
  ? args.sample
3941
- ? "data: DEMO sample data — explicit illustrative mode; no local transcripts or persisted spend state were read."
3942
- : "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"
3943
4042
  : mode === "local-logs"
3944
- ? "data: local Claude Code/Codex logs priced at API-equivalent rates."
3945
- : "data: connected local spend state with provider-reported cost kept separate from API-equivalent estimates.";
3946
- return ok([
3947
- "Your AI Receipt a shareable, redacted spend card (no client/project/user names).",
3948
- `receipt: ${outPath}`,
3949
- dataLine,
3950
- "",
3951
- "Caption to share:",
3952
- generateReportCardCaption({
3953
- summary,
3954
- records: headlineRecords,
3955
- mode,
3956
- ...(providerCoverage ? { providerCoverage } : {})
3957
- }),
3958
- "",
3959
- "privacy: rendered locally; only totals, generic candidate categories, and evidence labels are included."
3960
- ].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
+ }));
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 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)",
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",
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.3",
59
- "@agent-finops/report": "0.9.3",
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
  }