@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.
- package/dist/bin/policy-builder.d.ts +2 -0
- package/dist/bin/policy-builder.js +72 -0
- package/dist/src/commands/record.d.ts +3 -0
- package/dist/src/commands/record.js +42 -0
- package/dist/src/commands/synthesize.d.ts +3 -0
- package/dist/src/commands/synthesize.js +46 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +4 -0
- package/dist/src/output.d.ts +49 -0
- package/dist/src/output.js +137 -0
- package/package.json +42 -0
- package/src/commands/record.ts +46 -0
- package/src/commands/synthesize.ts +54 -0
- package/src/index.ts +5 -0
- package/src/output.ts +163 -0
|
@@ -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,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,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,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
|
+
}
|