@go-labs-sg/bb 1.20.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.
@@ -0,0 +1,76 @@
1
+ import { CliRuntimeError } from "./error.js";
2
+ const effectLabels = {
3
+ "state-change": "changes Budget Builder state",
4
+ email: "sends email",
5
+ "external-write": "writes to an external system",
6
+ delete: "deletes data",
7
+ "financial-write": "creates or changes financial records",
8
+ };
9
+ const isEffectAllowed = (effect, permissions) => {
10
+ switch (effect) {
11
+ case "state-change":
12
+ return permissions.allowStateChange === true;
13
+ case "email":
14
+ return permissions.allowEmail === true;
15
+ case "external-write":
16
+ return permissions.allowExternalWrite === true;
17
+ case "delete":
18
+ return permissions.allowDelete === true;
19
+ case "financial-write":
20
+ return permissions.allowFinancialWrite === true;
21
+ }
22
+ };
23
+ const uniqueEffects = (effects) => [...new Set(effects)];
24
+ export const getRequiredEffectPermissions = (effects, permissions) => uniqueEffects(effects).filter((effect) => !isEffectAllowed(effect, permissions));
25
+ export const formatConfirmationPreview = (request) => [
26
+ "Budget Builder workflow preview",
27
+ `Action: ${request.action}`,
28
+ `Target: ${request.target?.trim() || "not specified"}`,
29
+ `Effects: ${uniqueEffects(request.effects)
30
+ .map((effect) => effectLabels[effect])
31
+ .join("; ") || "none"}`,
32
+ ...(request.details === undefined ? [] : [`Details: ${request.details}`]),
33
+ ].join("\n");
34
+ export const confirmSensitiveCommand = async (runtime, request) => {
35
+ const requiredPermissions = getRequiredEffectPermissions(request.effects, request.permissions);
36
+ if (request.effects.length === 0) {
37
+ return { confirmed: true, requiredPermissions };
38
+ }
39
+ const target = request.target?.trim();
40
+ if (!runtime.isTTY) {
41
+ if (target === undefined) {
42
+ throw new CliRuntimeError("CONFIRMATION_REQUIRED", "Non-interactive sensitive commands require an explicit target.", {
43
+ details: {
44
+ action: request.action,
45
+ effects: requiredPermissions,
46
+ missingFlags: requiredPermissions.map((effect) => `--allow-${effect}`),
47
+ target: "required",
48
+ },
49
+ });
50
+ }
51
+ if (requiredPermissions.length === 0) {
52
+ return { confirmed: true, requiredPermissions };
53
+ }
54
+ const missing = requiredPermissions.map((effect) => `--allow-${effect}`);
55
+ throw new CliRuntimeError("CONFIRMATION_REQUIRED", "Non-interactive sensitive commands require an explicit target and all applicable allow flags.", {
56
+ details: {
57
+ action: request.action,
58
+ effects: requiredPermissions,
59
+ missingFlags: missing,
60
+ },
61
+ });
62
+ }
63
+ if (requiredPermissions.length === 0) {
64
+ return { confirmed: true, requiredPermissions };
65
+ }
66
+ const preview = formatConfirmationPreview(request);
67
+ runtime.stderr.write(`${preview}\n`);
68
+ if (runtime.prompt === undefined) {
69
+ throw new CliRuntimeError("CONFIRMATION_REQUIRED", "This command needs an interactive confirmation prompt.");
70
+ }
71
+ const answer = await runtime.prompt("Type CONFIRM to continue: ");
72
+ if (answer !== "CONFIRM") {
73
+ throw new CliRuntimeError("ABORTED", "Command aborted by user.");
74
+ }
75
+ return { confirmed: true, preview, requiredPermissions };
76
+ };
@@ -0,0 +1,143 @@
1
+ const invocationErrorCodes = new Set([
2
+ "USAGE",
3
+ "VALIDATION",
4
+ "CONFIRMATION_REQUIRED",
5
+ ]);
6
+ const trpcCodeMap = {
7
+ BAD_REQUEST: "VALIDATION",
8
+ CONFLICT: "CONFLICT",
9
+ FORBIDDEN: "AUTHORIZATION",
10
+ INTERNAL_SERVER_ERROR: "API",
11
+ METHOD_NOT_SUPPORTED: "API",
12
+ NOT_FOUND: "NOT_FOUND",
13
+ PARSE_ERROR: "VALIDATION",
14
+ PAYLOAD_TOO_LARGE: "VALIDATION",
15
+ PRECONDITION_FAILED: "CONFLICT",
16
+ TIMEOUT: "NETWORK",
17
+ TOO_MANY_REQUESTS: "API",
18
+ UNAUTHORIZED: "AUTHENTICATION",
19
+ UNPROCESSABLE_CONTENT: "VALIDATION",
20
+ };
21
+ const cliErrorCodes = new Set([
22
+ "USAGE",
23
+ "VALIDATION",
24
+ "CONFIRMATION_REQUIRED",
25
+ "AUTHENTICATION",
26
+ "AUTHORIZATION",
27
+ "NOT_FOUND",
28
+ "CONFLICT",
29
+ "API",
30
+ "NETWORK",
31
+ "ABORTED",
32
+ "INTERRUPTED",
33
+ "INTERNAL",
34
+ ]);
35
+ export class CliRuntimeError extends Error {
36
+ code;
37
+ details;
38
+ requestId;
39
+ constructor(code, message, options = {}) {
40
+ super(message);
41
+ this.name = "CliRuntimeError";
42
+ this.code = code;
43
+ this.details = options.details;
44
+ this.requestId = options.requestId;
45
+ }
46
+ }
47
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
48
+ const asNonEmptyString = (value) => typeof value === "string" && value.trim() !== "" ? value : undefined;
49
+ const asDetails = (value) => {
50
+ if (!isRecord(value))
51
+ return undefined;
52
+ const details = {};
53
+ for (const [key, nestedValue] of Object.entries(value)) {
54
+ if (isJsonValue(nestedValue))
55
+ details[key] = nestedValue;
56
+ }
57
+ return Object.keys(details).length > 0 ? details : undefined;
58
+ };
59
+ const isJsonValue = (value) => {
60
+ if (value === null ||
61
+ typeof value === "string" ||
62
+ typeof value === "boolean" ||
63
+ typeof value === "number") {
64
+ return true;
65
+ }
66
+ if (Array.isArray(value))
67
+ return value.every(isJsonValue);
68
+ return isRecord(value) && Object.values(value).every(isJsonValue);
69
+ };
70
+ const mapErrorCode = (value) => {
71
+ if (typeof value !== "string")
72
+ return undefined;
73
+ if (cliErrorCodes.has(value))
74
+ return value;
75
+ if (value in trpcCodeMap)
76
+ return trpcCodeMap[value];
77
+ return undefined;
78
+ };
79
+ const networkMessagePattern = /\b(fetch failed|network error|unable to connect|econnrefused|econnreset|enotfound|etimedout|socket hang up)\b/i;
80
+ const isNetworkError = (error, depth = 0) => {
81
+ if (depth > 3 || !isRecord(error))
82
+ return false;
83
+ const message = asNonEmptyString(error.message);
84
+ if (message !== undefined && networkMessagePattern.test(message))
85
+ return true;
86
+ const code = asNonEmptyString(error.code);
87
+ if (code !== undefined && networkMessagePattern.test(code))
88
+ return true;
89
+ return isNetworkError(error.cause, depth + 1);
90
+ };
91
+ /**
92
+ * Maps local errors and the common tRPC client error shape into the stable
93
+ * CLI contract without importing a transport-specific error class.
94
+ */
95
+ export const normalizeCliError = (error) => {
96
+ if (error instanceof CliRuntimeError) {
97
+ return {
98
+ code: error.code,
99
+ message: error.message,
100
+ ...(error.details === undefined ? {} : { details: error.details }),
101
+ ...(error.requestId === undefined ? {} : { requestId: error.requestId }),
102
+ };
103
+ }
104
+ if (isNetworkError(error)) {
105
+ return {
106
+ code: "NETWORK",
107
+ message: error instanceof Error
108
+ ? error.message
109
+ : "The Budget Builder API could not be reached.",
110
+ };
111
+ }
112
+ if (isRecord(error)) {
113
+ const data = isRecord(error.data) ? error.data : undefined;
114
+ const transportCode = mapErrorCode(data?.code ?? error.code);
115
+ if (error instanceof Error &&
116
+ data === undefined &&
117
+ transportCode === undefined) {
118
+ return { code: "INTERNAL", message: error.message };
119
+ }
120
+ const code = transportCode ?? "API";
121
+ const message = asNonEmptyString(error.message) ??
122
+ "The Budget Builder API request failed.";
123
+ const requestId = asNonEmptyString(data?.requestId) ??
124
+ asNonEmptyString(data?.requestID) ??
125
+ asNonEmptyString(error.requestId);
126
+ const details = asDetails(data?.details ?? error.details);
127
+ return {
128
+ code,
129
+ message,
130
+ ...(details === undefined ? {} : { details }),
131
+ ...(requestId === undefined ? {} : { requestId }),
132
+ };
133
+ }
134
+ if (error instanceof Error) {
135
+ return { code: "INTERNAL", message: error.message };
136
+ }
137
+ return { code: "INTERNAL", message: "An unexpected CLI error occurred." };
138
+ };
139
+ export const exitCodeForCliError = (error) => {
140
+ if (error.code === "INTERRUPTED")
141
+ return 130;
142
+ return invocationErrorCodes.has(error.code) ? 2 : 1;
143
+ };
@@ -0,0 +1,6 @@
1
+ export { confirmSensitiveCommand, formatConfirmationPreview, getRequiredEffectPermissions, } from "./confirmation.js";
2
+ export { CliRuntimeError, exitCodeForCliError, normalizeCliError, } from "./error.js";
3
+ export { createSuccessEnvelope, emitCliDiagnostic, emitCliError, emitCliSuccess, } from "./output.js";
4
+ export { createProcessRuntime } from "./process-runtime.js";
5
+ export { sanitizeCliDiagnostic, sanitizeCliValue } from "./sanitize.js";
6
+ export { clearActiveCliSession, confirmCurrentCommand, emitCommandError, emitCommandResult, getActiveCliSession, setActiveCliSession, } from "./session.js";
@@ -0,0 +1,36 @@
1
+ import { exitCodeForCliError, normalizeCliError } from "./error.js";
2
+ import { sanitizeCliDiagnostic } from "./sanitize.js";
3
+ const serialize = (value) => `${JSON.stringify(value, null, 2)}\n`;
4
+ export const createSuccessEnvelope = (data, invocation) => ({
5
+ ok: true,
6
+ data,
7
+ meta: {
8
+ command: invocation.command,
9
+ cliVersion: invocation.cliVersion,
10
+ apiBaseUrl: invocation.apiBaseUrl,
11
+ },
12
+ });
13
+ export const emitCliSuccess = (runtime, invocation, data) => {
14
+ runtime.stdout.write(serialize(invocation.legacy ? data : createSuccessEnvelope(data, invocation)));
15
+ return 0;
16
+ };
17
+ export const emitCliError = (runtime, invocation, error) => {
18
+ const normalized = normalizeCliError(error);
19
+ if (invocation.legacy) {
20
+ runtime.stderr.write(serialize({ error: normalized.message }));
21
+ }
22
+ else {
23
+ const envelope = {
24
+ ok: false,
25
+ error: normalized,
26
+ meta: { command: invocation.command },
27
+ };
28
+ runtime.stderr.write(serialize(envelope));
29
+ }
30
+ return exitCodeForCliError(normalized);
31
+ };
32
+ export const emitCliDiagnostic = (runtime, invocation, diagnostic) => {
33
+ if (invocation.quiet || !invocation.debug)
34
+ return;
35
+ runtime.stderr.write(serialize(sanitizeCliDiagnostic(diagnostic)));
36
+ };
@@ -0,0 +1,28 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ export const createProcessRuntime = () => ({
3
+ stdout: {
4
+ write: (value) => {
5
+ process.stdout.write(value);
6
+ },
7
+ },
8
+ stderr: {
9
+ write: (value) => {
10
+ process.stderr.write(value);
11
+ },
12
+ },
13
+ isTTY: Boolean(process.stdin.isTTY && process.stderr.isTTY),
14
+ env: process.env,
15
+ prompt: async (message) => {
16
+ const prompt = createInterface({
17
+ input: process.stdin,
18
+ output: process.stderr,
19
+ });
20
+ try {
21
+ return await prompt.question(message);
22
+ }
23
+ finally {
24
+ prompt.close();
25
+ }
26
+ },
27
+ now: () => new Date(),
28
+ });
@@ -0,0 +1,50 @@
1
+ const sensitiveKeys = new Set([
2
+ "apikey",
3
+ "authorization",
4
+ "bb_api_key",
5
+ "password",
6
+ "paymentproof",
7
+ "paymentproofattachments",
8
+ "paymentproofpath",
9
+ "paymentreference",
10
+ "payload",
11
+ "proof",
12
+ "receipt",
13
+ "secret",
14
+ "token",
15
+ ]);
16
+ const isPresignedUrl = (value) => typeof value === "string" &&
17
+ (/[?&]X-Amz-(?:Algorithm|Credential|Signature)=/i.test(value) ||
18
+ /[?&]X-Goog-(?:Algorithm|Credential|Signature)=/i.test(value));
19
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
20
+ export const sanitizeCliValue = (value, seen = new WeakSet()) => {
21
+ if (value === null ||
22
+ typeof value === "string" ||
23
+ typeof value === "boolean" ||
24
+ typeof value === "number") {
25
+ return isPresignedUrl(value) ? "[redacted]" : value;
26
+ }
27
+ if (typeof value === "bigint")
28
+ return value.toString();
29
+ if (value instanceof Date)
30
+ return value.toISOString();
31
+ if (typeof value !== "object" || value === null)
32
+ return String(value);
33
+ if (seen.has(value))
34
+ return "[circular]";
35
+ seen.add(value);
36
+ if (Array.isArray(value)) {
37
+ return value.map((item) => sanitizeCliValue(item, seen));
38
+ }
39
+ if (!isRecord(value))
40
+ return String(value);
41
+ return Object.fromEntries(Object.entries(value).map(([key, nestedValue]) => [
42
+ key,
43
+ sensitiveKeys.has(key.toLowerCase()) ||
44
+ key.toLowerCase().includes("secret") ||
45
+ key.toLowerCase().includes("password")
46
+ ? "[redacted]"
47
+ : sanitizeCliValue(nestedValue, seen),
48
+ ]));
49
+ };
50
+ export const sanitizeCliDiagnostic = (diagnostic) => sanitizeCliValue(diagnostic);
@@ -0,0 +1,50 @@
1
+ import { confirmSensitiveCommand } from "./confirmation.js";
2
+ import { emitCliError, emitCliSuccess } from "./output.js";
3
+ let activeSession;
4
+ export const setActiveCliSession = (session) => {
5
+ activeSession = {
6
+ ...session,
7
+ confirmedEffects: new Set(),
8
+ };
9
+ };
10
+ export const clearActiveCliSession = () => {
11
+ activeSession = undefined;
12
+ };
13
+ export const getActiveCliSession = () => activeSession;
14
+ const requireSession = () => {
15
+ if (activeSession === undefined) {
16
+ throw new Error("Budget Builder CLI runtime session is not configured.");
17
+ }
18
+ return activeSession;
19
+ };
20
+ const toJsonValue = (value) => {
21
+ const serialized = JSON.stringify(value, (_key, nestedValue) => typeof nestedValue === "bigint" ? nestedValue.toString() : nestedValue);
22
+ if (serialized === undefined)
23
+ return null;
24
+ return JSON.parse(serialized);
25
+ };
26
+ export const emitCommandResult = (data) => {
27
+ const session = requireSession();
28
+ return emitCliSuccess(session.runtime, session.invocation, toJsonValue(data));
29
+ };
30
+ export const emitCommandError = (error) => {
31
+ const session = requireSession();
32
+ return emitCliError(session.runtime, session.invocation, error);
33
+ };
34
+ export const confirmCurrentCommand = async (input) => {
35
+ const session = requireSession();
36
+ const effects = input.effects ?? session.effects;
37
+ const unconfirmedEffects = effects.filter((effect) => !session.confirmedEffects.has(effect));
38
+ if (unconfirmedEffects.length === 0)
39
+ return;
40
+ await confirmSensitiveCommand(session.runtime, {
41
+ action: input.action,
42
+ target: input.target,
43
+ details: input.details,
44
+ effects: unconfirmedEffects,
45
+ permissions: session.permissions,
46
+ });
47
+ for (const effect of unconfirmedEffects) {
48
+ session.confirmedEffects.add(effect);
49
+ }
50
+ };
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,44 +1,38 @@
1
1
  {
2
2
  "name": "@go-labs-sg/bb",
3
- "version": "1.20.0",
4
- "description": "Budget Builder CLI for AI agents — manage budgets, bills, and claims; bill records use isClaimable=false for bills and isClaimable=true for claims.",
3
+ "version": "2.0.0",
4
+ "description": "Budget Builder CLI for AI agents — manage budgets, bills, claims, quotations, and customer invoices with explicit workflow previews for sensitive changes.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
+ "exports": {
8
+ ".": "./dist/index.js",
9
+ "./command-manifest": "./command-manifest.json"
10
+ },
7
11
  "bin": {
8
12
  "bb": "./dist/index.js"
9
13
  },
10
14
  "files": [
11
15
  "dist",
12
- "README.md"
16
+ "README.md",
17
+ "role-aware-agent-guide.md",
18
+ "command-reference.md",
19
+ "command-manifest.json"
13
20
  ],
14
- "scripts": {
15
- "build": "tsc -p tsconfig.build.json",
16
- "typecheck": "tsc --noEmit",
17
- "check": "biome check .",
18
- "check:write": "biome check --write .",
19
- "run": "bun run src/index.ts",
20
- "prepack": "node scripts/strip-workspace-deps.js && bun run build",
21
- "postpack": "node scripts/restore-package-json.js"
22
- },
23
21
  "dependencies": {
24
22
  "@trpc/client": "^11.18.0",
25
23
  "dotenv": "^17.4.2",
26
24
  "mime-types": "^3.0.2",
27
25
  "superjson": "^2.2.6"
28
26
  },
29
- "devDependencies": {
30
- "@types/mime-types": "^3.0.1",
31
- "typescript": "^6.0.3"
32
- },
33
27
  "engines": {
34
- "node": ">=18"
28
+ "node": ">=24"
35
29
  },
36
30
  "publishConfig": {
37
31
  "access": "public"
38
32
  },
39
33
  "repository": {
40
34
  "type": "git",
41
- "url": "https://github.com/go-labs-sg/budget-builder",
42
- "directory": "packages/cli"
35
+ "url": "https://github.com/go-labs-sg/go-labs.git",
36
+ "directory": "packages/budget-builder/cli"
43
37
  }
44
38
  }
@@ -0,0 +1,169 @@
1
+ # Role-aware Budget Builder CLI guide for agents
2
+
3
+ This guide explains how an AI agent should choose Budget Builder CLI commands for the identity behind `BB_API_KEY`. It complements [`bb help`](./README.md#command-overview), which lists commands and arguments but intentionally does not decide authorization on the client. Version 2 documents grouped resource commands (for example, `bb budget get <budget-id>`); flat and `snake_case` commands remain compatibility aliases during the v2 transition.
4
+
5
+ ## Mandatory preflight
6
+
7
+ Run these commands at the beginning of every agent session and after replacing `BB_API_KEY`:
8
+
9
+ ```bash
10
+ bb version
11
+ bb auth whoami
12
+ ```
13
+
14
+ Keep the returned `id`, `email`, and `role` in the working context. Before any mutation, state which identity will perform it. A safe agent summary is:
15
+
16
+ > Authenticated as `<name> <email>` with role `<role>`. I will use only operations allowed for this identity and for resources it owns, is assigned to, or is recorded to approve.
17
+
18
+ Never infer authority from a person's job title, email address, API-key label, or the fact that a command appears in `bb help`.
19
+
20
+ ## How authorization is decided
21
+
22
+ The Budget Builder API, not the CLI, makes the final decision. A request may need all of the following:
23
+
24
+ 1. An active API key and user account.
25
+ 2. A sufficient current database role: `USER`, `INSIDE_SALES`, `LEAD`, `ACCOUNTING_TEAM`, or `ADMIN`. Pending approval assignment does not preserve decision authority after a role change.
26
+ 3. Access to the specific resource, commonly through creator ownership or project assignment as Business Development, Inside Sales, or Project Manager.
27
+ 4. A compatible resource state, such as a bill being `DRAFT` or `REJECTED` before editing.
28
+ 5. A pending approval record assigned to the current user for approval decisions.
29
+ 6. Required evidence and consistent related records, such as payment proof, approved quotation coverage, or matching project/budget IDs.
30
+
31
+ Therefore, two users with the same role can receive different results for the same command and different resource IDs.
32
+
33
+ ## Role guide
34
+
35
+ ### `USER`
36
+
37
+ Use base-user credentials for ordinary, attributable work. Typical operations include:
38
+
39
+ - `bb auth whoami` and managing the current user's own API keys with `bb user api-key ...`.
40
+ - Collaboration endpoints intentionally shared with every authenticated role, including company, contact, and project creation/update.
41
+ - Creating claims and creating supplier bills when project access and validation permit it.
42
+ - Creating and maintaining the user's own draft/rejected quotations and bills.
43
+ - Working on budgets the user created or whose project assigns that user.
44
+ - Requesting approval after inspecting the entity and obtaining user confirmation.
45
+
46
+ Shared collaboration endpoints are explicit API exceptions, not a general grant over every resource. Do not attempt admin analytics, error logs, integration recovery, user provisioning, cross-user API-key management, final payment actions, or approval decisions.
47
+
48
+ ### `INSIDE_SALES`
49
+
50
+ Apply the `USER` rules, plus the supplier access and assigned project/budget responsibilities granted to Inside Sales. This role can participate in supplier approval-request flows and has broader supplier access than a base user.
51
+
52
+ Do not treat Inside Sales as Lead or Admin. Budget and supplier decisions still follow the recorded approval chain, and all project assignment and state checks remain active.
53
+
54
+ ### `LEAD`
55
+
56
+ Apply the base resource rules, plus Lead-stage budget and supplier workflows. Lead-created suppliers are auto-approved, and Leads can handle budget/supplier approvals that the API assigns to them.
57
+
58
+ Do not use Lead credentials for Admin-only dashboards, user/service-identity administration, errors, integration retries, final bill payment, or admin customer-invoice actions.
59
+
60
+ ### `ACCOUNTING_TEAM`
61
+
62
+ Use this role for finance-stage bill work. Typical additional operations include:
63
+
64
+ - Moving an eligible bill to `CHECKED`.
65
+ - Acting on bill approval records assigned to this accounting user.
66
+ - Correcting allowed paid-bill metadata with `bb bill payment patch`.
67
+ - Correcting a bill invoice number with `bb bill invoice-number patch`.
68
+
69
+ The role does not grant the direct Admin-only `APPROVED` or `PAID` status changes. It also does not grant user administration, system dashboards/errors/integration recovery, or Admin-only customer-invoice payment actions.
70
+
71
+ ### `ADMIN`
72
+
73
+ Admin credentials may perform global and final-control operations, including:
74
+
75
+ - `bb user create` for API-only service identities.
76
+ - Cross-user `bb user api-key create`, `bb user api-key list`, and `bb user api-key revoke` with `--userId`.
77
+ - Global dashboard/performance, error-log, and integration-operation commands.
78
+ - Admin-only supplier/category recovery and archive operations.
79
+ - Final bill approval/payment and Admin customer-invoice decisions/payment.
80
+
81
+ Admin is not a force flag. State transitions, evidence, consistency checks, external-operation locks, and interactive confirmation rules still apply.
82
+
83
+ ## Common command gates
84
+
85
+ This table highlights the role-sensitive command groups an agent is most likely to confuse. It is not exhaustive, and the resource/state checks described above still apply.
86
+
87
+ | Commands or action | Required identity condition |
88
+ | --- | --- |
89
+ | `bb auth whoami`; own `bb user api-key create`, `bb user api-key list`, and `bb user api-key revoke` | Any active role; omit `--userId` to act as the current user. |
90
+ | Cross-user API-key commands; `bb user create` | `ADMIN`. |
91
+ | Budget/project mutations, estimates, and budget attachments | `ADMIN`, budget creator, or project assignment as Business Development, Inside Sales, or Project Manager, depending on the endpoint. |
92
+ | Edit/delete a bill or quotation | Usually creator or `ADMIN`, with a supported status. |
93
+ | `bb budget approve`, `bb budget reject`, `bb supplier approve`, and `bb supplier reject` | Current user must be the pending approver and currently have role `LEAD` or `ADMIN`. |
94
+ | `bb bill approve` and `bb bill reject` | Current user must be the pending approver and currently have role `ACCOUNTING_TEAM` for the finance/check stage or `ADMIN` for the final approval stage. |
95
+ | `bb quotation approve` and `bb quotation reject` | Current user must be the pending approver and currently have role `ADMIN`; a normal submitter cannot decide the quotation. |
96
+ | `bb bill status update ... CHECKED` | `ACCOUNTING_TEAM` or `ADMIN`. |
97
+ | `bb bill status update ... APPROVED` or `... PAID` | `ADMIN`; state, integration, payment-reference, and payment-proof requirements still apply. |
98
+ | `bb bill payment patch`; `bb bill invoice-number patch` | `ACCOUNTING_TEAM` or `ADMIN`, with the procedure's bill-state constraints. |
99
+ | `bb customer-invoice approve` and `bb customer-invoice reject` | `ADMIN` batch operations selected by batch ID; the API resolves the internal pending Admin approval record. |
100
+ | `bb customer-invoice payment mark-paid` | `ADMIN`. |
101
+ | Dashboard/performance, supplier analytics, errors, integration operations | `ADMIN`. |
102
+ | `bb item category create`, `bb item category update`, `bb item category delete`, `bb supplier delete`, `bb supplier reactivate`, and `bb budget item placeholder-bill create` | `ADMIN`. |
103
+
104
+ ## Command-selection rules
105
+
106
+ Use this sequence for every task:
107
+
108
+ 1. Run `bb auth whoami` and classify the identity using the role guide above.
109
+ 2. Start with a read command for the exact target, such as `bb budget get`, `bb bill get`, `bb quotation get`, or `bb customer-invoice get`.
110
+ 3. Confirm creator, project assignments, status, and pending approver information from the returned data when available.
111
+ 4. Compare the intended command with both the role and resource conditions.
112
+ 5. Before every mutation, explain the exact target and side effects and wait for explicit user confirmation.
113
+ 6. In an interactive terminal, review the CLI preview and type the exact `CONFIRM` only when it matches the user's approved action. In non-interactive use, supply every effect guard declared for the command: `--allow-state-change`, `--allow-email`, `--allow-external-write`, `--allow-delete`, and/or `--allow-financial-write`.
114
+ 7. Execute once, then verify the returned JSON or re-read the resource.
115
+
116
+ The granular flags acknowledge specific runtime effects; they never grant role or resource authorization and never replace the user's explicit confirmation. If the CLI reports a missing effect flag, stop and reassess the preview instead of adding broad flags automatically.
117
+
118
+ The following distinctions are especially important:
119
+
120
+ - `bb user api-key create`, `bb user api-key list`, and `bb user api-key revoke` default to the current user for every role; targeting someone else with `--userId` is Admin-only.
121
+ - Budget, supplier, bill, and quotation decisions require both the matching live role and a pending approval assigned to the current identity. Customer-invoice approve/reject are Admin batch operations selected by batch ID; the API resolves their internal approval record.
122
+ - Some company, contact, project, supplier, and catalog collaboration procedures intentionally allow every active authenticated role. Treat these endpoint-specific rules as exceptions rather than inferring access to adjacent mutations.
123
+ - Budget and project commands often depend on creator/assignment access even when the role itself is valid.
124
+ - Bill/claim status commands have separate role rules for `CHECKED`, `APPROVED`, and `PAID`.
125
+ - `bb bill create --payload` uses `isClaimable=false`; `isClaimable=true` creates a claim. Claims intentionally permit some base-user flows that supplier bills do not.
126
+ - Staged attachments belong to the user in the database. Do not reuse, share, or manually construct staged keys.
127
+
128
+ ## Handling authorization failures
129
+
130
+ On `FORBIDDEN`:
131
+
132
+ 1. Stop the attempted workflow.
133
+ 2. Report the command and target resource.
134
+ 3. Include the `whoami` `id`, `email`, and `role` without exposing the API key.
135
+ 4. Explain the likely missing role, creator ownership, project assignment, or pending-approver record.
136
+ 5. Ask the user whether the resource assignment or operating identity should change.
137
+
138
+ Do not retry with lower-level upload/request procedures, direct tRPC paths, a different API key, or repeated calls. A CLI denial is an API authorization result, not a client limitation to work around.
139
+
140
+ On `BAD_REQUEST` or `CONFLICT`, re-read the resource before deciding whether to retry. These responses commonly indicate an invalid state transition, stale data, missing evidence, or an integration operation already in progress.
141
+
142
+ ## Safe examples
143
+
144
+ Base user preparing a claim:
145
+
146
+ ```bash
147
+ bb auth whoami
148
+ bb project get <project-id>
149
+ bb bill attachment stage <project-id> <receipt.pdf>
150
+ bb bill create --payload '<claim-json-with-isClaimable-true>'
151
+ ```
152
+
153
+ Accounting user processing a bill:
154
+
155
+ ```bash
156
+ bb auth whoami
157
+ bb bill get <bill-id>
158
+ bb bill status update <bill-id> CHECKED
159
+ ```
160
+
161
+ Admin inspecting a failed integration before a confirmed retry:
162
+
163
+ ```bash
164
+ bb auth whoami
165
+ bb integration operation list --status FAILED
166
+ bb integration operation retry <operation-id>
167
+ ```
168
+
169
+ The mutation and retry examples remain subject to explicit user approval and the effect-aware runtime rules above: exact `CONFIRM` interactively, or every applicable granular `--allow-*` flag in non-interactive use.