@ciphrix/cli 0.1.1 → 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.
- package/README.md +46 -3
- package/dist/actions.d.ts +3 -1
- package/dist/actions.js +16 -7
- package/dist/api.d.ts +3 -1
- package/dist/api.js +7 -3
- package/dist/cli.js +20 -5
- package/dist/commands/capabilities.d.ts +6 -0
- package/dist/commands/capabilities.js +288 -0
- package/dist/commands/capabilityDefinitions.d.ts +17 -0
- package/dist/commands/capabilityDefinitions.js +191 -0
- package/dist/commands/control.d.ts +2 -1
- package/dist/commands/control.js +11 -1
- package/dist/commands/framework.js +2 -1
- package/dist/commands/test.d.ts +2 -2
- package/dist/commands/test.js +18 -3
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/manifest.d.ts +19 -0
- package/dist/manifest.js +93 -0
- package/dist/toolSurface.d.ts +0 -8
- package/dist/toolSurface.js +8 -44
- package/dist/upload.d.ts +10 -0
- package/dist/upload.js +31 -11
- package/package.json +1 -1
- package/skills/ciphrix/SKILL.md +26 -1
- package/skills/ciphrix/references/commands.md +1231 -7
package/README.md
CHANGED
|
@@ -72,8 +72,8 @@ changes show the proposed action and ask for confirmation; automation can opt in
|
|
|
72
72
|
- **Manage operational registers** — work with assets, vendors, risks, treatments, ownership, and files.
|
|
73
73
|
- **Operate monitoring** — inspect checks, resources, runs, findings, integrations, and background jobs.
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
Established record commands accept names and stable Ciphrix codes where advertised. Access inventory,
|
|
76
|
+
review tasks and proposals require the exact IDs returned by their read commands; never guess identifiers.
|
|
77
77
|
|
|
78
78
|
## Built for agent workspaces
|
|
79
79
|
|
|
@@ -99,7 +99,9 @@ The skill works with compatible agent harnesses including Codex, Claude Code, Cu
|
|
|
99
99
|
is also included in the npm package at [`skills/ciphrix/SKILL.md`](skills/ciphrix/SKILL.md). Installing
|
|
100
100
|
the npm package never modifies an agent's configuration directories.
|
|
101
101
|
|
|
102
|
-
Run `npx skills update` to update installed skills when a newer version is available.
|
|
102
|
+
Run `npx skills update` to update installed skills when a newer version is available. Keep the skill
|
|
103
|
+
aligned with your installed CLI version; its generated command reference and local help describe the
|
|
104
|
+
available grammar. Server capability discovery remains authoritative for which operations you can use.
|
|
103
105
|
|
|
104
106
|
### Ask in natural language, operate through the CLI
|
|
105
107
|
|
|
@@ -155,6 +157,47 @@ one command. Installed help is authoritative. The complete generated reference i
|
|
|
155
157
|
|
|
156
158
|
</details>
|
|
157
159
|
|
|
160
|
+
### Context, reviews and agent-assisted work
|
|
161
|
+
|
|
162
|
+
The onboarding group reads separate Business and Operating Context, saves agreed questionnaire answers,
|
|
163
|
+
and offers explicit optional setup and completion. Business Context readiness is decided by the server;
|
|
164
|
+
Operating Context and optional setup do not block journey completion. Saving context never automatically
|
|
165
|
+
starts agents or completes the journey. Local `--help` works offline; `onboarding --help-onboarding`
|
|
166
|
+
returns authenticated server guidance and the current status pack.
|
|
167
|
+
|
|
168
|
+
The access group separates inventory, reviewed import proposals, assigned reviewer decisions and campaign
|
|
169
|
+
results. A revoke decision records a removal request, not provider-side revocation. Recording decisions,
|
|
170
|
+
completing a reviewer task, and ending a campaign are separate operations. Imports merge only explicitly
|
|
171
|
+
selected reviewed rows; inspect per-row failures even when some rows applied.
|
|
172
|
+
|
|
173
|
+
Agent findings and advisory test/control assessments do not make formal human decisions. Assigned-task
|
|
174
|
+
discovery does not complete work. File staging and processing are explicit, separate operations; processing
|
|
175
|
+
may update context or create vendors. Evidence/CSV commands return expiring signed references without
|
|
176
|
+
automatically downloading files. Use each command's help for the exact inputs, next reads and effects.
|
|
177
|
+
|
|
178
|
+
### Manifest compatibility, JSON and retries
|
|
179
|
+
|
|
180
|
+
The CLI discovers allowed tools and their effects from the authenticated server manifest. New command
|
|
181
|
+
groups require server-declared command bindings. An unavailable binding is reported as unsupported;
|
|
182
|
+
the CLI never guesses a different endpoint or interprets the manifest as authorization. Existing
|
|
183
|
+
commands keep their successful JSON formats. New capability commands emit complete success/error
|
|
184
|
+
envelopes with `--json`, preserving warnings, pagination, truncation and structured error details.
|
|
185
|
+
Failures and partially applied mutations return a nonzero exit status.
|
|
186
|
+
|
|
187
|
+
Confirmed writes require explicit consent. New commands in JSON/non-interactive mode require `--yes`
|
|
188
|
+
for confirmed operations; immediate writes do not accept that flag. Write controls are rejected for
|
|
189
|
+
reads. The optional `--idempotency-key` names one logical write: reuse identical input for a retry,
|
|
190
|
+
never a new key to bypass an unknown outcome. There are no automatic write retries. Confirmed replay
|
|
191
|
+
also needs a valid confirmation token; a fresh invocation re-preflights and may be blocked by changed
|
|
192
|
+
state or expiry. The CLI does not save confirmation tokens or promise recovery across invocations.
|
|
193
|
+
Inspect current records/jobs when uncertain. Replayed status packs and signed URLs are original
|
|
194
|
+
snapshots, not refreshed state or renewed downloads.
|
|
195
|
+
|
|
196
|
+
Mutation JSON files must be regular files, at most 1 MiB, containing an explicitly selected array.
|
|
197
|
+
Use clear flags or JSON null where supported, not the string `"null"`. Do not infer confirmations
|
|
198
|
+
from suggested matches. New repeated flags collect separate values; existing command conventions remain
|
|
199
|
+
compatible. See the generated command reference for all options and nested payload details.
|
|
200
|
+
|
|
158
201
|
## Safe by default
|
|
159
202
|
|
|
160
203
|
- 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
|
|
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 (
|
|
24
|
+
if (tool.confirmation === 'none')
|
|
20
25
|
return first;
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
.
|
|
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,
|
|
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,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"]];
|