@ciphrix/cli 0.1.1 → 0.3.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.
package/README.md CHANGED
@@ -60,8 +60,9 @@ ciphrix test run result "MFA enforced" \
60
60
  --note "Rolled out organisation-wide"
61
61
  ```
62
62
 
63
- Add `--json` to any command for stable machine-readable output. Commands that perform consequential
64
- changes show the proposed action and ask for confirmation; automation can opt in with `--yes`.
63
+ Add `--json` for machine-readable output. Confirmed changes show a proposal and ask for consent;
64
+ automation must explicitly opt in with `--yes`. New immediate decision/send commands apply directly
65
+ and reject `--yes`; check command help rather than adding the flag to every write.
65
66
 
66
67
  ## One interface for compliance work
67
68
 
@@ -72,8 +73,33 @@ changes show the proposed action and ask for confirmation; automation can opt in
72
73
  - **Manage operational registers** — work with assets, vendors, risks, treatments, ownership, and files.
73
74
  - **Operate monitoring** — inspect checks, resources, runs, findings, integrations, and background jobs.
74
75
 
75
- Names and stable Ciphrix codes are accepted throughout, so neither people nor agents need to work with
76
- opaque database identifiers.
76
+ Established record commands accept names and stable Ciphrix codes where advertised. Access inventory,
77
+ review tasks and proposals require the exact IDs returned by their read commands; never guess identifiers.
78
+
79
+ ### Document review and approval
80
+
81
+ Configure Simple or Advanced workflows, assign active users, and explicitly send, recall or decide
82
+ review/approval requests. Admins and InfoSec leads operate these commands; employees can receive
83
+ assignments and complete decisions in the app.
84
+
85
+ ```bash
86
+ # Read state before configuring an existing draft
87
+ ciphrix document get "Access Control Policy" --json
88
+ ciphrix document workflow set "Access Control Policy" --type advanced
89
+ ciphrix user list --search "reviewer@example.com" --json
90
+ ciphrix document reviewer set "Access Control Policy" --user "reviewer@example.com"
91
+ ciphrix document approver set "Access Control Policy" --user "approver@example.com"
92
+
93
+ # Send only when authorized; review completion is a separate human decision
94
+ ciphrix document submit "Access Control Policy"
95
+ ```
96
+
97
+ After the reviewer completes review in the app (or an authorized caller explicitly records that
98
+ decision), request approval separately with `document approval send`. Ordinary approval remains
99
+ `document approve`; self-approval is a distinct explicit decision, not a review bypass.
100
+
101
+ See the [document workflow guide](skills/ciphrix/references/document-workflows.md) for recovery,
102
+ confirmation/retry boundaries and result interpretation. Use installed `--help` for exact options.
77
103
 
78
104
  ## Built for agent workspaces
79
105
 
@@ -99,7 +125,15 @@ The skill works with compatible agent harnesses including Codex, Claude Code, Cu
99
125
  is also included in the npm package at [`skills/ciphrix/SKILL.md`](skills/ciphrix/SKILL.md). Installing
100
126
  the npm package never modifies an agent's configuration directories.
101
127
 
102
- Run `npx skills update` to update installed skills when a newer version is available.
128
+ Run `npx skills update` to update installed skills when a newer version is available. Keep the skill
129
+ aligned with your installed CLI version; its generated command reference and local help describe the
130
+ available grammar. Server capability discovery remains authoritative for which operations you can use.
131
+
132
+ For document workflows, assignment and request sending are separate from formal review and approval
133
+ decisions. An agent may coordinate an authorized recall/configure/resend request, but must ask before
134
+ making a formal decision. A sent request must be recalled before changing its recipient, even when
135
+ selecting the same person. Recall clears that assignment. Active employees may be assigned and finish
136
+ their decisions in the app; tool access remains restricted to admins and InfoSec leads.
103
137
 
104
138
  ### Ask in natural language, operate through the CLI
105
139
 
@@ -144,6 +178,7 @@ one command. Installed help is authoritative. The complete generated reference i
144
178
  | `ciphrix login` · `logout` | Authorize this device or revoke its session. |
145
179
  | `ciphrix context get` · `status` · `set` · `reset` | Read and maintain Business and Operating Context. |
146
180
  | `ciphrix document …` | Browse, read, create, edit, version, submit, approve, and delete documents. |
181
+ | `ciphrix user list` | Discover active users by name/email or paginated listing for assignment. |
147
182
  | `ciphrix test …` | Inspect tests, links, runs and evidence; attach items and record results. |
148
183
  | `ciphrix framework …` | Browse applied frameworks and work with clauses and mappings. |
149
184
  | `ciphrix control …` | Inspect controls, ownership, applicability, design requirements, and mappings. |
@@ -155,6 +190,47 @@ one command. Installed help is authoritative. The complete generated reference i
155
190
 
156
191
  </details>
157
192
 
193
+ ### Context, reviews and agent-assisted work
194
+
195
+ The onboarding group reads separate Business and Operating Context, saves agreed questionnaire answers,
196
+ and offers explicit optional setup and completion. Business Context readiness is decided by the server;
197
+ Operating Context and optional setup do not block journey completion. Saving context never automatically
198
+ starts agents or completes the journey. Local `--help` works offline; `onboarding --help-onboarding`
199
+ returns authenticated server guidance and the current status pack.
200
+
201
+ The access group separates inventory, reviewed import proposals, assigned reviewer decisions and campaign
202
+ results. A revoke decision records a removal request, not provider-side revocation. Recording decisions,
203
+ completing a reviewer task, and ending a campaign are separate operations. Imports merge only explicitly
204
+ selected reviewed rows; inspect per-row failures even when some rows applied.
205
+
206
+ Agent findings and advisory test/control assessments do not make formal human decisions. Assigned-task
207
+ discovery does not complete work. File staging and processing are explicit, separate operations; processing
208
+ may update context or create vendors. Evidence/CSV commands return expiring signed references without
209
+ automatically downloading files. Use each command's help for the exact inputs, next reads and effects.
210
+
211
+ ### Manifest compatibility, JSON and retries
212
+
213
+ The CLI discovers allowed tools and their effects from the authenticated server manifest. New command
214
+ groups require server-declared command bindings. An unavailable binding is reported as unsupported;
215
+ the CLI never guesses a different endpoint or interprets the manifest as authorization. Existing
216
+ commands keep their successful JSON formats. New capability commands emit complete success/error
217
+ envelopes with `--json`, preserving warnings, pagination, truncation and structured error details.
218
+ Failures and partially applied mutations return a nonzero exit status.
219
+
220
+ Confirmed writes require explicit consent. New commands in JSON/non-interactive mode require `--yes`
221
+ for confirmed operations; immediate writes do not accept that flag. Write controls are rejected for
222
+ reads. The optional `--idempotency-key` names one logical write: reuse identical input for a retry,
223
+ never a new key to bypass an unknown outcome. There are no automatic write retries. Confirmed replay
224
+ also needs a valid confirmation token; a fresh invocation re-preflights and may be blocked by changed
225
+ state or expiry. The CLI does not save confirmation tokens or promise recovery across invocations.
226
+ Inspect current records/jobs when uncertain. Replayed status packs and signed URLs are original
227
+ snapshots, not refreshed state or renewed downloads.
228
+
229
+ Mutation JSON files must be regular files, at most 1 MiB, containing an explicitly selected array.
230
+ Use clear flags or JSON null where supported, not the string `"null"`. Do not infer confirmations
231
+ from suggested matches. New repeated flags collect separate values; existing command conventions remain
232
+ compatible. See the generated command reference for all options and nested payload details.
233
+
158
234
  ## Safe by default
159
235
 
160
236
  - Credentials use the operating system keychain by default and are isolated by API environment.
package/dist/actions.d.ts CHANGED
@@ -8,6 +8,8 @@ export interface ApplyOptions {
8
8
  fallback: string;
9
9
  yes?: boolean | undefined;
10
10
  confirm?: ((question: string) => Promise<boolean>) | undefined;
11
+ idempotencyKey?: string | undefined;
12
+ quiet?: boolean | undefined;
11
13
  }
12
14
  /**
13
15
  * Runs a tool, handling the two-step confirm contract when the tool requires it.
@@ -16,4 +18,4 @@ export interface ApplyOptions {
16
18
  * - Tools that require confirmation: the first call is a preflight (summary + token), the user is asked
17
19
  * once, then the change is applied with the token and an idempotency key.
18
20
  */
19
- export declare const applyTool: ({ ctx, io, toolName, input, fallback, yes, confirm, }: ApplyOptions) => Promise<Record<string, unknown> | null>;
21
+ export declare const applyTool: ({ ctx, io, toolName, input, fallback, yes, confirm, idempotencyKey: suppliedKey, quiet, }: ApplyOptions) => Promise<Record<string, unknown> | null>;
package/dist/actions.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { promptConfirm, writeLine } from './io.js';
3
+ import { ApiError } from './api.js';
4
+ import { getTool } from './manifest.js';
3
5
  import { asRecord, asText, callTool, errorMessage } from './toolSurface.js';
4
6
  /**
5
7
  * Runs a tool, handling the two-step confirm contract when the tool requires it.
@@ -8,23 +10,30 @@ import { asRecord, asText, callTool, errorMessage } from './toolSurface.js';
8
10
  * - Tools that require confirmation: the first call is a preflight (summary + token), the user is asked
9
11
  * once, then the change is applied with the token and an idempotency key.
10
12
  */
11
- export const applyTool = async ({ ctx, io, toolName, input, fallback, yes = false, confirm, }) => {
13
+ export const applyTool = async ({ ctx, io, toolName, input, fallback, yes = false, confirm, idempotencyKey: suppliedKey, quiet = false, }) => {
12
14
  const ask = confirm ?? ((question) => promptConfirm(io, question));
13
- const idempotencyKey = randomUUID();
15
+ const idempotencyKey = suppliedKey ?? randomUUID();
16
+ const tool = await getTool(ctx, toolName);
17
+ if (tool.effect !== 'write')
18
+ throw new Error('Expected a write operation.');
14
19
  const first = await callTool(ctx, toolName, input, { idempotencyKey });
15
20
  if (first.status !== 'ok')
16
- throw new Error(errorMessage(first, fallback));
21
+ throw new ApiError(errorMessage(first, fallback), 400, asText(asRecord(first.error).code), asRecord(first.error).details, first);
17
22
  const confirmation = asRecord(asRecord(first.meta).confirmation);
18
23
  const token = asText(confirmation.token);
19
- if (!token)
24
+ if (tool.confirmation === 'none')
20
25
  return first;
21
- writeLine(io.stdout, asText(confirmation.summary, 'Apply this change?'));
26
+ if (!token)
27
+ throw new Error('The server did not return the required confirmation token. No change was applied.');
28
+ if (!quiet)
29
+ writeLine(io.stdout, asText(confirmation.summary, 'Apply this change?'));
22
30
  if (!yes && !(await ask('Apply this change?'))) {
23
- writeLine(io.stdout, 'Cancelled.');
31
+ if (!quiet)
32
+ writeLine(io.stdout, 'Cancelled.');
24
33
  return null;
25
34
  }
26
35
  const result = await callTool(ctx, toolName, input, { confirmationToken: token, idempotencyKey });
27
36
  if (result.status !== 'ok')
28
- throw new Error(errorMessage(result, fallback));
37
+ throw new ApiError(errorMessage(result, fallback), 400, asText(asRecord(result.error).code), asRecord(result.error).details, result);
29
38
  return result;
30
39
  };
package/dist/api.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export declare class ApiError extends Error {
2
2
  readonly status: number;
3
3
  readonly code: string | undefined;
4
- constructor(message: string, status: number, code?: string | undefined);
4
+ readonly details: unknown;
5
+ readonly envelope: unknown;
6
+ constructor(message: string, status: number, code?: string | undefined, details?: unknown, envelope?: unknown);
5
7
  }
6
8
  export interface RequestOptions {
7
9
  method?: string;
package/dist/api.js CHANGED
@@ -1,11 +1,15 @@
1
1
  export class ApiError extends Error {
2
2
  status;
3
3
  code;
4
- constructor(message, status, code = undefined) {
4
+ details;
5
+ envelope;
6
+ constructor(message, status, code = undefined, details = undefined, envelope = undefined) {
5
7
  super(message);
6
8
  this.name = 'ApiError';
7
9
  this.status = status;
8
10
  this.code = code;
11
+ this.details = details;
12
+ this.envelope = envelope;
9
13
  }
10
14
  }
11
15
  /** Default time allowed for an API request, including reading its response body. */
@@ -104,7 +108,7 @@ const errorPayload = (data) => {
104
108
  : typeof nested?.code === 'string'
105
109
  ? nested.code
106
110
  : undefined;
107
- const details = Array.isArray(nested?.details) ? nested?.details : [];
111
+ const details = nested?.details;
108
112
  return { message, code, details };
109
113
  };
110
114
  /**
@@ -172,7 +176,7 @@ export const createApiClient = (baseUrl, limits = {}) => ({
172
176
  }
173
177
  if (!response.ok) {
174
178
  const { message, code, details } = errorPayload(data);
175
- throw new ApiError(withDetails(message || `Request failed with status ${response.status}`, code, details), response.status, code);
179
+ throw new ApiError(withDetails(message || `Request failed with status ${response.status}`, code, Array.isArray(details) ? details : []), response.status, code, details, data);
176
180
  }
177
181
  return data;
178
182
  });
package/dist/cli.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { Command, CommanderError } from 'commander';
2
+ import { registerCapabilities, RenderedCommandError } from './commands/capabilities.js';
3
+ import { asRecord } from './toolSurface.js';
2
4
  import { formatBanner } from './banner.js';
3
5
  import { loginCommand } from './commands/login.js';
4
6
  import { logoutCommand } from './commands/logout.js';
@@ -247,7 +249,9 @@ export const createProgram = (io = createDefaultIO()) => {
247
249
  json: readBooleanOption(options, 'json'),
248
250
  });
249
251
  });
250
- const testRun = test.command('run').description('Create, result and delete runs');
252
+ const testRun = test
253
+ .command('run')
254
+ .description('Run lifecycle, human results/notes, linked evidence references and advisory assessments');
251
255
  testRun
252
256
  .command('create <name>')
253
257
  .description('Create a new pending run')
@@ -266,17 +270,21 @@ export const createProgram = (io = createDefaultIO()) => {
266
270
  testRun
267
271
  .command('result <name>')
268
272
  .description('Record a run result: passing | failing | skipped | pending (reopen)')
269
- .requiredOption('--status <status>', 'passing | failing | skipped | pending')
273
+ .option('--status <status>', 'human-decided result: passing | failing | skipped | pending; omit for note-only changes')
270
274
  .option('--note <text>', 'note for the run')
275
+ .option('--clear-note', 'clear the note without changing the result; conflicts with --note')
271
276
  .option('--run <id>', 'a specific run')
272
277
  .option('--api-url <url>', 'override the API base URL for this command')
273
278
  .option('--json', 'output raw JSON')
274
279
  .action(async (name, options) => {
280
+ if (readBooleanOption(options, 'clearNote') &&
281
+ readStringOption(options, 'note') !== undefined)
282
+ throw new Error('--clear-note conflicts with --note.');
275
283
  await testRunResultCommand({
276
284
  io,
277
285
  name,
278
- status: readStringOption(options, 'status') ?? '',
279
- note: readStringOption(options, 'note'),
286
+ status: readStringOption(options, 'status'),
287
+ note: readBooleanOption(options, 'clearNote') ? null : readStringOption(options, 'note'),
280
288
  runId: readStringOption(options, 'run'),
281
289
  apiUrl: readStringOption(options, 'apiUrl'),
282
290
  json: readBooleanOption(options, 'json'),
@@ -848,10 +856,11 @@ export const createProgram = (io = createDefaultIO()) => {
848
856
  });
849
857
  const control = program
850
858
  .command('control')
851
- .description('Controls: status, applicability, owner and design requirements');
859
+ .description('Controls: status, applicability, ownership, design requirements and advisory assessments');
852
860
  control
853
861
  .command('list')
854
862
  .description('List controls with their status, applicability and owner')
863
+ .option('--framework <code>', 'filter by catalogue framework code; repeat for multiple values', (value, previous = []) => [...previous, value])
855
864
  .option('--search <text>', 'filter by name or code')
856
865
  .option('--domain <value>', 'filter by control domain')
857
866
  .option('--status <csv>', 'filter by status')
@@ -864,6 +873,9 @@ export const createProgram = (io = createDefaultIO()) => {
864
873
  .action(async (options) => {
865
874
  await controlListCommand({
866
875
  io,
876
+ framework: Array.isArray(asRecord(options).framework)
877
+ ? asRecord(options).framework
878
+ : undefined,
867
879
  search: readStringOption(options, 'search'),
868
880
  domain: readStringOption(options, 'domain'),
869
881
  status: readStringOption(options, 'status'),
@@ -1567,6 +1579,7 @@ export const createProgram = (io = createDefaultIO()) => {
1567
1579
  json: readBooleanOption(options, 'json'),
1568
1580
  });
1569
1581
  });
1582
+ registerCapabilities(program, io);
1570
1583
  // A bare `ciphrix` shows the banner and help; commands never print the banner.
1571
1584
  program.action(() => {
1572
1585
  writeBlock(io.stdout, `${formatBanner(io.theme)}\n\n`);
@@ -1590,6 +1603,8 @@ export const main = async (argv = process.argv, io = createDefaultIO()) => {
1590
1603
  return 0;
1591
1604
  }
1592
1605
  catch (error) {
1606
+ if (error instanceof RenderedCommandError)
1607
+ return 1;
1593
1608
  if (error instanceof CommanderError) {
1594
1609
  return error.exitCode;
1595
1610
  }
@@ -0,0 +1,6 @@
1
+ import { Command } from 'commander';
2
+ import { type CliIO } from '../io.js';
3
+ import { type ToolContext } from '../toolSurface.js';
4
+ export declare class RenderedCommandError extends Error {
5
+ }
6
+ export declare const registerCapabilities: (program: Command, io: CliIO, context?: (apiUrl?: string) => Promise<ToolContext>) => void;
@@ -0,0 +1,311 @@
1
+ import { Command, Option } from 'commander';
2
+ import { applyTool } from '../actions.js';
3
+ import { ApiError } from '../api.js';
4
+ import { promptConfirm, writeLine } from '../io.js';
5
+ import { ManifestError, resolveBinding } from '../manifest.js';
6
+ import { asRecord, callTool, resolveToolContext } from '../toolSurface.js';
7
+ import { FileInputError, readUploadFile, stageLocalFile } from '../upload.js';
8
+ import { capabilities, onboardingOperations, } from './capabilityDefinitions.js';
9
+ export class RenderedCommandError extends Error {
10
+ }
11
+ class InputError extends Error {
12
+ }
13
+ const retryHelp = 'Writes use one idempotency key per operation. Reuse that key/input for retries; never bypass an unknown outcome with a new key. Confirmed calls also require valid confirmation: a fresh invocation re-preflights and may be blocked by changed state or expiry. Inspect current state/jobs when uncertain. Replayed status packs and signed URLs are snapshots, not refreshed reads.';
14
+ const parseJson = (text) => {
15
+ try {
16
+ return JSON.parse(text);
17
+ }
18
+ catch {
19
+ throw new InputError('Expected strict JSON. Quote string values as JSON strings.');
20
+ }
21
+ };
22
+ const inputFor = async (spec, args, options) => {
23
+ const input = {};
24
+ for (const [index, name] of (spec.argument?.split(' ') ?? []).entries())
25
+ input[name] = args[index];
26
+ for (const field of spec.fields) {
27
+ const value = options[field.flag.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())];
28
+ if (value === undefined)
29
+ continue;
30
+ if (Object.hasOwn(input, field.field))
31
+ throw new InputError(`Conflicting options for ${field.field}.`);
32
+ let parsed = value;
33
+ if (field.kind === 'clear')
34
+ parsed = null;
35
+ if (field.kind === 'integer') {
36
+ if (typeof value !== 'string' || !/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)))
37
+ throw new InputError(`--${field.flag} requires a nonnegative integer.`);
38
+ parsed = Number(value);
39
+ }
40
+ if (field.kind === 'json') {
41
+ if (typeof value !== 'string')
42
+ throw new InputError('Expected a JSON argument.');
43
+ parsed = parseJson(value);
44
+ }
45
+ if (field.kind === 'file') {
46
+ if (typeof value !== 'string')
47
+ throw new InputError('Expected a regular file path.');
48
+ parsed = parseJson((await readUploadFile(value, 1024 * 1024)).toString('utf8'));
49
+ if (!Array.isArray(parsed))
50
+ throw new InputError(`--${field.flag} requires a JSON array.`);
51
+ }
52
+ input[field.field] = parsed;
53
+ }
54
+ if (spec.path === 'access entry bulk-update')
55
+ input.operation = 'update';
56
+ if (spec.inputAdapter === 'documentWorkflow') {
57
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
58
+ const document = typeof input.document === 'string' ? input.document : '';
59
+ if (!document.trim())
60
+ throw new InputError('Provide a document name, code or ID.');
61
+ delete input.document;
62
+ input[uuid.test(document) ? 'policyId' : 'name'] = document;
63
+ if (input.user !== undefined) {
64
+ const user = typeof input.user === 'string' ? input.user : '';
65
+ if (uuid.test(user))
66
+ input.user = { id: user };
67
+ else if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user))
68
+ input.user = { email: user };
69
+ else
70
+ throw new InputError('--user requires an exact email or User ID.');
71
+ }
72
+ }
73
+ return input;
74
+ };
75
+ const partialFailure = (value, depth = 0) => {
76
+ if (depth > 32 || !value || typeof value !== 'object')
77
+ return false;
78
+ if (Array.isArray(value))
79
+ return value.some((item) => partialFailure(item, depth + 1));
80
+ const record = asRecord(value);
81
+ if (['failures', 'failed', 'unmerged'].some((key) => Array.isArray(record[key]) && record[key].length > 0))
82
+ return true;
83
+ return Object.values(record).some((child) => partialFailure(child, depth + 1));
84
+ };
85
+ const execute = async (ctx, tool, input, io, options) => {
86
+ if (tool.effect === 'read') {
87
+ if (options.yes || options.idempotencyKey !== undefined)
88
+ throw new InputError('Write controls do not apply to a read operation.');
89
+ return callTool(ctx, tool.name, input);
90
+ }
91
+ if (tool.confirmation === 'none' && options.yes)
92
+ throw new InputError('This write applies immediately and does not accept --yes.');
93
+ if (tool.confirmation !== 'none' && !options.yes && (options.json || !io.stdout.isTTY))
94
+ throw new InputError('This confirmed write requires --yes in JSON or non-interactive mode. No change was applied.');
95
+ return applyTool({
96
+ ctx,
97
+ io,
98
+ quiet: options.json === true,
99
+ toolName: tool.name,
100
+ input,
101
+ fallback: 'The operation could not be completed.',
102
+ yes: options.yes === true,
103
+ ...(typeof options.idempotencyKey === 'string' && { idempotencyKey: options.idempotencyKey }),
104
+ confirm: (question) => promptConfirm(io, question),
105
+ });
106
+ };
107
+ const render = (io, json, envelope, next) => {
108
+ if (json)
109
+ writeLine(io.stdout, JSON.stringify(envelope));
110
+ else {
111
+ writeLine(io.stdout, JSON.stringify(envelope.data ?? envelope, null, 2));
112
+ if (Array.isArray(envelope.warnings))
113
+ for (const warning of envelope.warnings)
114
+ writeLine(io.stdout, `warning: ${String(warning)}`);
115
+ if (asRecord(envelope.meta).truncated)
116
+ writeLine(io.stdout, 'Results are truncated; inspect continuation details.');
117
+ if (next)
118
+ writeLine(io.stdout, `next: ciphrix ${next} --help`);
119
+ }
120
+ };
121
+ const run = async (io, options, next, action) => {
122
+ let failed;
123
+ let applying = false;
124
+ try {
125
+ const key = options.idempotencyKey;
126
+ if (key !== undefined && (typeof key !== 'string' || !key.trim() || key.length > 256)) {
127
+ throw new InputError('The idempotency key must contain 1 to 256 characters.');
128
+ }
129
+ const envelope = await action(() => {
130
+ applying = true;
131
+ });
132
+ if (!envelope) {
133
+ if (options.json)
134
+ writeLine(io.stdout, JSON.stringify({ status: 'cancelled' }));
135
+ return;
136
+ }
137
+ render(io, options.json === true, envelope, next);
138
+ failed = envelope.status !== 'ok' || (applying && partialFailure(envelope.data));
139
+ if (failed && !options.json)
140
+ writeLine(io.stderr, 'The operation failed or partially applied. Inspect the returned outcomes before taking further action.');
141
+ }
142
+ catch (error) {
143
+ const envelope = error instanceof ApiError && asRecord(error.envelope).status === 'error'
144
+ ? asRecord(error.envelope)
145
+ : {
146
+ status: 'error',
147
+ error: {
148
+ code: error instanceof InputError || error instanceof FileInputError
149
+ ? 'invalid_input'
150
+ : error instanceof ManifestError
151
+ ? 'unsupported_capability'
152
+ : error instanceof ApiError
153
+ ? (error.code ?? 'http_error')
154
+ : 'transport_error',
155
+ message: error instanceof InputError ||
156
+ error instanceof FileInputError ||
157
+ error instanceof ApiError ||
158
+ error instanceof ManifestError
159
+ ? error.message
160
+ : 'The request could not be completed. Check capabilities and inspect state before retrying a write.',
161
+ ...(error instanceof ApiError && {
162
+ httpStatus: error.status,
163
+ ...(error.details !== undefined && { details: error.details }),
164
+ }),
165
+ },
166
+ };
167
+ render(options.json ? io : { ...io, stdout: io.stderr }, options.json === true, envelope, '');
168
+ failed = true;
169
+ }
170
+ if (failed)
171
+ throw new RenderedCommandError();
172
+ };
173
+ const groupDescriptions = {
174
+ user: 'Active tenant-user discovery; no account administration',
175
+ 'document workflow': 'Configure workflow or explicitly return to draft',
176
+ 'document reviewer': 'Configure the review recipient; recall sent requests first',
177
+ 'document approver': 'Configure the approval recipient; recall sent requests first',
178
+ 'document review': 'Explicit review decisions and request recall; send with document submit',
179
+ 'document approval': 'Approval requests, explicit decisions and recall; ordinary decision with document approve',
180
+ access: 'Access inventory, reviewed imports and assigned reviewer workflows',
181
+ 'access resource': 'Resources, owners and asset relationships',
182
+ 'access entry': 'Revisioned access inventory; inventory edits are not review decisions',
183
+ 'access import': 'Your import proposals, selected-row merges and explicit discard',
184
+ 'access review': 'Review campaigns, assigned decisions, completion and frozen outcomes',
185
+ agent: 'Agent activity, complete proposals and explicit human decisions',
186
+ 'agent run': 'Grouped agent activity; findings are not completed assessments',
187
+ 'agent action': 'Findings, reviewed proposals and declared approve/reject/choose options',
188
+ task: 'Your assigned stored work; discovery never completes tasks',
189
+ file: 'Owned local-file staging and explicit processing; no automatic downloads',
190
+ };
191
+ const parent = (program, path) => {
192
+ let current = program;
193
+ const traversed = [];
194
+ for (const segment of path) {
195
+ traversed.push(segment);
196
+ current =
197
+ current.commands.find((command) => command.name() === segment) ??
198
+ current
199
+ .command(segment)
200
+ .description(groupDescriptions[traversed.join(' ')] ?? `${segment} capabilities`);
201
+ }
202
+ return current;
203
+ };
204
+ const common = (command) => {
205
+ command
206
+ .option('--api-url <url>', 'override the API base URL')
207
+ .option('--json', 'emit one complete result/error envelope')
208
+ .option('--yes', 'confirmed writes only: explicitly authorize without prompting; rejected on reads/immediate writes')
209
+ .option('--idempotency-key <key>', 'writes only: key for one logical operation; reuse identical input, at most 256 characters')
210
+ .addHelpText('after', `\n${retryHelp}\n`);
211
+ };
212
+ const addField = (command, field) => {
213
+ const flag = `--${field.flag}${['boolean', 'clear'].includes(field.kind) ? '' : ' <value>'}`;
214
+ const option = new Option(flag, field.help);
215
+ if (field.required)
216
+ option.makeOptionMandatory();
217
+ if (field.kind === 'repeat')
218
+ option.argParser((value, previous = []) => [...previous, value]);
219
+ command.addOption(option);
220
+ };
221
+ export const registerCapabilities = (program, io, context = resolveToolContext) => {
222
+ for (const spec of capabilities) {
223
+ const segments = spec.path.split(' ');
224
+ const operation = segments.pop();
225
+ const command = parent(program, segments)
226
+ .command(operation +
227
+ (spec.argument
228
+ ? ` ${spec.argument
229
+ .split(' ')
230
+ .map((name) => `<${name}>`)
231
+ .join(' ')}`
232
+ : ''))
233
+ .description(spec.description);
234
+ for (const field of spec.fields)
235
+ addField(command, field);
236
+ if (spec.path === 'access entry bulk-update')
237
+ command.addHelpText('after', '\nRows: [{accessId, expectedRevision}]. Only update is exposed. field selects identity, personId, role or status; value is a JSON string, or null to clear personId. Each row can fail independently.\n');
238
+ if (spec.path === 'access import merge')
239
+ command.addHelpText('after', '\nRows require proposal_row_id. Optional reviewed corrections: resource_id, resource_name, website, identity, role, person_id, resource_owner_person_id, confirm_person_match, confirm_visual_evidence, expected_revision, access_id. Preserve snake_case. Never infer match/evidence confirmations or merge all rows implicitly. Read the proposal before selecting rows.\n');
240
+ if (spec.path === 'access review decide')
241
+ command.addHelpText('after', '\nItems: [{accessId, expectedRevision, decision, comment?}]. decision is keep or revoke; comment may be null. Revoke records a removal request, not provider revocation. Complete the assigned task separately after all decisions.\n');
242
+ common(command);
243
+ command.action(async (...values) => {
244
+ const options = asRecord(values[values.length - 2]);
245
+ await run(io, options, spec.next, async (write) => {
246
+ const input = await inputFor(spec, values.slice(0, -2), options);
247
+ const ctx = await context(typeof options.apiUrl === 'string' ? options.apiUrl : undefined);
248
+ const tool = await resolveBinding(ctx, segments, operation);
249
+ if (tool.effect === 'write')
250
+ write();
251
+ if (spec.path === 'file stage') {
252
+ if (tool.effect !== 'write' || tool.confirmation !== 'none' || options.yes)
253
+ throw new InputError('Staging requires an immediate write capability and does not accept --yes.');
254
+ const advertised = Number(tool.limits?.maxFileBytes);
255
+ const staged = await stageLocalFile(ctx, String(input.path), String(input.purpose), {
256
+ maxUploadBytes: Number.isSafeInteger(advertised) && advertised > 0
257
+ ? Math.min(advertised, 25 * 1024 * 1024)
258
+ : 25 * 1024 * 1024,
259
+ }, tool, typeof options.idempotencyKey === 'string' ? options.idempotencyKey : undefined);
260
+ return staged.envelope;
261
+ }
262
+ return execute(ctx, tool, input, io, options);
263
+ });
264
+ });
265
+ }
266
+ const onboarding = program
267
+ .command('onboarding')
268
+ .description('Learn organizational context, inspect optional setup and explicitly complete the journey. Operating Context does not block completion.');
269
+ for (const [operation, description] of onboardingOperations)
270
+ onboarding.option(`--${operation}${['get-context', 'set-context', 'init-framework'].includes(operation) ? ' <value>' : ''}`, description);
271
+ onboarding
272
+ .option('--question <id>', 'questionId from the selected schema')
273
+ .option('--value <json>', 'strict JSON answer, including quoted JSON strings')
274
+ .option('--additional-context <text>', 'answer explanation; explanation-only answers are supported');
275
+ common(onboarding);
276
+ onboarding.action(async (options) => {
277
+ await run(io, options, 'onboarding --status', async (write) => {
278
+ const selected = onboardingOperations.filter(([operation]) => options[operation.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())] !== undefined);
279
+ if (selected.length !== 1)
280
+ throw new InputError('Select exactly one onboarding operation; use --help for syntax.');
281
+ const operation = selected[0][0];
282
+ const input = {};
283
+ if (operation === 'get-context' || operation === 'set-context')
284
+ input.store = options[operation === 'get-context' ? 'getContext' : 'setContext'];
285
+ if (operation === 'set-context') {
286
+ if (typeof options.question !== 'string' ||
287
+ (options.value === undefined && options.additionalContext === undefined))
288
+ throw new InputError('Set context requires --question and --value and/or --additional-context.');
289
+ input.questionId = options.question;
290
+ if (options.value !== undefined) {
291
+ if (typeof options.value !== 'string')
292
+ throw new InputError('Expected a JSON answer.');
293
+ input.value = parseJson(options.value);
294
+ }
295
+ if (options.additionalContext !== undefined)
296
+ input.additionalContext = options.additionalContext;
297
+ }
298
+ else if (options.question !== undefined ||
299
+ options.value !== undefined ||
300
+ options.additionalContext !== undefined)
301
+ throw new InputError('Answer options require --set-context.');
302
+ if (operation === 'init-framework')
303
+ input.frameworkCode = options.initFramework;
304
+ const ctx = await context(typeof options.apiUrl === 'string' ? options.apiUrl : undefined);
305
+ const tool = await resolveBinding(ctx, ['onboarding'], operation === 'help-onboarding' ? 'help' : operation);
306
+ if (tool.effect === 'write')
307
+ write();
308
+ return execute(ctx, tool, input, io, options);
309
+ });
310
+ });
311
+ };
@@ -0,0 +1,18 @@
1
+ export type FieldKind = 'text' | 'integer' | 'boolean' | 'repeat' | 'json' | 'file' | 'clear';
2
+ export interface Field {
3
+ flag: string;
4
+ field: string;
5
+ help: string;
6
+ kind: FieldKind;
7
+ required: boolean;
8
+ }
9
+ export interface Capability {
10
+ path: string;
11
+ description: string;
12
+ argument?: string;
13
+ inputAdapter?: 'documentWorkflow';
14
+ fields: Field[];
15
+ next: string;
16
+ }
17
+ export declare const capabilities: Capability[];
18
+ export declare const onboardingOperations: readonly [readonly ["status", "read current journey and separate context readiness"], readonly ["help-onboarding", "read authenticated server help, all options and current status"], readonly ["get-context", "read business/operating schema and answers"], readonly ["set-context", "save one agreed business/operating answer without completing the journey"], readonly ["run-vendor-agent", "start vendor discovery; may create vendors; requires Business Context 100%"], readonly ["run-risk-agent", "start initial risk generation; requires Business Context 100%"], readonly ["init-framework", "initialize a selected framework; may consume quota and notify administrators"], readonly ["complete", "explicitly complete with Business Context 100%; operating/setup remain optional"]];