@crediolabs/policy-builder-cli 0.1.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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ // apps/policy-builder-cli/bin/policy-builder.ts - subcommand router.
3
+ //
4
+ // Usage:
5
+ // policy-builder record --network <mainnet|testnet> --hash <tx>
6
+ // [--xdr <b64>] [--json] [--quiet] [--out <path>]
7
+ // policy-builder synthesize --mandate <path.json>
8
+ // [--json] [--quiet] [--out <path>]
9
+ // policy-builder synthesize --recorded-tx <path.json> --network <mainnet|testnet>
10
+ // [--responses <path.json>]
11
+ // [--json] [--quiet] [--out <path>]
12
+ //
13
+ // The router is hand-rolled (no commander / yargs dep). It splits argv into
14
+ // `command + subcommand-flags + global-flags` and dispatches to the matching
15
+ // command body. Bad args -> CliError -> non-zero exit (JSON envelope under
16
+ // --json).
17
+ import { runRecordCommand } from "../src/commands/record.js";
18
+ import { runSynthesizeCommand } from "../src/commands/synthesize.js";
19
+ import { emitCliError, parseFlags } from "../src/output.js";
20
+ async function main() {
21
+ const argv = process.argv.slice(2);
22
+ const command = argv[0];
23
+ const subcommandArgs = argv.slice(1);
24
+ // Pull out --json/--quiet/--out first so they survive any position.
25
+ // We re-parse the subcommand args below so command bodies can use
26
+ // parsePairs cleanly (those strip the leading -- prefix).
27
+ const flags = parseFlags(subcommandArgs);
28
+ try {
29
+ switch (command) {
30
+ case 'record':
31
+ await runRecordCommand(subcommandArgs, flags);
32
+ return;
33
+ case 'synthesize':
34
+ case 'synth': {
35
+ await runSynthesizeCommand(subcommandArgs, flags);
36
+ return;
37
+ }
38
+ case 'help':
39
+ case '--help':
40
+ case '-h':
41
+ case undefined:
42
+ printHelp();
43
+ return;
44
+ default:
45
+ process.stderr.write(`unknown command: ${command}\n`);
46
+ printHelp();
47
+ process.exit(2);
48
+ }
49
+ }
50
+ catch (e) {
51
+ emitCliError(e, flags);
52
+ }
53
+ }
54
+ function printHelp() {
55
+ process.stdout.write(`policy-builder - OZ policy-synth CLI
56
+
57
+ Usage:
58
+ policy-builder record --network <mainnet|testnet> --hash <tx> | --xdr <b64>
59
+ [--json] [--quiet] [--out <path>]
60
+ policy-builder synthesize --mandate <path.json>
61
+ [--json] [--quiet] [--out <path>]
62
+ policy-builder synthesize --recorded-tx <path.json> --network <mainnet|testnet>
63
+ [--responses <path.json>]
64
+ [--json] [--quiet] [--out <path>]
65
+
66
+ Flags:
67
+ --json emit machine-readable JSON on stdout
68
+ --quiet suppress progress / non-error output
69
+ --out write artefact to file (JSON)
70
+ `);
71
+ }
72
+ await main();
@@ -0,0 +1,3 @@
1
+ import type { RecordedTransaction } from '@crediolabs/policy-synth';
2
+ import { type CliFlags } from '../output.ts';
3
+ export declare function runRecordCommand(argv: ReadonlyArray<string>, flags: CliFlags): Promise<RecordedTransaction>;
@@ -0,0 +1,42 @@
1
+ // apps/policy-builder-cli/src/commands/record.ts
2
+ //
3
+ // `policy-builder record` subcommand. Thin wrapper around the core
4
+ // `recordTransaction` - no business logic, just argv -> RecordInput + the
5
+ // canonical CLI envelope.
6
+ import { runRecordTransaction } from '@crediolabs/policy-builder-mcp';
7
+ import { CliError, formatToolResponse, parsePairs } from "../output.js";
8
+ export async function runRecordCommand(argv, flags) {
9
+ const pairs = parsePairs(argv);
10
+ const network = pairs.network;
11
+ if (!network) {
12
+ throw new CliError({
13
+ code: 'CLI_MISSING_ARG',
14
+ message: 'record: --network <mainnet|testnet> is required',
15
+ severity: 'error',
16
+ retryable: false,
17
+ });
18
+ }
19
+ if (!pairs.hash && !pairs.xdr) {
20
+ throw new CliError({
21
+ code: 'CLI_MISSING_ARG',
22
+ message: 'record: exactly one of --hash <tx> or --xdr <b64> is required',
23
+ severity: 'error',
24
+ retryable: false,
25
+ });
26
+ }
27
+ if (pairs.hash && pairs.xdr) {
28
+ throw new CliError({
29
+ code: 'CLI_MISSING_ARG',
30
+ message: 'record: provide exactly one of --hash or --xdr, not both',
31
+ severity: 'error',
32
+ retryable: false,
33
+ });
34
+ }
35
+ const args = { network };
36
+ if (pairs.hash)
37
+ args.hash = pairs.hash;
38
+ if (pairs.xdr)
39
+ args.xdr = pairs.xdr;
40
+ const res = await runRecordTransaction(args);
41
+ return formatToolResponse(res, flags, 'record');
42
+ }
@@ -0,0 +1,3 @@
1
+ import type { ProposedPolicy } from '@crediolabs/policy-synth';
2
+ import { type CliFlags } from '../output.ts';
3
+ export declare function runSynthesizeCommand(argv: ReadonlyArray<string>, flags: CliFlags): Promise<ProposedPolicy>;
@@ -0,0 +1,46 @@
1
+ // apps/policy-builder-cli/src/commands/synthesize.ts
2
+ //
3
+ // `policy-builder synthesize` subcommand. Dispatches to ONE of the two
4
+ // front-ends (mandate / recording) based on which file flag is supplied:
5
+ // --mandate <path.json> -> synthesizeFromMandate
6
+ // --recorded-tx <path.json> -> synthesizeFromRecording
7
+ //
8
+ // The CLI mirrors the MCP tool's discriminated union: one subcommand, two
9
+ // front-ends, mutually exclusive.
10
+ import { runSynthesizePolicy } from '@crediolabs/policy-builder-mcp';
11
+ import { CliError, formatToolResponse, parsePairs, readJsonFile } from "../output.js";
12
+ export async function runSynthesizeCommand(argv, flags) {
13
+ const pairs = parsePairs(argv);
14
+ const hasMandate = Boolean(pairs.mandate);
15
+ const hasRecorded = Boolean(pairs['recorded-tx']);
16
+ if (hasMandate === hasRecorded) {
17
+ throw new CliError({
18
+ code: 'CLI_MISSING_ARG',
19
+ message: 'synthesize: exactly one of --mandate <path> or --recorded-tx <path> is required',
20
+ severity: 'error',
21
+ retryable: false,
22
+ });
23
+ }
24
+ if (hasMandate) {
25
+ const mandate = readJsonFile(pairs.mandate);
26
+ const res = await runSynthesizePolicy({ source: 'mandate', mandate });
27
+ return formatToolResponse(res, flags, 'synthesize(mandate)');
28
+ }
29
+ // hasRecorded
30
+ const recordedTx = readJsonFile(pairs['recorded-tx']);
31
+ const network = pairs.network;
32
+ if (!network) {
33
+ throw new CliError({
34
+ code: 'CLI_MISSING_ARG',
35
+ message: 'synthesize: --network <mainnet|testnet> is required with --recorded-tx',
36
+ severity: 'error',
37
+ retryable: false,
38
+ });
39
+ }
40
+ const args = { source: 'recording', recordedTx, network };
41
+ if (pairs.responses) {
42
+ args.userResponses = readJsonFile(pairs.responses);
43
+ }
44
+ const res = await runSynthesizePolicy(args);
45
+ return formatToolResponse(res, flags, 'synthesize(recording)');
46
+ }
@@ -0,0 +1,3 @@
1
+ export { runRecordCommand } from './commands/record.ts';
2
+ export { runSynthesizeCommand } from './commands/synthesize.ts';
3
+ export { formatToolResponse, readJsonFile, writeJsonFile } from './output.ts';
@@ -0,0 +1,4 @@
1
+ // apps/policy-builder-cli/src/index.ts - public re-exports for the CLI package.
2
+ export { runRecordCommand } from "./commands/record.js";
3
+ export { runSynthesizeCommand } from "./commands/synthesize.js";
4
+ export { formatToolResponse, readJsonFile, writeJsonFile } from "./output.js";
@@ -0,0 +1,49 @@
1
+ import type { ErrorCode, ToolError, ToolResponse } from '@crediolabs/policy-synth';
2
+ /** CLI flags (the only ones shipped in T1). */
3
+ export interface CliFlags {
4
+ json: boolean;
5
+ quiet: boolean;
6
+ out: string | null;
7
+ }
8
+ /** Parse the argv tail for the known flags. Unrecognised flags are ignored
9
+ * (the caller enforces per-subcommand required flags separately). */
10
+ export declare function parseFlags(argv: ReadonlyArray<string>): CliFlags;
11
+ /** Resolve `--value <v>` style pairs after the subcommand name. Returns
12
+ * an object keyed by the option name (without `--`). Throws on missing
13
+ * value or duplicate keys. */
14
+ export declare function parsePairs(argv: ReadonlyArray<string>): Record<string, string>;
15
+ /** Read a JSON file, parse it, and return the value. Throws with a CLI-
16
+ * friendly error if the file is missing or malformed. */
17
+ export declare function readJsonFile(path: string): unknown;
18
+ /** Write a JSON-serialisable value to disk. Pretty-prints by default so the
19
+ * artefact is human-readable; CI scripts that need compact JSON can pipe
20
+ * through `jq` instead. */
21
+ export declare function writeJsonFile(path: string, value: unknown): void;
22
+ /** Wraps a core ToolResponse for the CLI:
23
+ * - always prints something useful (JSON or a short summary)
24
+ * - exits non-zero on ToolError so CI scripts can gate on `$?`
25
+ * - writes the `{ ok, data }` envelope to --out when present (matches
26
+ * the --json stdout shape so CI scripts get a single canonical payload)
27
+ *
28
+ * Throws CliError so the router can map it to a process exit code + a
29
+ * structured JSON envelope under --json. */
30
+ export declare function formatToolResponse<T>(res: ToolResponse<T>, flags: CliFlags, outLabel?: string): T;
31
+ /** CLI-specific error codes, distinct from the core's ErrorCode union. They
32
+ * cover failures that arise around the core call (bad argv, missing or
33
+ * malformed input files) and never collide with a core code. */
34
+ export type CliErrorCode = 'CLI_MISSING_ARG' | 'CLI_FILE_NOT_FOUND' | 'CLI_INVALID_JSON' | 'CLI_INTERNAL';
35
+ /** A ToolError whose `code` may be a core ErrorCode OR a CLI-local code. The
36
+ * CLI wraps both core failures and its own argv / IO failures in this shape;
37
+ * a core ToolError is assignable here since ErrorCode is a subset. */
38
+ export type CliToolError = Omit<ToolError, 'code'> & {
39
+ code: ErrorCode | CliErrorCode;
40
+ };
41
+ /** CLI-local error class wrapping a (core or CLI) ToolError so the router can
42
+ * map it to a non-zero exit. The error is preserved verbatim for --json. */
43
+ export declare class CliError extends Error {
44
+ readonly toolError: CliToolError;
45
+ constructor(err: CliToolError);
46
+ }
47
+ /** Pretty-print a CliError to stderr and exit non-zero. Used by the router
48
+ * when the catch fires. */
49
+ export declare function emitCliError(e: unknown, flags: CliFlags): never;
@@ -0,0 +1,137 @@
1
+ // apps/policy-builder-cli/src/output.ts
2
+ //
3
+ // Output helpers for the CLI: formatToolResponse for the `--json` flag and
4
+ // file I/O for `--out`. The CLI is intentionally tiny - no commander / yargs
5
+ // dependency; the router is a hand-rolled argv parser.
6
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { resolve } from 'node:path';
8
+ /** Parse the argv tail for the known flags. Unrecognised flags are ignored
9
+ * (the caller enforces per-subcommand required flags separately). */
10
+ export function parseFlags(argv) {
11
+ let json = false;
12
+ let quiet = false;
13
+ let out = null;
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const a = argv[i];
16
+ if (a === '--json')
17
+ json = true;
18
+ else if (a === '--quiet')
19
+ quiet = true;
20
+ else if (a === '--out' && argv[i + 1]) {
21
+ out = argv[i + 1];
22
+ i++;
23
+ }
24
+ else if (a?.startsWith('--out=')) {
25
+ out = a.slice('--out='.length);
26
+ }
27
+ }
28
+ return { json, quiet, out };
29
+ }
30
+ /** Resolve `--value <v>` style pairs after the subcommand name. Returns
31
+ * an object keyed by the option name (without `--`). Throws on missing
32
+ * value or duplicate keys. */
33
+ export function parsePairs(argv) {
34
+ const out = {};
35
+ for (let i = 0; i < argv.length; i++) {
36
+ const a = argv[i];
37
+ if (a?.startsWith('--') && a.includes('=')) {
38
+ const eq = a.indexOf('=');
39
+ const key = a.slice(2, eq);
40
+ const val = a.slice(eq + 1);
41
+ if (key && val !== undefined)
42
+ out[key] = val;
43
+ }
44
+ else if (a?.startsWith('--') && argv[i + 1] && !argv[i + 1]?.startsWith('--')) {
45
+ const key = a.slice(2);
46
+ const val = argv[i + 1];
47
+ out[key] = val;
48
+ i++;
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+ /** Read a JSON file, parse it, and return the value. Throws with a CLI-
54
+ * friendly error if the file is missing or malformed. */
55
+ export function readJsonFile(path) {
56
+ const abs = resolve(path);
57
+ if (!existsSync(abs)) {
58
+ throw new CliError({
59
+ code: 'CLI_FILE_NOT_FOUND',
60
+ message: `file not found: ${path}`,
61
+ severity: 'error',
62
+ retryable: false,
63
+ });
64
+ }
65
+ const raw = readFileSync(abs, 'utf8');
66
+ try {
67
+ return JSON.parse(raw);
68
+ }
69
+ catch (e) {
70
+ throw new CliError({
71
+ code: 'CLI_INVALID_JSON',
72
+ message: `invalid JSON in ${path}: ${e.message}`,
73
+ severity: 'error',
74
+ retryable: false,
75
+ });
76
+ }
77
+ }
78
+ /** Write a JSON-serialisable value to disk. Pretty-prints by default so the
79
+ * artefact is human-readable; CI scripts that need compact JSON can pipe
80
+ * through `jq` instead. */
81
+ export function writeJsonFile(path, value) {
82
+ const abs = resolve(path);
83
+ writeFileSync(abs, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
84
+ }
85
+ /** Wraps a core ToolResponse for the CLI:
86
+ * - always prints something useful (JSON or a short summary)
87
+ * - exits non-zero on ToolError so CI scripts can gate on `$?`
88
+ * - writes the `{ ok, data }` envelope to --out when present (matches
89
+ * the --json stdout shape so CI scripts get a single canonical payload)
90
+ *
91
+ * Throws CliError so the router can map it to a process exit code + a
92
+ * structured JSON envelope under --json. */
93
+ export function formatToolResponse(res, flags, outLabel = 'result') {
94
+ if (res.ok) {
95
+ const envelope = { ok: true, data: res.data };
96
+ if (flags.out)
97
+ writeJsonFile(flags.out, envelope);
98
+ if (flags.json) {
99
+ // newline-terminated JSON so it pipes cleanly
100
+ process.stdout.write(`${JSON.stringify(envelope)}\n`);
101
+ }
102
+ else if (!flags.quiet) {
103
+ process.stdout.write(`${outLabel}: ok\n`);
104
+ }
105
+ return res.data;
106
+ }
107
+ throw new CliError(res.error);
108
+ }
109
+ /** CLI-local error class wrapping a (core or CLI) ToolError so the router can
110
+ * map it to a non-zero exit. The error is preserved verbatim for --json. */
111
+ export class CliError extends Error {
112
+ toolError;
113
+ constructor(err) {
114
+ super(err.message);
115
+ this.toolError = err;
116
+ }
117
+ }
118
+ /** Pretty-print a CliError to stderr and exit non-zero. Used by the router
119
+ * when the catch fires. */
120
+ export function emitCliError(e, flags) {
121
+ if (e instanceof CliError) {
122
+ if (flags.json) {
123
+ process.stdout.write(`${JSON.stringify({ ok: false, error: e.toolError })}\n`);
124
+ }
125
+ else {
126
+ process.stderr.write(`error: ${e.toolError.code} - ${e.toolError.message}\n`);
127
+ }
128
+ process.exit(1);
129
+ }
130
+ if (flags.json) {
131
+ process.stdout.write(`${JSON.stringify({ ok: false, error: { code: 'CLI_INTERNAL', message: e.message, severity: 'fatal', retryable: false } })}\n`);
132
+ }
133
+ else {
134
+ process.stderr.write(`internal error: ${e.message}\n`);
135
+ }
136
+ process.exit(2);
137
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@crediolabs/policy-builder-cli",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "description": "CLI wrapper around the OZ policy-synth core (record + synthesize) for solo-dev and CI workflows.",
6
+ "type": "module",
7
+ "main": "./dist/src/index.js",
8
+ "types": "./dist/src/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/src/index.d.ts",
12
+ "bun": "./src/index.ts",
13
+ "import": "./dist/src/index.js",
14
+ "default": "./dist/src/index.js"
15
+ }
16
+ },
17
+ "bin": {
18
+ "policy-builder": "./dist/bin/policy-builder.js"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src",
23
+ "!src/**/*.test.ts"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "scripts": {
29
+ "test": "bun test",
30
+ "build": "tsc -p tsconfig.build.json"
31
+ },
32
+ "dependencies": {
33
+ "@crediolabs/policy-builder-mcp": "^0.1.0",
34
+ "@crediolabs/policy-synth": "^0.1.0",
35
+ "zod": "3.25.76"
36
+ },
37
+ "devDependencies": {
38
+ "@biomejs/biome": "2.5.5",
39
+ "@types/node": "^26.1.1",
40
+ "typescript": "5.9.3"
41
+ }
42
+ }
@@ -0,0 +1,46 @@
1
+ // apps/policy-builder-cli/src/commands/record.ts
2
+ //
3
+ // `policy-builder record` subcommand. Thin wrapper around the core
4
+ // `recordTransaction` - no business logic, just argv -> RecordInput + the
5
+ // canonical CLI envelope.
6
+
7
+ import { runRecordTransaction } from '@crediolabs/policy-builder-mcp'
8
+ import type { RecordedTransaction } from '@crediolabs/policy-synth'
9
+ import { CliError, type CliFlags, formatToolResponse, parsePairs } from '../output.ts'
10
+
11
+ export async function runRecordCommand(
12
+ argv: ReadonlyArray<string>,
13
+ flags: CliFlags
14
+ ): Promise<RecordedTransaction> {
15
+ const pairs = parsePairs(argv)
16
+ const network = pairs.network
17
+ if (!network) {
18
+ throw new CliError({
19
+ code: 'CLI_MISSING_ARG',
20
+ message: 'record: --network <mainnet|testnet> is required',
21
+ severity: 'error',
22
+ retryable: false,
23
+ })
24
+ }
25
+ if (!pairs.hash && !pairs.xdr) {
26
+ throw new CliError({
27
+ code: 'CLI_MISSING_ARG',
28
+ message: 'record: exactly one of --hash <tx> or --xdr <b64> is required',
29
+ severity: 'error',
30
+ retryable: false,
31
+ })
32
+ }
33
+ if (pairs.hash && pairs.xdr) {
34
+ throw new CliError({
35
+ code: 'CLI_MISSING_ARG',
36
+ message: 'record: provide exactly one of --hash or --xdr, not both',
37
+ severity: 'error',
38
+ retryable: false,
39
+ })
40
+ }
41
+ const args: Record<string, unknown> = { network }
42
+ if (pairs.hash) args.hash = pairs.hash
43
+ if (pairs.xdr) args.xdr = pairs.xdr
44
+ const res = await runRecordTransaction(args)
45
+ return formatToolResponse(res, flags, 'record')
46
+ }
@@ -0,0 +1,54 @@
1
+ // apps/policy-builder-cli/src/commands/synthesize.ts
2
+ //
3
+ // `policy-builder synthesize` subcommand. Dispatches to ONE of the two
4
+ // front-ends (mandate / recording) based on which file flag is supplied:
5
+ // --mandate <path.json> -> synthesizeFromMandate
6
+ // --recorded-tx <path.json> -> synthesizeFromRecording
7
+ //
8
+ // The CLI mirrors the MCP tool's discriminated union: one subcommand, two
9
+ // front-ends, mutually exclusive.
10
+
11
+ import { runSynthesizePolicy } from '@crediolabs/policy-builder-mcp'
12
+ import type { ProposedPolicy } from '@crediolabs/policy-synth'
13
+ import { CliError, type CliFlags, formatToolResponse, parsePairs, readJsonFile } from '../output.ts'
14
+
15
+ export async function runSynthesizeCommand(
16
+ argv: ReadonlyArray<string>,
17
+ flags: CliFlags
18
+ ): Promise<ProposedPolicy> {
19
+ const pairs = parsePairs(argv)
20
+ const hasMandate = Boolean(pairs.mandate)
21
+ const hasRecorded = Boolean(pairs['recorded-tx'])
22
+ if (hasMandate === hasRecorded) {
23
+ throw new CliError({
24
+ code: 'CLI_MISSING_ARG',
25
+ message: 'synthesize: exactly one of --mandate <path> or --recorded-tx <path> is required',
26
+ severity: 'error',
27
+ retryable: false,
28
+ })
29
+ }
30
+
31
+ if (hasMandate) {
32
+ const mandate = readJsonFile(pairs.mandate as string) as Record<string, unknown>
33
+ const res = await runSynthesizePolicy({ source: 'mandate', mandate })
34
+ return formatToolResponse(res, flags, 'synthesize(mandate)')
35
+ }
36
+
37
+ // hasRecorded
38
+ const recordedTx = readJsonFile(pairs['recorded-tx'] as string) as Record<string, unknown>
39
+ const network = pairs.network
40
+ if (!network) {
41
+ throw new CliError({
42
+ code: 'CLI_MISSING_ARG',
43
+ message: 'synthesize: --network <mainnet|testnet> is required with --recorded-tx',
44
+ severity: 'error',
45
+ retryable: false,
46
+ })
47
+ }
48
+ const args: Record<string, unknown> = { source: 'recording', recordedTx, network }
49
+ if (pairs.responses) {
50
+ args.userResponses = readJsonFile(pairs.responses)
51
+ }
52
+ const res = await runSynthesizePolicy(args)
53
+ return formatToolResponse(res, flags, 'synthesize(recording)')
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ // apps/policy-builder-cli/src/index.ts - public re-exports for the CLI package.
2
+
3
+ export { runRecordCommand } from './commands/record.ts'
4
+ export { runSynthesizeCommand } from './commands/synthesize.ts'
5
+ export { formatToolResponse, readJsonFile, writeJsonFile } from './output.ts'
package/src/output.ts ADDED
@@ -0,0 +1,163 @@
1
+ // apps/policy-builder-cli/src/output.ts
2
+ //
3
+ // Output helpers for the CLI: formatToolResponse for the `--json` flag and
4
+ // file I/O for `--out`. The CLI is intentionally tiny - no commander / yargs
5
+ // dependency; the router is a hand-rolled argv parser.
6
+
7
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
8
+ import { resolve } from 'node:path'
9
+ import type { ErrorCode, ToolError, ToolResponse } from '@crediolabs/policy-synth'
10
+
11
+ /** CLI flags (the only ones shipped in T1). */
12
+ export interface CliFlags {
13
+ json: boolean
14
+ quiet: boolean
15
+ out: string | null
16
+ }
17
+
18
+ /** Parse the argv tail for the known flags. Unrecognised flags are ignored
19
+ * (the caller enforces per-subcommand required flags separately). */
20
+ export function parseFlags(argv: ReadonlyArray<string>): CliFlags {
21
+ let json = false
22
+ let quiet = false
23
+ let out: string | null = null
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const a = argv[i]
26
+ if (a === '--json') json = true
27
+ else if (a === '--quiet') quiet = true
28
+ else if (a === '--out' && argv[i + 1]) {
29
+ out = argv[i + 1] as string
30
+ i++
31
+ } else if (a?.startsWith('--out=')) {
32
+ out = a.slice('--out='.length)
33
+ }
34
+ }
35
+ return { json, quiet, out }
36
+ }
37
+
38
+ /** Resolve `--value <v>` style pairs after the subcommand name. Returns
39
+ * an object keyed by the option name (without `--`). Throws on missing
40
+ * value or duplicate keys. */
41
+ export function parsePairs(argv: ReadonlyArray<string>): Record<string, string> {
42
+ const out: Record<string, string> = {}
43
+ for (let i = 0; i < argv.length; i++) {
44
+ const a = argv[i]
45
+ if (a?.startsWith('--') && a.includes('=')) {
46
+ const eq = a.indexOf('=')
47
+ const key = a.slice(2, eq)
48
+ const val = a.slice(eq + 1)
49
+ if (key && val !== undefined) out[key] = val
50
+ } else if (a?.startsWith('--') && argv[i + 1] && !argv[i + 1]?.startsWith('--')) {
51
+ const key = a.slice(2)
52
+ const val = argv[i + 1] as string
53
+ out[key] = val
54
+ i++
55
+ }
56
+ }
57
+ return out
58
+ }
59
+
60
+ /** Read a JSON file, parse it, and return the value. Throws with a CLI-
61
+ * friendly error if the file is missing or malformed. */
62
+ export function readJsonFile(path: string): unknown {
63
+ const abs = resolve(path)
64
+ if (!existsSync(abs)) {
65
+ throw new CliError({
66
+ code: 'CLI_FILE_NOT_FOUND',
67
+ message: `file not found: ${path}`,
68
+ severity: 'error',
69
+ retryable: false,
70
+ })
71
+ }
72
+ const raw = readFileSync(abs, 'utf8')
73
+ try {
74
+ return JSON.parse(raw)
75
+ } catch (e) {
76
+ throw new CliError({
77
+ code: 'CLI_INVALID_JSON',
78
+ message: `invalid JSON in ${path}: ${(e as Error).message}`,
79
+ severity: 'error',
80
+ retryable: false,
81
+ })
82
+ }
83
+ }
84
+
85
+ /** Write a JSON-serialisable value to disk. Pretty-prints by default so the
86
+ * artefact is human-readable; CI scripts that need compact JSON can pipe
87
+ * through `jq` instead. */
88
+ export function writeJsonFile(path: string, value: unknown): void {
89
+ const abs = resolve(path)
90
+ writeFileSync(abs, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
91
+ }
92
+
93
+ /** Wraps a core ToolResponse for the CLI:
94
+ * - always prints something useful (JSON or a short summary)
95
+ * - exits non-zero on ToolError so CI scripts can gate on `$?`
96
+ * - writes the `{ ok, data }` envelope to --out when present (matches
97
+ * the --json stdout shape so CI scripts get a single canonical payload)
98
+ *
99
+ * Throws CliError so the router can map it to a process exit code + a
100
+ * structured JSON envelope under --json. */
101
+ export function formatToolResponse<T>(
102
+ res: ToolResponse<T>,
103
+ flags: CliFlags,
104
+ outLabel = 'result'
105
+ ): T {
106
+ if (res.ok) {
107
+ const envelope = { ok: true as const, data: res.data }
108
+ if (flags.out) writeJsonFile(flags.out, envelope)
109
+ if (flags.json) {
110
+ // newline-terminated JSON so it pipes cleanly
111
+ process.stdout.write(`${JSON.stringify(envelope)}\n`)
112
+ } else if (!flags.quiet) {
113
+ process.stdout.write(`${outLabel}: ok\n`)
114
+ }
115
+ return res.data
116
+ }
117
+ throw new CliError(res.error)
118
+ }
119
+
120
+ /** CLI-specific error codes, distinct from the core's ErrorCode union. They
121
+ * cover failures that arise around the core call (bad argv, missing or
122
+ * malformed input files) and never collide with a core code. */
123
+ export type CliErrorCode =
124
+ | 'CLI_MISSING_ARG'
125
+ | 'CLI_FILE_NOT_FOUND'
126
+ | 'CLI_INVALID_JSON'
127
+ | 'CLI_INTERNAL'
128
+
129
+ /** A ToolError whose `code` may be a core ErrorCode OR a CLI-local code. The
130
+ * CLI wraps both core failures and its own argv / IO failures in this shape;
131
+ * a core ToolError is assignable here since ErrorCode is a subset. */
132
+ export type CliToolError = Omit<ToolError, 'code'> & { code: ErrorCode | CliErrorCode }
133
+
134
+ /** CLI-local error class wrapping a (core or CLI) ToolError so the router can
135
+ * map it to a non-zero exit. The error is preserved verbatim for --json. */
136
+ export class CliError extends Error {
137
+ readonly toolError: CliToolError
138
+ constructor(err: CliToolError) {
139
+ super(err.message)
140
+ this.toolError = err
141
+ }
142
+ }
143
+
144
+ /** Pretty-print a CliError to stderr and exit non-zero. Used by the router
145
+ * when the catch fires. */
146
+ export function emitCliError(e: unknown, flags: CliFlags): never {
147
+ if (e instanceof CliError) {
148
+ if (flags.json) {
149
+ process.stdout.write(`${JSON.stringify({ ok: false, error: e.toolError })}\n`)
150
+ } else {
151
+ process.stderr.write(`error: ${e.toolError.code} - ${e.toolError.message}\n`)
152
+ }
153
+ process.exit(1)
154
+ }
155
+ if (flags.json) {
156
+ process.stdout.write(
157
+ `${JSON.stringify({ ok: false, error: { code: 'CLI_INTERNAL', message: (e as Error).message, severity: 'fatal', retryable: false } })}\n`
158
+ )
159
+ } else {
160
+ process.stderr.write(`internal error: ${(e as Error).message}\n`)
161
+ }
162
+ process.exit(2)
163
+ }