@go-labs-sg/bb 1.21.0 → 2.0.0

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.
@@ -1,7 +1,18 @@
1
- import { createTRPCProxyClient, httpBatchLink, loggerLink } from "@trpc/client";
1
+ import { createTRPCProxyClient, httpBatchLink, loggerLink, } from "@trpc/client";
2
2
  import SuperJSON from "superjson";
3
3
  import { sanitizeValueForTrace, shouldLogCliActions } from "./cli-trace.js";
4
- export const BUDGET_BUILDER_API_BASE_URL = "https://budget-builder.getout.events";
4
+ export const DEFAULT_BUDGET_BUILDER_API_BASE_URL = "https://budget-builder.getout.events";
5
+ const normalizeApiBaseUrl = (value) => {
6
+ const url = new URL(value);
7
+ const isLocalHttp = url.protocol === "http:" &&
8
+ (url.hostname === "localhost" || url.hostname === "127.0.0.1");
9
+ if (url.protocol !== "https:" && !isLocalHttp) {
10
+ throw new Error("Budget Builder API URL must use HTTPS, except for localhost development.");
11
+ }
12
+ url.pathname = url.pathname.replace(/\/$/, "");
13
+ return url.toString().replace(/\/$/, "");
14
+ };
15
+ export let BUDGET_BUILDER_API_BASE_URL = DEFAULT_BUDGET_BUILDER_API_BASE_URL;
5
16
  function getAuthHeader() {
6
17
  const key = process.env.BB_API_KEY;
7
18
  if (!key?.trim())
@@ -17,7 +28,7 @@ const containsRawApiKey = (value, seen = new WeakSet()) => {
17
28
  return Object.entries(value).some(([key, nestedValue]) => (key.toLowerCase() === "apikey" && typeof nestedValue === "string") ||
18
29
  containsRawApiKey(nestedValue, seen));
19
30
  };
20
- export const api = createTRPCProxyClient({
31
+ export const createBudgetBuilderClient = (apiBaseUrl = BUDGET_BUILDER_API_BASE_URL) => createTRPCProxyClient({
21
32
  links: [
22
33
  loggerLink({
23
34
  enabled: (opts) => shouldLogCliActions() &&
@@ -31,7 +42,7 @@ export const api = createTRPCProxyClient({
31
42
  },
32
43
  }),
33
44
  httpBatchLink({
34
- url: `${BUDGET_BUILDER_API_BASE_URL}/api/trpc`,
45
+ url: `${apiBaseUrl}/api/trpc`,
35
46
  transformer: SuperJSON,
36
47
  headers: () => {
37
48
  const headers = {
@@ -45,13 +56,38 @@ export const api = createTRPCProxyClient({
45
56
  }),
46
57
  ],
47
58
  });
59
+ let activeClient = createBudgetBuilderClient();
60
+ export const configureBudgetBuilderApiBaseUrl = (value) => {
61
+ const normalized = normalizeApiBaseUrl(value);
62
+ BUDGET_BUILDER_API_BASE_URL = normalized;
63
+ activeClient = createBudgetBuilderClient(normalized);
64
+ return normalized;
65
+ };
66
+ /**
67
+ * Stable facade used by command handlers. Tests can replace the transport without
68
+ * teaching commands about tRPC or process-wide authentication.
69
+ */
70
+ export const api = new Proxy({}, {
71
+ get: (_target, property, receiver) => Reflect.get(activeClient, property, receiver),
72
+ });
73
+ export const setBudgetBuilderClientForTesting = (client) => {
74
+ const previous = activeClient;
75
+ activeClient = client;
76
+ return () => {
77
+ activeClient = previous;
78
+ };
79
+ };
80
+ export class MissingApiKeyError extends Error {
81
+ code = "AUTHENTICATION";
82
+ constructor() {
83
+ super("BB_API_KEY is required. Set it in the environment.");
84
+ this.name = "MissingApiKeyError";
85
+ }
86
+ }
48
87
  export function requireApiKey() {
49
88
  const key = process.env.BB_API_KEY?.trim();
50
89
  if (!key) {
51
- console.error(JSON.stringify({
52
- error: "BB_API_KEY is required. Set it in the environment.",
53
- }));
54
- process.exit(1);
90
+ throw new MissingApiKeyError();
55
91
  }
56
92
  return key;
57
93
  }
package/dist/commands.js CHANGED
@@ -9,6 +9,7 @@ import { billStatusesForApi, } from "./parse-cli-enums.js";
9
9
  import { parseBudgetDiscountPayload, parseCompanyUpdatePayload, parseContactCreatePayload, parseContactUpdatePayload, parseCreateBillPayload, parseCreateBudgetPayload, parseCreateCustomerInvoicePayload, parseCreateQuotationPayload, parseItemCreatePayload, parseItemUpdatePayload, parseSendCustomerInvoiceToContactPersonPayload, parseSendEstimateToContactPersonPayload, parseSupplierCreatePayload, parseSupplierUpdatePayload, parseUpdateBillPayload, parseUpdateBillPaymentEvidencePayload, parseUpdateBudgetCommissionPayload, parseUpdateBudgetPayload, parseUpdateProjectPayload, parseUpdateQuotationPayload, parseValidateBillSelectionPayload, } from "./parse-mutation-payload.js";
10
10
  import { BillStatus, BudgetStatus, ProjectStatus } from "./prisma-enums.js";
11
11
  import { createRichTextFromPlainText } from "./rich-text.js";
12
+ import { confirmCurrentCommand, emitCommandResult, getActiveCliSession, } from "./runtime/index.js";
12
13
  const BUDGET = "BUDGET";
13
14
  const BILL = "BILL";
14
15
  const SUPPLIER = "SUPPLIER";
@@ -52,7 +53,7 @@ function isPendingRequest(n) {
52
53
  return n.notificationType === "request" && n.status === "PENDING_APPROVAL";
53
54
  }
54
55
  const out = (data) => {
55
- console.log(JSON.stringify(data, null, 2));
56
+ emitCommandResult(data);
56
57
  };
57
58
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
58
59
  const stringField = (record, key) => {
@@ -311,6 +312,14 @@ export const buildSensitiveWorkflowPreview = ({ action, entity, details, }) => {
311
312
  ].join("\n");
312
313
  };
313
314
  const assertSensitiveWorkflowConfirmed = async (input) => {
315
+ if (getActiveCliSession()?.invocation.legacy !== true) {
316
+ await confirmCurrentCommand({
317
+ action: input.action,
318
+ target: input.entity,
319
+ details: input.details,
320
+ });
321
+ return;
322
+ }
314
323
  const expected = "CONFIRM";
315
324
  const preview = buildSensitiveWorkflowPreview(input);
316
325
  if (!process.stdin.isTTY || !process.stderr.isTTY) {
@@ -335,6 +344,15 @@ const assertLockedBudgetChangeConfirmed = async ({ budget, action, }) => {
335
344
  return;
336
345
  }
337
346
  const statusLabel = lockedBudgetStatusLabel(budget.status);
347
+ if (getActiveCliSession()?.invocation.legacy !== true) {
348
+ await confirmCurrentCommand({
349
+ action,
350
+ target: `budget ${budget.id}`,
351
+ details: `Changes locked budget "${budget.name}" in status ${statusLabel}.`,
352
+ effects: ["state-change"],
353
+ });
354
+ return;
355
+ }
338
356
  if (!process.stdin.isTTY || !process.stderr.isTTY) {
339
357
  throw new Error(`${action} would change locked budget "${budget.name}" (${budget.id}) with status ${statusLabel}. Run this command in an interactive terminal and type "yes" to confirm.`);
340
358
  }
@@ -1407,7 +1425,7 @@ export const syncCustomerInvoice = async (invoiceId) => {
1407
1425
  await assertSensitiveWorkflowConfirmed({
1408
1426
  action: "Sync customer invoice from QuickBooks",
1409
1427
  entity: `invoice ${invoiceId}`,
1410
- details: "refreshes local status, QuickBooks metadata, balance, and history; a QBO-voided invoice becomes VOIDED locally and may reopen its estimate",
1428
+ details: "refreshes local status, QuickBooks metadata, balance, and history; QBO Paid or a zero balance becomes PAID, a reversed payment restores the delivery or approval status, and a QBO void becomes VOIDED",
1411
1429
  });
1412
1430
  const result = await api.customerInvoice.syncInvoiceStatus.mutate({
1413
1431
  invoiceId,
package/dist/index.js CHANGED
@@ -1,12 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import "./load-env.js";
3
- import { readFileSync } from "node:fs";
4
- import { BUDGET_BUILDER_API_BASE_URL, requireApiKey } from "./api-client.js";
3
+ import { readFileSync, realpathSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { BUDGET_BUILDER_API_BASE_URL, configureBudgetBuilderApiBaseUrl, DEFAULT_BUDGET_BUILDER_API_BASE_URL, requireApiKey, } from "./api-client.js";
5
7
  import { consumeCliQuietFlags, logCliAction, sanitizeFlagsForTrace, setCliQuiet, shouldLogCliActions, } from "./cli-trace.js";
6
8
  import { addBudgetItems, approveBill, approveBudget, approveCustomerInvoice, approveQuotation, approveSupplier, checkCustomerInvoiceReadiness, checkProjectReconciliation, cleanupStagedBillAttachments, cleanupStagedQuotationAttachments, completeProject, createApiKeyForUser, createBillApproval, createBillFromPayload, createBudgetApproval, createBudgetCategory, createBudgetFromPayload, createCompany, createContactPersonFromPayload, createCustomerInvoice, createEstimate, createItemCategory, createItemFromPayload, createPlaceholderBillForBudgetItem, createProject, createQuotationFromPayload, createSupplierCertification, createSupplierFromPayload, createSupplierPaymentMethod, createSupplierRoleOption, createSupplierTagOption, createUser, deleteBillById, deleteBudgetById, deleteBudgetCategory, deleteBudgetCommission, deleteBudgetDiscount, deleteCompanyById, deleteCustomerInvoice, deleteItemById, deleteItemCategoriesByIds, deleteProjectById, deleteQuotationById, deleteSuppliersByIds, discardCreatingCustomerInvoice, downloadCustomerInvoicePaymentProof, downloadCustomerInvoicePdf, downloadQuotationPdf, getApprovedBudgets, getBillAttachments, getBillDetails, getBudget, getBudgetCategories, getBudgetCategoryBenchmarks, getBudgetDetails, getBudgetItemsOnly, getBudgetVersions, getCompany, getCustomerInvoice, getCustomerInvoiceEmailContext, getDashboard, getErrorMetrics, getEstimatePerformance, getFinancialOverview, getItem, getItemPricingHistory, getMonthlyMetrics, getProject, getQuotationDetails, getRecentErrors, getSupplierAnalytics, getSupplierDetails, getSupplierPricingHistory, getSystemOverview, getUserPerformance, importQuickBooksProjectId, listApiKeysForUser, listApprovals, listBills, listBudgets, listCompanies, listContacts, listCustomerInvoices, listEligibleCustomerInvoiceBudgets, listIntegrationOperations, listItemCategories, listItems, listProjects, listQuotations, listSuppliers, listUsers, markBudgetWonWithProof, markCustomerInvoicePaidFromPath, patchBillInvoiceNumber, patchBillPayment, reactivateSuppliersByIds, reconcileProject, rejectBill, rejectBudget, rejectCustomerInvoice, rejectQuotation, rejectSupplier, removeBudgetItem, renameBudgetVersion, reorderBudgetItemsCli, restoreBudgetVersion, retryIntegrationOperation, revokeApiKeyForUser, sendCustomerInvoiceToContactPersonFromPayload, sendEstimateToContactPersonFromPayload, setBudgetItemsNotUtilized, stageBillAttachmentsFromPaths, submitQuotation, syncCustomerInvoice, updateBillFromPayload, updateBillPaymentEvidenceFromPayload, updateBillStatus, updateBudgetCategory, updateBudgetCommissionFromPayload, updateBudgetDiscountFromPayload, updateBudgetFromPayload, updateBudgetItem, updateBudgetItemSupplierCli, updateBudgetStatus, updateCompanyFromPayload, updateContactPersonFromPayload, updateItemCategory, updateItemFromPayload, updateProjectFromPayload, updateProjectStatus, updateQuotationFromPayload, updateSupplierFromPayload, uploadBillAttachmentFromPath, uploadBillAttachmentsFromPaths, uploadBillDocumentsFromPaths, uploadBudgetAttachmentFromPath, uploadQuotationAttachmentFromPath, validateBillSelectionFromPayload, voidCustomerInvoice, whoAmI, } from "./commands.js";
7
9
  import { getFlag, parseArgs } from "./parse-args.js";
8
10
  import { billStatusesForUpdateHelp, budgetStatusesForHelp, isBudgetStatusUpdate, parseApprovalTypeFlag, parseBillStatusForUpdate, parseCommaSeparatedBillStatuses, parseCommaSeparatedBudgetStatuses, parseCommaSeparatedCustomerInvoiceStatuses, parseCommaSeparatedIds, parseCommaSeparatedQuotationStatuses, parseCommaSeparatedSupplierStatuses, parseOptionalBillListSortBy, parseOptionalBillListSortDir, parseOptionalDashboardRole, parseOptionalDeals, parseOptionalErrorSeverity, parseOptionalErrorStatus, parseOptionalExtendedProjectStatus, parseOptionalFinancialRole, parseOptionalSupplierAnalyticsTimeFrame, parseOptionalTimeFrame, parseProjectStatusForUpdate, parseUserRole, projectStatusesForHelp, userRolesForHelp, } from "./parse-cli-enums.js";
9
11
  import { parseJsonFlag, parseOptionalNumber as parseOptNum, } from "./parse-json-flag.js";
12
+ import { createCompletionScript, createHumanHelp, resolveCommand, } from "./registry/index.js";
13
+ import { CliRuntimeError, clearActiveCliSession, confirmCurrentCommand, createProcessRuntime, emitCommandError, emitCommandResult, setActiveCliSession, } from "./runtime/index.js";
10
14
  function parsePositiveIntFlag(value, flagName) {
11
15
  if (value === undefined)
12
16
  return undefined;
@@ -139,7 +143,7 @@ function parseDashboardQueryFlags(flags) {
139
143
  endDate: getFlag(flags, "endDate"),
140
144
  };
141
145
  }
142
- function printHelp() {
146
+ function printLegacyHelp(runtime) {
143
147
  const help = `
144
148
  bb — Budget Builder CLI for AI agents (parity with MCP tools)
145
149
 
@@ -208,10 +212,10 @@ Bills
208
212
  list-bills [--projectId] [--budgetId] [--status CSV] [--search <text>] [--isClaimable true|false] [--createdByIds <csv>] [--sortBy createdAt|amount|status] [--sortDir asc|desc] [--page] [--pageSize]
209
213
  list-claims same flags as list-bills; only reimbursable claims (ignores --isClaimable)
210
214
  isClaimable differentiates the shared bill/claim records: false = bill, true = claim.
211
- New supplier bills from 1 Jul 2026 00:00 SGT require approved quotation coverage for each non-legacy line item.
215
+ Non-legacy supplier bills from 1 Jul 2026 00:00 SGT require approved quotation coverage for every selected line item, including already-paid bills.
212
216
  create-bill-approval <billId> (also sends approval request emails)
213
217
  create-bill --payload '<json>' (bill.create; payload.isClaimable false = bill, true = claim)
214
- validate-bill-selection --payload '<json>' Check projectId, supplierId, budgetItemIds, and optional alreadyPaid before creation.
218
+ validate-bill-selection --payload '<json>' Check projectId, supplierId, and budgetItemIds before creation; alreadyPaid never bypasses quotation checks.
215
219
  stage-bill-attachment <projectId> <filePath...> [--file <path>] [--files <csv>] Upload files before create-bill; returns attachment JSON for attachments/paymentProofAttachments
216
220
  cleanup-staged-bill-attachments <projectId> --keys <csv> Delete unattached staged bill/claim uploads.
217
221
  update-bill --payload '<json>' (bill.update; must include id)
@@ -261,7 +265,7 @@ Customer invoices
261
265
  Admin-only. Uploads proof, creates a QBO Payment for the live balance when needed, attaches proof in QBO when possible, and records payment history in BB.
262
266
  download-customer-invoice-payment-proof <invoiceId> [--output <path>]
263
267
  download-customer-invoice-pdf <invoiceId> [--output <path>]
264
- sync-customer-invoice <invoiceId> Refresh local status and balance from QuickBooks; QBO voids can reopen a closed estimate.
268
+ sync-customer-invoice <invoiceId> Refresh local status and balance from QuickBooks; paid invoices become PAID, reversals restore delivery/approval, and QBO voids can reopen an estimate.
265
269
 
266
270
  Approvals
267
271
  list-approvals | get-pending-approvals [--type budget|supplier|bill|quotation|customer_invoice|all]
@@ -361,7 +365,7 @@ Note: MCP also exposes a Prisma schema resource; the CLI has no equivalent.
361
365
 
362
366
  Not covered vs MCP get_budget: "get-budget" also fetches line items in one call.
363
367
  `.trim();
364
- console.log(help);
368
+ runtime.stdout.write(`${help}\n`);
365
369
  }
366
370
  const getCliPackageMetadata = () => {
367
371
  const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -370,30 +374,242 @@ const getCliPackageMetadata = () => {
370
374
  }
371
375
  return { name: packageJson.name, version: packageJson.version };
372
376
  };
373
- const printVersion = () => {
377
+ const getVersionData = () => {
374
378
  const metadata = getCliPackageMetadata();
375
- console.log(JSON.stringify({
379
+ return {
376
380
  ...metadata,
377
381
  apiBaseUrl: BUDGET_BUILDER_API_BASE_URL,
378
- }, null, 2));
382
+ };
379
383
  };
380
384
  /** MCP registers tools with snake_case; CLI commands are kebab-case. */
381
385
  function normalizeCliCommand(raw) {
382
386
  return raw.replace(/_/g, "-");
383
387
  }
384
- async function main() {
385
- const { command, positional, flags } = parseArgs(process.argv);
386
- setCliQuiet(consumeCliQuietFlags(flags));
387
- const cmd = normalizeCliCommand(command ?? "");
388
- if (cmd === "version" || flags.version === true) {
389
- printVersion();
390
- process.exit(0);
388
+ const booleanFlagValues = new Set(["true", "false", "1", "0"]);
389
+ const isEnabledFlag = (argv, flag) => {
390
+ for (let index = 0; index < argv.length; index++) {
391
+ const value = argv[index];
392
+ if (value === `${flag}=true` || value === `${flag}=1`)
393
+ return true;
394
+ if (value?.startsWith(`${flag}=`))
395
+ return false;
396
+ if (value !== flag)
397
+ continue;
398
+ const next = argv[index + 1];
399
+ if (next !== undefined && booleanFlagValues.has(next)) {
400
+ return next === "true" || next === "1";
401
+ }
402
+ return true;
391
403
  }
392
- if (!cmd || cmd === "help" || flags.help === true) {
393
- printHelp();
394
- process.exit(0);
404
+ return false;
405
+ };
406
+ const runtimeOnlyFlags = [
407
+ "--allow-state-change",
408
+ "--allow-email",
409
+ "--allow-external-write",
410
+ "--allow-delete",
411
+ "--allow-financial-write",
412
+ "--debug",
413
+ "--quiet",
414
+ "--api-url",
415
+ ];
416
+ const isRuntimeOnlyArgument = (value) => runtimeOnlyFlags.some((flag) => value === flag || value.startsWith(`${flag}=`)) || value === "-q";
417
+ const parseRootArguments = (argv) => {
418
+ const commandArgv = [];
419
+ let apiBaseUrl;
420
+ for (let index = 0; index < argv.length; index++) {
421
+ const value = argv[index];
422
+ if (value === undefined)
423
+ continue;
424
+ if (value.startsWith("--api-url=")) {
425
+ apiBaseUrl = value.slice("--api-url=".length);
426
+ continue;
427
+ }
428
+ if (value === "--api-url") {
429
+ const next = argv[index + 1];
430
+ if (next === undefined || next.startsWith("-")) {
431
+ throw new CliRuntimeError("USAGE", "--api-url requires a URL.");
432
+ }
433
+ apiBaseUrl = next;
434
+ index++;
435
+ continue;
436
+ }
437
+ if (isRuntimeOnlyArgument(value)) {
438
+ const next = argv[index + 1];
439
+ if (next !== undefined && booleanFlagValues.has(next))
440
+ index++;
441
+ continue;
442
+ }
443
+ commandArgv.push(value);
395
444
  }
396
- requireApiKey();
445
+ return { apiBaseUrl, commandArgv };
446
+ };
447
+ const effectPermissionsFromArgv = (argv) => ({
448
+ allowStateChange: isEnabledFlag(argv, "--allow-state-change"),
449
+ allowEmail: isEnabledFlag(argv, "--allow-email"),
450
+ allowExternalWrite: isEnabledFlag(argv, "--allow-external-write"),
451
+ allowDelete: isEnabledFlag(argv, "--allow-delete"),
452
+ allowFinancialWrite: isEnabledFlag(argv, "--allow-financial-write"),
453
+ });
454
+ const isCompletionShell = (value) => value === "bash" ||
455
+ value === "zsh" ||
456
+ value === "fish" ||
457
+ value === "powershell";
458
+ const legacyArgvForCommand = (argv, resolvedCommand) => {
459
+ const legacyTarget = resolvedCommand.command.legacyTarget;
460
+ if (legacyTarget === undefined) {
461
+ throw new CliRuntimeError("INTERNAL", `Command ${resolvedCommand.command.path.join(" ")} has no dispatcher target.`);
462
+ }
463
+ return [
464
+ argv[0] ?? "node",
465
+ argv[1] ?? "bb",
466
+ legacyTarget,
467
+ ...resolvedCommand.argv.filter((value) => !isRuntimeOnlyArgument(value)),
468
+ ];
469
+ };
470
+ const normalizeDispatcherError = (error) => {
471
+ if (!(error instanceof Error))
472
+ return error;
473
+ if (/\brequires\b|\bmust be\b|\bmust include\b|^Invalid\b|^Missing\b|^--/.test(error.message)) {
474
+ return new CliRuntimeError("VALIDATION", error.message);
475
+ }
476
+ return error;
477
+ };
478
+ const commandConfirmationTarget = (resolvedCommand) => {
479
+ const positionalTarget = resolvedCommand.argv.find((value) => value !== "--" && !value.startsWith("-"));
480
+ if (positionalTarget !== undefined &&
481
+ !positionalTarget.startsWith("{") &&
482
+ !positionalTarget.startsWith("[")) {
483
+ return positionalTarget;
484
+ }
485
+ return resolvedCommand.command.path.join(" ");
486
+ };
487
+ export async function runCli(argv = process.argv, runtime = createProcessRuntime()) {
488
+ const rawArgv = argv.slice(2);
489
+ const metadata = getCliPackageMetadata();
490
+ const quiet = isEnabledFlag(rawArgv, "--quiet") ||
491
+ rawArgv.includes("-q") ||
492
+ runtime.env.BB_CLI_QUIET === "1";
493
+ const debug = isEnabledFlag(rawArgv, "--debug");
494
+ let rootArguments;
495
+ try {
496
+ rootArguments = parseRootArguments(rawArgv);
497
+ const apiBaseUrl = rootArguments.apiBaseUrl ?? runtime.env.BB_API_URL?.trim();
498
+ configureBudgetBuilderApiBaseUrl(apiBaseUrl || DEFAULT_BUDGET_BUILDER_API_BASE_URL);
499
+ }
500
+ catch (error) {
501
+ setActiveCliSession({
502
+ runtime,
503
+ invocation: {
504
+ command: "startup",
505
+ cliVersion: metadata.version,
506
+ apiBaseUrl: BUDGET_BUILDER_API_BASE_URL,
507
+ quiet,
508
+ debug,
509
+ },
510
+ effects: [],
511
+ permissions: {},
512
+ });
513
+ const message = error instanceof Error ? error.message : String(error);
514
+ const exitCode = emitCommandError(error instanceof CliRuntimeError
515
+ ? error
516
+ : new CliRuntimeError("USAGE", `Invalid API URL: ${message}`));
517
+ clearActiveCliSession();
518
+ return exitCode;
519
+ }
520
+ const { commandArgv } = rootArguments;
521
+ if (commandArgv.length === 0 ||
522
+ commandArgv[0] === "help" ||
523
+ isEnabledFlag(commandArgv, "--help") ||
524
+ commandArgv.includes("-h")) {
525
+ if (commandArgv.includes("--legacy")) {
526
+ printLegacyHelp(runtime);
527
+ }
528
+ else {
529
+ runtime.stdout.write(`${createHumanHelp()}\n`);
530
+ }
531
+ return 0;
532
+ }
533
+ if (commandArgv[0] === "completion") {
534
+ const shell = commandArgv[1];
535
+ if (shell === undefined || !isCompletionShell(shell)) {
536
+ setActiveCliSession({
537
+ runtime,
538
+ invocation: {
539
+ command: "completion",
540
+ cliVersion: metadata.version,
541
+ apiBaseUrl: BUDGET_BUILDER_API_BASE_URL,
542
+ quiet,
543
+ debug,
544
+ },
545
+ effects: [],
546
+ permissions: {},
547
+ });
548
+ const exitCode = emitCommandError(new CliRuntimeError("USAGE", "completion requires bash, zsh, fish, or powershell."));
549
+ clearActiveCliSession();
550
+ return exitCode;
551
+ }
552
+ runtime.stdout.write(createCompletionScript(shell));
553
+ return 0;
554
+ }
555
+ if (commandArgv[0] === "version" ||
556
+ isEnabledFlag(commandArgv, "--version") ||
557
+ commandArgv.includes("-v")) {
558
+ setActiveCliSession({
559
+ runtime,
560
+ invocation: {
561
+ command: "version",
562
+ cliVersion: metadata.version,
563
+ apiBaseUrl: BUDGET_BUILDER_API_BASE_URL,
564
+ quiet,
565
+ debug,
566
+ },
567
+ effects: [],
568
+ permissions: {},
569
+ });
570
+ emitCommandResult(getVersionData());
571
+ clearActiveCliSession();
572
+ return 0;
573
+ }
574
+ const resolvedCommand = resolveCommand(commandArgv);
575
+ if (resolvedCommand === undefined) {
576
+ setActiveCliSession({
577
+ runtime,
578
+ invocation: {
579
+ command: commandArgv[0] ?? "unknown",
580
+ cliVersion: metadata.version,
581
+ apiBaseUrl: BUDGET_BUILDER_API_BASE_URL,
582
+ quiet,
583
+ debug,
584
+ },
585
+ effects: [],
586
+ permissions: {},
587
+ });
588
+ const exitCode = emitCommandError(new CliRuntimeError("USAGE", `Unknown command: ${commandArgv.join(" ")}. Run 'bb help'.`));
589
+ clearActiveCliSession();
590
+ return exitCode;
591
+ }
592
+ const effectiveArgv = legacyArgvForCommand(argv, resolvedCommand);
593
+ const { command, positional, flags } = parseArgs(effectiveArgv);
594
+ consumeCliQuietFlags(flags);
595
+ setCliQuiet(resolvedCommand.isLegacyAlias ? quiet : quiet || !debug);
596
+ setActiveCliSession({
597
+ runtime,
598
+ invocation: {
599
+ command: resolvedCommand.command.path.join(" "),
600
+ cliVersion: metadata.version,
601
+ apiBaseUrl: BUDGET_BUILDER_API_BASE_URL,
602
+ legacy: resolvedCommand.isLegacyAlias,
603
+ quiet,
604
+ debug,
605
+ },
606
+ effects: resolvedCommand.command.effects,
607
+ permissions: effectPermissionsFromArgv(rawArgv),
608
+ });
609
+ if (resolvedCommand.isLegacyAlias && !quiet) {
610
+ runtime.stderr.write(`[bb] Deprecated command "${resolvedCommand.invokedAs.join(" ")}"; use "bb ${resolvedCommand.command.path.join(" ")}".\n`);
611
+ }
612
+ const cmd = normalizeCliCommand(command ?? "");
397
613
  const t0 = Date.now();
398
614
  if (shouldLogCliActions()) {
399
615
  logCliAction({
@@ -405,6 +621,15 @@ async function main() {
405
621
  });
406
622
  }
407
623
  try {
624
+ requireApiKey();
625
+ if (!resolvedCommand.isLegacyAlias &&
626
+ resolvedCommand.command.effects.length > 0) {
627
+ await confirmCurrentCommand({
628
+ action: resolvedCommand.command.path.join(" "),
629
+ target: commandConfirmationTarget(resolvedCommand),
630
+ details: "Canonical v2 mutation. Supply every effect-specific --allow-* flag for non-interactive execution.",
631
+ });
632
+ }
408
633
  switch (cmd) {
409
634
  case "whoami": {
410
635
  await whoAmI();
@@ -1842,6 +2067,8 @@ async function main() {
1842
2067
  durationMs: Date.now() - t0,
1843
2068
  });
1844
2069
  }
2070
+ clearActiveCliSession();
2071
+ return 0;
1845
2072
  }
1846
2073
  catch (err) {
1847
2074
  const message = err instanceof Error ? err.message : String(err);
@@ -1854,8 +2081,21 @@ async function main() {
1854
2081
  error: message,
1855
2082
  });
1856
2083
  }
1857
- console.error(JSON.stringify({ error: message }));
1858
- process.exit(1);
2084
+ const exitCode = emitCommandError(normalizeDispatcherError(err));
2085
+ clearActiveCliSession();
2086
+ return exitCode;
1859
2087
  }
1860
2088
  }
1861
- main();
2089
+ const resolveExecutablePath = (value) => {
2090
+ try {
2091
+ return realpathSync(value);
2092
+ }
2093
+ catch {
2094
+ return resolve(value);
2095
+ }
2096
+ };
2097
+ const isDirectExecution = process.argv[1] !== undefined &&
2098
+ resolveExecutablePath(process.argv[1]) === fileURLToPath(import.meta.url);
2099
+ if (isDirectExecution) {
2100
+ process.exitCode = await runCli();
2101
+ }
@@ -0,0 +1,36 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { commandRegistry, createCommandManifest, globalCommandOptions, } from "./index.js";
5
+ const packageRoot = process.cwd();
6
+ const manifestPath = resolve(packageRoot, "command-manifest.json");
7
+ const reference = [
8
+ "# Budget Builder CLI command reference",
9
+ "",
10
+ "This file is generated from the typed command registry. Do not edit it manually.",
11
+ "This catalog is not filtered by the authenticated user's role. The Budget Builder API authorizes every request using the current database role plus applicable resource, workflow-state, and pending-approver checks.",
12
+ "Command-specific arguments currently pass through the compatibility dispatcher; use `bb help --legacy` for their detailed transition reference.",
13
+ "",
14
+ "## Global options",
15
+ "",
16
+ ...globalCommandOptions.map((option) => `- \`${option.name}\`: ${option.description}`),
17
+ "",
18
+ ...commandRegistry.flatMap((command) => [
19
+ `## \`bb ${command.path.join(" ")}\``,
20
+ "",
21
+ command.summary,
22
+ "",
23
+ `Legacy aliases: ${command.legacyAliases.map((alias) => `\`${alias}\``).join(", ")}.`,
24
+ "",
25
+ `Effects: ${command.effects.length > 0 ? command.effects.join(", ") : "none"}.`,
26
+ "",
27
+ ]),
28
+ ].join("\n");
29
+ await Promise.all([
30
+ writeFile(resolve(packageRoot, "command-reference.md"), reference),
31
+ writeFile(manifestPath, `${JSON.stringify(createCommandManifest(), null, "\t")}\n`),
32
+ ]);
33
+ execFileSync(process.execPath, ["x", "biome", "format", "--write", manifestPath], {
34
+ cwd: packageRoot,
35
+ stdio: ["ignore", "ignore", "inherit"],
36
+ });