@ciphrix/cli 0.1.0 → 0.2.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,288 @@
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
+ return input;
57
+ };
58
+ const partialFailure = (value, depth = 0) => {
59
+ if (depth > 32 || !value || typeof value !== 'object')
60
+ return false;
61
+ if (Array.isArray(value))
62
+ return value.some((item) => partialFailure(item, depth + 1));
63
+ const record = asRecord(value);
64
+ if (['failures', 'failed', 'unmerged'].some((key) => Array.isArray(record[key]) && record[key].length > 0))
65
+ return true;
66
+ return Object.values(record).some((child) => partialFailure(child, depth + 1));
67
+ };
68
+ const execute = async (ctx, tool, input, io, options) => {
69
+ if (tool.effect === 'read') {
70
+ if (options.yes || options.idempotencyKey !== undefined)
71
+ throw new InputError('Write controls do not apply to a read operation.');
72
+ return callTool(ctx, tool.name, input);
73
+ }
74
+ if (tool.confirmation === 'none' && options.yes)
75
+ throw new InputError('This write applies immediately and does not accept --yes.');
76
+ if (tool.confirmation !== 'none' && !options.yes && (options.json || !io.stdout.isTTY))
77
+ throw new InputError('This confirmed write requires --yes in JSON or non-interactive mode. No change was applied.');
78
+ return applyTool({
79
+ ctx,
80
+ io,
81
+ quiet: options.json === true,
82
+ toolName: tool.name,
83
+ input,
84
+ fallback: 'The operation could not be completed.',
85
+ yes: options.yes === true,
86
+ ...(typeof options.idempotencyKey === 'string' && { idempotencyKey: options.idempotencyKey }),
87
+ confirm: (question) => promptConfirm(io, question),
88
+ });
89
+ };
90
+ const render = (io, json, envelope, next) => {
91
+ if (json)
92
+ writeLine(io.stdout, JSON.stringify(envelope));
93
+ else {
94
+ writeLine(io.stdout, JSON.stringify(envelope.data ?? envelope, null, 2));
95
+ if (Array.isArray(envelope.warnings))
96
+ for (const warning of envelope.warnings)
97
+ writeLine(io.stdout, `warning: ${String(warning)}`);
98
+ if (asRecord(envelope.meta).truncated)
99
+ writeLine(io.stdout, 'Results are truncated; inspect continuation details.');
100
+ if (next)
101
+ writeLine(io.stdout, `next: ciphrix ${next} --help`);
102
+ }
103
+ };
104
+ const run = async (io, options, next, action) => {
105
+ let failed;
106
+ let applying = false;
107
+ try {
108
+ const key = options.idempotencyKey;
109
+ if (key !== undefined && (typeof key !== 'string' || !key.trim() || key.length > 256)) {
110
+ throw new InputError('The idempotency key must contain 1 to 256 characters.');
111
+ }
112
+ const envelope = await action(() => {
113
+ applying = true;
114
+ });
115
+ if (!envelope) {
116
+ if (options.json)
117
+ writeLine(io.stdout, JSON.stringify({ status: 'cancelled' }));
118
+ return;
119
+ }
120
+ render(io, options.json === true, envelope, next);
121
+ failed = envelope.status !== 'ok' || (applying && partialFailure(envelope.data));
122
+ if (failed && !options.json)
123
+ writeLine(io.stderr, 'The operation failed or partially applied. Inspect the returned outcomes before taking further action.');
124
+ }
125
+ catch (error) {
126
+ const envelope = error instanceof ApiError && asRecord(error.envelope).status === 'error'
127
+ ? asRecord(error.envelope)
128
+ : {
129
+ status: 'error',
130
+ error: {
131
+ code: error instanceof InputError || error instanceof FileInputError
132
+ ? 'invalid_input'
133
+ : error instanceof ManifestError
134
+ ? 'unsupported_capability'
135
+ : error instanceof ApiError
136
+ ? (error.code ?? 'http_error')
137
+ : 'transport_error',
138
+ message: error instanceof InputError ||
139
+ error instanceof FileInputError ||
140
+ error instanceof ApiError ||
141
+ error instanceof ManifestError
142
+ ? error.message
143
+ : 'The request could not be completed. Check capabilities and inspect state before retrying a write.',
144
+ ...(error instanceof ApiError && {
145
+ httpStatus: error.status,
146
+ ...(error.details !== undefined && { details: error.details }),
147
+ }),
148
+ },
149
+ };
150
+ render(options.json ? io : { ...io, stdout: io.stderr }, options.json === true, envelope, '');
151
+ failed = true;
152
+ }
153
+ if (failed)
154
+ throw new RenderedCommandError();
155
+ };
156
+ const groupDescriptions = {
157
+ access: 'Access inventory, reviewed imports and assigned reviewer workflows',
158
+ 'access resource': 'Resources, owners and asset relationships',
159
+ 'access entry': 'Revisioned access inventory; inventory edits are not review decisions',
160
+ 'access import': 'Your import proposals, selected-row merges and explicit discard',
161
+ 'access review': 'Review campaigns, assigned decisions, completion and frozen outcomes',
162
+ agent: 'Agent activity, complete proposals and explicit human decisions',
163
+ 'agent run': 'Grouped agent activity; findings are not completed assessments',
164
+ 'agent action': 'Findings, reviewed proposals and declared approve/reject/choose options',
165
+ task: 'Your assigned stored work; discovery never completes tasks',
166
+ file: 'Owned local-file staging and explicit processing; no automatic downloads',
167
+ };
168
+ const parent = (program, path) => {
169
+ let current = program;
170
+ const traversed = [];
171
+ for (const segment of path) {
172
+ traversed.push(segment);
173
+ current =
174
+ current.commands.find((command) => command.name() === segment) ??
175
+ current
176
+ .command(segment)
177
+ .description(groupDescriptions[traversed.join(' ')] ?? `${segment} capabilities`);
178
+ }
179
+ return current;
180
+ };
181
+ const common = (command) => {
182
+ command
183
+ .option('--api-url <url>', 'override the API base URL')
184
+ .option('--json', 'emit one complete result/error envelope')
185
+ .option('--yes', 'confirmed writes only: explicitly authorize without prompting; rejected on reads/immediate writes')
186
+ .option('--idempotency-key <key>', 'writes only: key for one logical operation; reuse identical input, at most 256 characters')
187
+ .addHelpText('after', `\n${retryHelp}\n`);
188
+ };
189
+ const addField = (command, field) => {
190
+ const flag = `--${field.flag}${['boolean', 'clear'].includes(field.kind) ? '' : ' <value>'}`;
191
+ const option = new Option(flag, field.help);
192
+ if (field.required)
193
+ option.makeOptionMandatory();
194
+ if (field.kind === 'repeat')
195
+ option.argParser((value, previous = []) => [...previous, value]);
196
+ command.addOption(option);
197
+ };
198
+ export const registerCapabilities = (program, io, context = resolveToolContext) => {
199
+ for (const spec of capabilities) {
200
+ const segments = spec.path.split(' ');
201
+ const operation = segments.pop();
202
+ const command = parent(program, segments)
203
+ .command(operation +
204
+ (spec.argument
205
+ ? ` ${spec.argument
206
+ .split(' ')
207
+ .map((name) => `<${name}>`)
208
+ .join(' ')}`
209
+ : ''))
210
+ .description(spec.description);
211
+ for (const field of spec.fields)
212
+ addField(command, field);
213
+ if (spec.path === 'access entry bulk-update')
214
+ 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');
215
+ if (spec.path === 'access import merge')
216
+ 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');
217
+ if (spec.path === 'access review decide')
218
+ 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');
219
+ common(command);
220
+ command.action(async (...values) => {
221
+ const options = asRecord(values[values.length - 2]);
222
+ await run(io, options, spec.next, async (write) => {
223
+ const input = await inputFor(spec, values.slice(0, -2), options);
224
+ const ctx = await context(typeof options.apiUrl === 'string' ? options.apiUrl : undefined);
225
+ const tool = await resolveBinding(ctx, segments, operation);
226
+ if (tool.effect === 'write')
227
+ write();
228
+ if (spec.path === 'file stage') {
229
+ if (tool.effect !== 'write' || tool.confirmation !== 'none' || options.yes)
230
+ throw new InputError('Staging requires an immediate write capability and does not accept --yes.');
231
+ const advertised = Number(tool.limits?.maxFileBytes);
232
+ const staged = await stageLocalFile(ctx, String(input.path), String(input.purpose), {
233
+ maxUploadBytes: Number.isSafeInteger(advertised) && advertised > 0
234
+ ? Math.min(advertised, 25 * 1024 * 1024)
235
+ : 25 * 1024 * 1024,
236
+ }, tool, typeof options.idempotencyKey === 'string' ? options.idempotencyKey : undefined);
237
+ return staged.envelope;
238
+ }
239
+ return execute(ctx, tool, input, io, options);
240
+ });
241
+ });
242
+ }
243
+ const onboarding = program
244
+ .command('onboarding')
245
+ .description('Learn organizational context, inspect optional setup and explicitly complete the journey. Operating Context does not block completion.');
246
+ for (const [operation, description] of onboardingOperations)
247
+ onboarding.option(`--${operation}${['get-context', 'set-context', 'init-framework'].includes(operation) ? ' <value>' : ''}`, description);
248
+ onboarding
249
+ .option('--question <id>', 'questionId from the selected schema')
250
+ .option('--value <json>', 'strict JSON answer, including quoted JSON strings')
251
+ .option('--additional-context <text>', 'answer explanation; explanation-only answers are supported');
252
+ common(onboarding);
253
+ onboarding.action(async (options) => {
254
+ await run(io, options, 'onboarding --status', async (write) => {
255
+ const selected = onboardingOperations.filter(([operation]) => options[operation.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())] !== undefined);
256
+ if (selected.length !== 1)
257
+ throw new InputError('Select exactly one onboarding operation; use --help for syntax.');
258
+ const operation = selected[0][0];
259
+ const input = {};
260
+ if (operation === 'get-context' || operation === 'set-context')
261
+ input.store = options[operation === 'get-context' ? 'getContext' : 'setContext'];
262
+ if (operation === 'set-context') {
263
+ if (typeof options.question !== 'string' ||
264
+ (options.value === undefined && options.additionalContext === undefined))
265
+ throw new InputError('Set context requires --question and --value and/or --additional-context.');
266
+ input.questionId = options.question;
267
+ if (options.value !== undefined) {
268
+ if (typeof options.value !== 'string')
269
+ throw new InputError('Expected a JSON answer.');
270
+ input.value = parseJson(options.value);
271
+ }
272
+ if (options.additionalContext !== undefined)
273
+ input.additionalContext = options.additionalContext;
274
+ }
275
+ else if (options.question !== undefined ||
276
+ options.value !== undefined ||
277
+ options.additionalContext !== undefined)
278
+ throw new InputError('Answer options require --set-context.');
279
+ if (operation === 'init-framework')
280
+ input.frameworkCode = options.initFramework;
281
+ const ctx = await context(typeof options.apiUrl === 'string' ? options.apiUrl : undefined);
282
+ const tool = await resolveBinding(ctx, ['onboarding'], operation === 'help-onboarding' ? 'help' : operation);
283
+ if (tool.effect === 'write')
284
+ write();
285
+ return execute(ctx, tool, input, io, options);
286
+ });
287
+ });
288
+ };
@@ -0,0 +1,17 @@
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
+ fields: Field[];
14
+ next: string;
15
+ }
16
+ export declare const capabilities: Capability[];
17
+ 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"]];
@@ -0,0 +1,191 @@
1
+ const help = {
2
+ page: 'page number starting at 1',
3
+ limit: 'page size; server limits apply',
4
+ search: 'search visible records',
5
+ 'sort-by': 'server-supported sort field',
6
+ 'sort-order': 'sort direction: asc or desc',
7
+ status: 'inventory status: active or inactive; review/agent operations use their own status vocabulary',
8
+ name: 'record name',
9
+ website: 'resource website',
10
+ 'owner-person': 'existing People ID for the resource owner',
11
+ 'include-empty': 'include resources with no access entries',
12
+ 'expected-updated-at': 'exact updatedAt timestamp from the latest read',
13
+ 'clear-website': 'clear the website; conflicts with --website',
14
+ 'clear-owner': 'clear the owner; conflicts with --owner-person',
15
+ resource: 'existing resource ID; repeat for multiple selections where supported',
16
+ person: 'existing People ID for the access identity',
17
+ 'review-state': 'inventory: needs_review, pending, keep, revoke or inactive; reviewer task: pending or all',
18
+ 'reviewed-before': 'ISO review-history cutoff',
19
+ identity: 'reported account identity; does not change authentication',
20
+ role: 'inventory access role; does not change product authorization',
21
+ 'clear-person': 'leave the identity unmatched; conflicts with --person',
22
+ 'expected-revision': 'current revision from the latest entry or reviewer-task read',
23
+ 'rows-file': 'JSON array of explicitly selected rows; regular file, at most 1 MiB',
24
+ field: 'single inventory field to update on every selected row',
25
+ value: 'strict JSON value; quote strings as JSON and use null only where supported',
26
+ action: 'explicit link or unlink action',
27
+ upload: 'ready upload ID owned by the caller; repeat to select multiple files',
28
+ cursor: 'continuation cursor returned by the preceding page',
29
+ filter: 'proposal-row filter: all, new, unchanged, update or conflict',
30
+ 'ids-only': 'return proposal-row IDs instead of full details',
31
+ 'due-at': 'ISO due date/time; review creation defaults to 14 days if omitted',
32
+ 'items-file': 'JSON array of accessId, expectedRevision, decision and optional comment; at most 1 MiB',
33
+ outcome: 'frozen outcome filter: keep, revoke, pending, unresolved or inactive',
34
+ agent: 'agent filter; repeat for multiple values',
35
+ 'action-type': 'action-type filter; repeat for multiple values',
36
+ 'initiator-type': 'initiator-type filter; repeat for multiple values',
37
+ from: 'ISO lower date/time bound',
38
+ to: 'ISO upper date/time bound',
39
+ offset: 'number of grouped results to skip',
40
+ job: 'existing background job ID',
41
+ 'review-only': 'read the current human-review queue',
42
+ decision: 'explicit human decision: approve, reject or choose',
43
+ option: 'exact declared option for choose only; no delete options',
44
+ reason: 'human explanation for the decision',
45
+ state: 'active or history',
46
+ 'task-type': 'active-task filter: all, todo, assignment or action; omit with history',
47
+ purpose: 'staging purpose advertised by the server; does not process the file',
48
+ attachment: 'evidence attachment ID belonging to the selected test run',
49
+ run: 'existing run ID; omit to use the current run',
50
+ };
51
+ const field = (flag, target = flag.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()), kind = 'text', required = false, description = help[flag] ?? `value for ${flag}`) => ({ flag, field: target, kind, required, help: description });
52
+ const page = () => [field('page', 'page', 'integer'), field('limit', 'limit', 'integer')];
53
+ const sort = () => [field('sort-by'), field('sort-order')];
54
+ const spec = (path, description, argument, fields, next) => ({ path, description, ...(argument && { argument }), fields, next });
55
+ const filters = () => [
56
+ field('agent', 'agent', 'repeat'),
57
+ field('action-type', 'actionType', 'repeat'),
58
+ field('status', 'status', 'repeat', false, 'status filter; repeat for multiple values'),
59
+ field('initiator-type', 'initiatorType', 'repeat'),
60
+ field('from'),
61
+ field('to'),
62
+ field('search', 'q'),
63
+ field('limit', 'limit', 'integer'),
64
+ ];
65
+ export const capabilities = [
66
+ spec('access resource list', 'List review inventory resources and owners.', undefined, [
67
+ ...page(),
68
+ field('search'),
69
+ ...sort(),
70
+ field('status'),
71
+ field('owner-person', 'ownerPersonId'),
72
+ field('include-empty', 'includeEmpty', 'boolean'),
73
+ ], 'access resource get'),
74
+ spec('access resource get', 'Read a resource and its current inventory context.', 'resourceId', [], 'access entry list'),
75
+ spec('access resource create', 'Create manual inventory; reconcile active reviews.', undefined, [field('name', 'name', 'text', true), field('website'), field('owner-person', 'ownerPersonId')], 'access resource get'),
76
+ spec('access resource update', 'Update inventory. Inactivation also deactivates child access.', 'resourceId', [
77
+ field('expected-updated-at'),
78
+ field('name'),
79
+ field('website'),
80
+ field('clear-website', 'website', 'clear'),
81
+ field('owner-person', 'ownerPersonId'),
82
+ field('clear-owner', 'ownerPersonId', 'clear'),
83
+ field('status'),
84
+ ], 'access resource get'),
85
+ spec('access resource link-asset', 'Link or unlink an existing asset; reconcile reviews.', 'resourceId assetId', [field('action', 'action', 'text', true)], 'access resource get'),
86
+ spec('access resource bulk-update', 'Update selected resource owners/statuses; inspect per-row failures.', undefined, [
87
+ field('resource', 'resourceIds', 'repeat', true),
88
+ field('owner-person', 'ownerPersonId'),
89
+ field('clear-owner', 'ownerPersonId', 'clear'),
90
+ field('status'),
91
+ ], 'access resource list'),
92
+ spec('access entry list', 'List current access inventory; no review decisions are recorded.', undefined, [
93
+ ...page(),
94
+ field('search'),
95
+ ...sort(),
96
+ field('resource', 'resourceId'),
97
+ field('person', 'personId'),
98
+ field('owner-person', 'ownerPersonId'),
99
+ field('status'),
100
+ field('review-state'),
101
+ field('reviewed-before'),
102
+ ], 'access entry get'),
103
+ spec('access entry get', 'Read current inventory revision and prior review outcome.', 'accessId', [], 'access entry update'),
104
+ spec('access entry create', 'Create manual access inventory, not a review decision.', undefined, [
105
+ field('resource', 'resourceId', 'text', true),
106
+ field('identity', 'identity', 'text', true),
107
+ field('role', 'role', 'text', true),
108
+ field('person', 'personId'),
109
+ field('status'),
110
+ ], 'access entry get'),
111
+ spec('access entry update', 'Update revisioned inventory; integration-owned fields stay locked.', 'accessId', [
112
+ field('expected-revision', 'expectedRevision', 'integer', true),
113
+ field('identity'),
114
+ field('role'),
115
+ field('person', 'personId'),
116
+ field('clear-person', 'personId', 'clear'),
117
+ field('status'),
118
+ ], 'access entry get'),
119
+ spec('access entry bulk-update', 'Update one field on selected revisioned entries; inspect partial failures.', undefined, [
120
+ field('rows-file', 'rows', 'file', true),
121
+ field('field', 'field', 'text', true),
122
+ field('value', 'value', 'json', true),
123
+ ], 'access entry list'),
124
+ spec('access import start', 'Extract a proposal from owned register_import uploads; does not merge.', undefined, [field('upload', 'uploadIds', 'repeat', true)], 'access import get'),
125
+ spec('access import list', 'List your unresolved imports using cursor pagination.', undefined, [field('cursor'), field('limit', 'limit', 'integer')], 'access import get'),
126
+ spec('access import get', 'Inspect proposal rows, matches, evidence and revisions before merging.', 'importId', [
127
+ field('cursor'),
128
+ field('limit', 'limit', 'integer'),
129
+ field('filter'),
130
+ field('search'),
131
+ field('ids-only', 'idsOnly', 'boolean'),
132
+ ], 'access import merge'),
133
+ spec('access import merge', 'Merge only reviewed selected rows; preserve corrections and explicit confirmations.', 'importId', [field('rows-file', 'rows', 'file', true)], 'access import get'),
134
+ spec('access import discard', 'Stop extraction/merge; retain successful merges and independent enrichment.', 'importId', [], 'access import list'),
135
+ spec('access review preview', 'Preview selected eligible owners and unresolved entries without creating work.', undefined, [field('resource', 'resourceIds', 'repeat', true), field('due-at')], 'access review start'),
136
+ spec('access review start', 'Create a review, reviewer tasks and assignment notifications.', undefined, [
137
+ field('name', 'name', 'text', true),
138
+ field('resource', 'resourceIds', 'repeat', true),
139
+ field('due-at'),
140
+ ], 'access review list'),
141
+ spec('access review list', 'Read active progress or closed/cancelled history.', undefined, [
142
+ field('status', 'status', 'text', false, 'review filter: active, closed, cancelled or history'),
143
+ ...page(),
144
+ ], 'access review task'),
145
+ spec('access review task', 'Read your assigned reviewer task, current revisions and decisions.', 'taskId', [...page(), field('search'), field('review-state')], 'access review decide'),
146
+ spec('access review decide', 'Record explicit keep/revoke decisions. Revoke is a removal request, not provider revocation.', 'taskId', [field('items-file', 'items', 'file', true)], 'access review task'),
147
+ spec('access review complete', 'Complete your assigned task after all decisions; may close a fully reviewed campaign.', 'taskId', [], 'access review list'),
148
+ spec('access review result', 'Read frozen outcomes; pending/unresolved never imply approval.', 'reviewId', [...page(), field('search'), field('resource', 'resourceId'), field('outcome')], 'access review export'),
149
+ spec('access review end', 'Explicitly close with current outcomes or cancel without a result; close reviewer tasks.', 'reviewId', [
150
+ field('status', 'status', 'text', true, 'closed freezes outcomes; cancelled creates no result'),
151
+ ], 'access review result'),
152
+ spec('access review export', 'Create a temporary signed CSV reference; no automatic download.', 'reviewId', [], 'access review result'),
153
+ spec('agent run list', 'List grouped agent activity; counts are not completed assessments.', undefined, [...filters(), field('offset', 'offset', 'integer')], 'agent action list'),
154
+ spec('agent action list', 'Inspect agent findings or current review work; preserve truncation and cursors.', undefined, [
155
+ ...filters(),
156
+ field('job', 'aiTaskId'),
157
+ field('cursor'),
158
+ field('review-only', 'reviewOnly', 'boolean'),
159
+ ], 'agent action get'),
160
+ spec('agent action get', 'Read a proposal and its declared options before deciding.', 'agentActionId', [], 'agent action decide'),
161
+ spec('agent action decide', 'Apply an explicit reviewed decision; unsupported, delete and truncated approvals are refused.', 'agentActionId', [
162
+ field('decision', 'decision', 'text', true),
163
+ field('expected-updated-at', 'expectedUpdatedAt', 'text', true),
164
+ field('option'),
165
+ field('reason'),
166
+ ], 'agent action get'),
167
+ spec('task list', 'Read your assigned stored tasks; does not refresh assignments or complete work.', undefined, [field('state'), field('task-type'), ...page()], 'task get'),
168
+ spec('task get', 'Read your task and its supported next capability; unsupported actions remain in the app.', 'taskId', [], 'access review task'),
169
+ spec('file stage', 'Stage a local regular file as your current identity; at most 25 MiB, no automatic processing.', 'path', [field('purpose', 'purpose', 'text', true)], 'file process'),
170
+ spec('file process', 'Process owned documents-purpose uploads. May update context or create vendors; starts background work.', undefined, [field('upload', 'uploadIds', 'repeat', true)], 'job status'),
171
+ spec('test run file', 'Create a temporary reference for evidence linked to this test/run; no automatic download.', 'name', [field('attachment', 'attachmentId', 'text', true), field('run', 'runId')], 'test items'),
172
+ spec('test run assess', 'Request advisory AI assessment; does not change the human run result.', 'name', [field('run', 'runId')], 'test get'),
173
+ spec('control assess', 'Request/reuse advisory assessment. A cached result may have no job ID.', 'name', [], 'control assessment'),
174
+ spec('control assessment', 'Read advisory assessment/progress; absence means not assessed, not compliant.', 'name', [], 'control get'),
175
+ ];
176
+ export const onboardingOperations = [
177
+ ['status', 'read current journey and separate context readiness'],
178
+ ['help-onboarding', 'read authenticated server help, all options and current status'],
179
+ ['get-context', 'read business/operating schema and answers'],
180
+ ['set-context', 'save one agreed business/operating answer without completing the journey'],
181
+ [
182
+ 'run-vendor-agent',
183
+ 'start vendor discovery; may create vendors; requires Business Context 100%',
184
+ ],
185
+ ['run-risk-agent', 'start initial risk generation; requires Business Context 100%'],
186
+ [
187
+ 'init-framework',
188
+ 'initialize a selected framework; may consume quota and notify administrators',
189
+ ],
190
+ ['complete', 'explicitly complete with Business Context 100%; operating/setup remain optional'],
191
+ ];
@@ -68,15 +68,17 @@ export const contextSetCommand = async ({ io, apiUrl, json = false, target, valu
68
68
  if (!questionId)
69
69
  throw new Error('A question id is required, for example business.company_size');
70
70
  const ctx = await resolveToolContext(apiUrl, credentialStore);
71
- const envelope = await callTool(ctx, 'set_context_answer', {
72
- store,
73
- questionId,
74
- value: parseValue(value),
71
+ const result = await applyTool({
72
+ ctx,
73
+ io,
74
+ toolName: 'set_context_answer',
75
+ input: { store, questionId, value: parseValue(value) },
76
+ fallback: 'Could not save the answer.',
75
77
  });
76
- if (envelope.status !== 'ok')
77
- throw new Error(errorMessage(envelope, 'Could not save the answer.'));
78
+ if (!result)
79
+ return;
78
80
  if (json) {
79
- writeLine(io.stdout, JSON.stringify(envelope.data, null, 2));
81
+ writeLine(io.stdout, JSON.stringify(result.data, null, 2));
80
82
  return;
81
83
  }
82
84
  writeLine(io.stdout, `${io.theme.green('✓')} ${store}.${questionId} saved`);
@@ -9,11 +9,13 @@ export interface ControlCommonOptions {
9
9
  export interface ControlReadOptions extends ControlCommonOptions {
10
10
  name: string;
11
11
  }
12
- export declare const controlListCommand: ({ io, apiUrl, json, credentialStore, search, domain, status, applicability, page, limit, }: ControlCommonOptions & {
12
+ export declare const controlListCommand: ({ io, apiUrl, json, credentialStore, search, framework, domain, status, applicability, ownerId, page, limit, }: ControlCommonOptions & {
13
13
  search?: string | undefined;
14
+ framework?: string[] | undefined;
14
15
  domain?: string | undefined;
15
16
  status?: string | undefined;
16
17
  applicability?: string | undefined;
18
+ ownerId?: string | undefined;
17
19
  page?: number | undefined;
18
20
  limit?: number | undefined;
19
21
  }) => Promise<void>;
@@ -2,11 +2,13 @@ import { applyTool } from '../actions.js';
2
2
  import { writeLine, writeNextHint, writePageFooter, writeTable } from '../io.js';
3
3
  import { asRecord, asText, callTool, errorMessage, resolveToolContext } from '../toolSurface.js';
4
4
  const text = (value, fallback = '-') => typeof value === 'string' ? value : typeof value === 'number' ? String(value) : fallback;
5
- export const controlListCommand = async ({ io, apiUrl, json = false, credentialStore, search, domain, status, applicability, page, limit, }) => {
5
+ export const controlListCommand = async ({ io, apiUrl, json = false, credentialStore, search, framework, domain, status, applicability, ownerId, page, limit, }) => {
6
6
  const ctx = await resolveToolContext(apiUrl, credentialStore);
7
7
  const input = {};
8
8
  if (search)
9
9
  input.search = search;
10
+ if (framework !== undefined)
11
+ input.framework = framework;
10
12
  if (domain)
11
13
  input.domain = [domain];
12
14
  if (status)
@@ -16,6 +18,11 @@ export const controlListCommand = async ({ io, apiUrl, json = false, credentialS
16
18
  .filter(Boolean);
17
19
  if (applicability)
18
20
  input.applicability = [applicability];
21
+ if (ownerId)
22
+ input.ownerId = ownerId
23
+ .split(',')
24
+ .map((value) => value.trim())
25
+ .filter(Boolean);
19
26
  if (page)
20
27
  input.page = page;
21
28
  if (limit)
@@ -81,6 +88,14 @@ export const controlGetCommand = async ({ io, name, apiUrl, json = false, creden
81
88
  continue;
82
89
  writeLine(io.stdout, ` ${io.theme.dim(`${label}:`)} ${text(value)}`);
83
90
  }
91
+ if (Array.isArray(control.complianceMap) && control.complianceMap.length > 0) {
92
+ writeLine(io.stdout, 'Catalogue framework relationships (not a compliance assessment):');
93
+ writeLine(io.stdout, JSON.stringify(control.complianceMap, null, 2));
94
+ }
95
+ if (control.items !== undefined) {
96
+ writeLine(io.stdout, 'Linked items:');
97
+ writeLine(io.stdout, JSON.stringify(control.items, null, 2));
98
+ }
84
99
  if (typeof control.notes === 'string' && control.notes.trim() !== '') {
85
100
  writeLine(io.stdout);
86
101
  writeLine(io.stdout, control.notes);
@@ -145,6 +160,16 @@ export const controlAvailableItemsCommand = async ({ io, name, itemType, apiUrl,
145
160
  const record = asRecord(item);
146
161
  return [text(record.name, text(record.id)), text(record.status)];
147
162
  }));
163
+ const total = typeof data.total === 'number' ? data.total : items.length;
164
+ const currentPage = typeof data.page === 'number' ? data.page : 1;
165
+ const pageSize = typeof data.limit === 'number' ? data.limit : 25;
166
+ writePageFooter(io, {
167
+ shown: items.length,
168
+ total,
169
+ page: currentPage,
170
+ limit: pageSize,
171
+ hasMore: data.hasMore === true || currentPage * pageSize < total,
172
+ });
148
173
  };
149
174
  export const controlUpdateCommand = async ({ io, name, apiUrl, json = false, yes = false, confirm, credentialStore, changes, }) => {
150
175
  if (Object.keys(changes).length === 0) {
@@ -24,9 +24,11 @@ export declare const frameworkClausesCommand: ({ io, framework, apiUrl, json, cr
24
24
  }) => Promise<void>;
25
25
  export declare const clauseGetCommand: ({ io, framework, clause, apiUrl, json, credentialStore, }: ClauseTargetOptions) => Promise<void>;
26
26
  export declare const clauseItemsCommand: ({ io, framework, clause, apiUrl, json, credentialStore, }: ClauseTargetOptions) => Promise<void>;
27
- export declare const clauseAvailableItemsCommand: ({ io, framework, clause, itemType, apiUrl, json, credentialStore, search, }: ClauseTargetOptions & {
27
+ export declare const clauseAvailableItemsCommand: ({ io, framework, clause, itemType, apiUrl, json, credentialStore, search, page, limit, }: ClauseTargetOptions & {
28
28
  itemType: string;
29
29
  search?: string | undefined;
30
+ page?: number | undefined;
31
+ limit?: number | undefined;
30
32
  }) => Promise<void>;
31
33
  export declare const clauseUpdateCommand: ({ io, framework, clause, changes, apiUrl, json, yes, credentialStore, }: ClauseTargetOptions & {
32
34
  changes: Record<string, unknown>;