@crediolabs/policy-builder-cli 0.1.17 → 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.
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- // apps/policy-builder-cli/src/commands/synthesize.ts
2
+ // packages/policy-builder-cli/src/commands/synthesize.ts
3
3
  //
4
4
  // `policy-builder synthesize` subcommand. Dispatches to ONE of the two
5
5
  // front-ends (mandate / recording) based on which file flag is supplied:
@@ -51,14 +51,7 @@ async function runSynthesizeCommand(argv, flags) {
51
51
  if (hasMandate) {
52
52
  const mandate = (0, output_ts_1.readJsonFile)(pairs.mandate);
53
53
  const args = { source: 'mandate', mandate };
54
- if (pairs['oz-config'] !== undefined) {
55
- args.ozConfig = readOzConfigFile(pairs['oz-config']);
56
- }
57
- if (pairs.confidence !== undefined) {
58
- args.confidenceOverride = { threshold: parseConfidence(pairs.confidence) };
59
- }
60
- if (explain)
61
- args.explain = true;
54
+ applySharedFlags(args, pairs, explain);
62
55
  const res = await (0, run_1.runSynthesizePolicy)(args);
63
56
  if (explain)
64
57
  emitExplainBlock(res, flags);
@@ -87,10 +80,7 @@ async function runSynthesizeCommand(argv, flags) {
87
80
  const userResponses = {};
88
81
  if (pairs.responses) {
89
82
  const file = (0, output_ts_1.readJsonFile)(pairs.responses);
90
- if (file !== null && typeof file === 'object' && !Array.isArray(file)) {
91
- Object.assign(userResponses, file);
92
- }
93
- else {
83
+ if (file === null || typeof file !== 'object' || Array.isArray(file)) {
94
84
  throw new output_ts_1.CliError({
95
85
  code: 'CLI_INVALID_JSON',
96
86
  message: `synthesize: --responses ${pairs.responses} must be a JSON object`,
@@ -98,48 +88,43 @@ async function runSynthesizeCommand(argv, flags) {
98
88
  retryable: false,
99
89
  });
100
90
  }
91
+ Object.assign(userResponses, file);
101
92
  }
102
- if (pairs['window-seconds'] !== undefined) {
103
- userResponses.windowSeconds = parsePositiveInt(pairs['window-seconds'], '--window-seconds');
104
- }
105
- if (pairs['valid-until'] !== undefined) {
106
- userResponses.validUntilLedger = parsePositiveInt(pairs['valid-until'], '--valid-until');
107
- }
108
- if (pairs['limit-amount'] !== undefined) {
109
- userResponses.limitAmount = parseI128String(pairs['limit-amount'], '--limit-amount');
110
- }
111
- if (pairs['invocation-limit'] !== undefined) {
112
- userResponses.invocationLimit = parsePositiveInt(pairs['invocation-limit'], '--invocation-limit');
93
+ // Per-field overrides. Each entry: argv flag name, userResponses key,
94
+ // and a parser that validates the raw string.
95
+ const userResponseFlags = [
96
+ ['window-seconds', 'windowSeconds', parsePositiveInt],
97
+ ['valid-until', 'validUntilLedger', parsePositiveInt],
98
+ ['limit-amount', 'limitAmount', parseI128String],
99
+ ['invocation-limit', 'invocationLimit', parsePositiveInt],
100
+ ];
101
+ for (const [flag, key, parse] of userResponseFlags) {
102
+ if (pairs[flag] !== undefined) {
103
+ userResponses[key] = parse(pairs[flag], `--${flag}`);
104
+ }
113
105
  }
114
106
  // --recipient <C...|G...> is REPEATABLE (parsePairs collapses duplicate keys,
115
107
  // so it is collected straight from argv). Each value builds the swap-recipient
116
108
  // allowlist; supplying it REPLACES the default pin to the recorded recipient.
117
109
  const recipients = collectRepeated(argv, 'recipient');
118
- if (recipients.length > 0) {
119
- for (const r of recipients) {
120
- // Same validator the run-layer schema applies (SDK StrKey underneath) - a
121
- // swap recipient may be a G... wallet or a C... contract. Shared rather
122
- // than re-inlined so the CLI and the schema cannot drift apart.
123
- if (!(0, policy_synth_1.isStellarAddress)(r)) {
124
- throw new output_ts_1.CliError({
125
- code: 'CLI_MISSING_ARG',
126
- message: `synthesize: --recipient "${r}" is not a valid Stellar address (expected a G... wallet or C... contract)`,
127
- severity: 'error',
128
- retryable: false,
129
- });
130
- }
110
+ for (const r of recipients) {
111
+ // Same validator the run-layer schema applies (SDK StrKey underneath) - a
112
+ // swap recipient may be a G... wallet or a C... contract. Shared rather
113
+ // than re-inlined so the CLI and the schema cannot drift apart.
114
+ if (!(0, policy_synth_1.isStellarAddress)(r)) {
115
+ throw new output_ts_1.CliError({
116
+ code: 'CLI_MISSING_ARG',
117
+ message: `synthesize: --recipient "${r}" is not a valid Stellar address (expected a G... wallet or C... contract)`,
118
+ severity: 'error',
119
+ retryable: false,
120
+ });
131
121
  }
132
- userResponses.swapRecipientAllowlist = recipients;
133
122
  }
134
- if (Object.keys(userResponses).length > 0) {
123
+ if (recipients.length > 0)
124
+ userResponses.swapRecipientAllowlist = recipients;
125
+ if (Object.keys(userResponses).length > 0)
135
126
  args.userResponses = userResponses;
136
- }
137
- if (pairs['oz-config'] !== undefined) {
138
- args.ozConfig = readOzConfigFile(pairs['oz-config']);
139
- }
140
- if (pairs.confidence !== undefined) {
141
- args.confidenceOverride = { threshold: parseConfidence(pairs.confidence) };
142
- }
127
+ applySharedFlags(args, pairs, explain);
143
128
  // --smart-account <C...> opts into the interpreter adapter, so constraints OZ
144
129
  // cannot express (per-method scoping, invocation-count windows, oracle bounds,
145
130
  // exact hop paths) lower to a real predicate document instead of just warnings.
@@ -149,31 +134,32 @@ async function runSynthesizeCommand(argv, flags) {
149
134
  // without `--smart-account` are rejected up front instead of being silently
150
135
  // dropped. The foot-gun: an empty value previously produced an "ok" envelope
151
136
  // with 0 policyDocuments, so callers thought the constraint had been enforced
152
- // when in fact it had been silently skipped.
137
+ // when it had been silently skipped.
153
138
  const smartAccountRaw = pairs['smart-account'];
154
139
  const installNonceRaw = pairs['install-nonce'];
155
140
  const oracleStalenessRaw = pairs['oracle-max-staleness'];
156
141
  const oracleDeviationRaw = pairs['oracle-max-deviation'];
157
- if (installNonceRaw !== undefined && smartAccountRaw === undefined) {
158
- throw new output_ts_1.CliError({
159
- code: 'CLI_MISSING_ARG',
160
- message: 'synthesize: --install-nonce requires --smart-account <C...> (interpreter opt-in)',
161
- severity: 'error',
162
- retryable: false,
163
- });
164
- }
165
- // Oracle params are an interpreter-only knob; reject up front so they cannot
166
- // be silently dropped when --smart-account is absent.
167
- if ((oracleStalenessRaw !== undefined || oracleDeviationRaw !== undefined) &&
168
- smartAccountRaw === undefined) {
169
- throw new output_ts_1.CliError({
170
- code: 'CLI_MISSING_ARG',
171
- message: 'synthesize: --oracle-max-staleness / --oracle-max-deviation require --smart-account <C...> (interpreter opt-in)',
172
- severity: 'error',
173
- retryable: false,
174
- });
142
+ // --install-nonce and the oracle knobs are interpreter-only. Reject up
143
+ // front so they cannot be silently dropped when --smart-account is absent.
144
+ if (smartAccountRaw === undefined) {
145
+ if (installNonceRaw !== undefined) {
146
+ throw new output_ts_1.CliError({
147
+ code: 'CLI_MISSING_ARG',
148
+ message: 'synthesize: --install-nonce requires --smart-account <C...> (interpreter opt-in)',
149
+ severity: 'error',
150
+ retryable: false,
151
+ });
152
+ }
153
+ if (oracleStalenessRaw !== undefined || oracleDeviationRaw !== undefined) {
154
+ throw new output_ts_1.CliError({
155
+ code: 'CLI_MISSING_ARG',
156
+ message: 'synthesize: --oracle-max-staleness / --oracle-max-deviation require --smart-account <C...> (interpreter opt-in)',
157
+ severity: 'error',
158
+ retryable: false,
159
+ });
160
+ }
175
161
  }
176
- if (smartAccountRaw !== undefined) {
162
+ else {
177
163
  const smartAccount = smartAccountRaw.trim();
178
164
  if (smartAccount.length === 0) {
179
165
  throw new output_ts_1.CliError({
@@ -230,6 +216,19 @@ async function runSynthesizeCommand(argv, flags) {
230
216
  emitExplainBlock(res, flags);
231
217
  return (0, output_ts_1.formatToolResponse)(res, flags, 'synthesize(recording)');
232
218
  }
219
+ /** Apply flags shared between the mandate and recording synthesize paths:
220
+ * --oz-config, --confidence, --explain. Pulled out so adding a shared flag
221
+ * adds one branch here rather than two copies in the command body. */
222
+ function applySharedFlags(args, pairs, explain) {
223
+ if (pairs['oz-config'] !== undefined) {
224
+ args.ozConfig = readOzConfigFile(pairs['oz-config']);
225
+ }
226
+ if (pairs.confidence !== undefined) {
227
+ args.confidenceOverride = { threshold: parseConfidence(pairs.confidence) };
228
+ }
229
+ if (explain)
230
+ args.explain = true;
231
+ }
233
232
  /** Augment the tool response envelope with the --explain fields and
234
233
  * (in non-JSON mode) print the review card readably. The CLI is the
235
234
  * single seam that places `review` + `predicateTree` on the wire
@@ -304,9 +303,11 @@ function parseConfidence(raw) {
304
303
  }
305
304
  /** Parse a strictly positive integer (windowSeconds, validUntilLedger,
306
305
  * invocationLimit, oracleParams bounds). The core re-validates these with
307
- * field-specific caps; the CLI just enforces "looks like an integer > 0". */
306
+ * field-specific caps; the CLI just enforces "looks like an integer > 0".
307
+ * The `^[0-9]+$` regex already pins the shape to a non-negative integer, so
308
+ * the only thing left to check is "not zero". */
308
309
  function parsePositiveInt(raw, flagName) {
309
- if (!POSITIVE_INT_RE.test(raw)) {
310
+ if (!POSITIVE_INT_RE.test(raw) || raw === '0') {
310
311
  throw new output_ts_1.CliError({
311
312
  code: 'CLI_MISSING_ARG',
312
313
  message: `synthesize: ${flagName} "${raw}" must be a positive integer`,
@@ -314,16 +315,7 @@ function parsePositiveInt(raw, flagName) {
314
315
  retryable: false,
315
316
  });
316
317
  }
317
- const n = Number(raw);
318
- if (!Number.isInteger(n) || n <= 0) {
319
- throw new output_ts_1.CliError({
320
- code: 'CLI_MISSING_ARG',
321
- message: `synthesize: ${flagName} "${raw}" must be a positive integer`,
322
- severity: 'error',
323
- retryable: false,
324
- });
325
- }
326
- return n;
318
+ return Number(raw);
327
319
  }
328
320
  /** Collect ALL values for a repeatable `--<name> <value>` / `--<name>=<value>`
329
321
  * flag from argv, in order. Unlike `parsePairs` (which keeps only the last
@@ -1,3 +1,4 @@
1
+ export { runMergeCommand } from './commands/merge.ts';
1
2
  export { runRecordCommand } from './commands/record.ts';
2
3
  export { runSynthesizeCommand } from './commands/synthesize.ts';
3
4
  export { formatToolResponse, readJsonFile, writeJsonFile } from './output.ts';
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
- // apps/policy-builder-cli/src/index.ts - public re-exports for the CLI package.
2
+ // packages/policy-builder-cli/src/index.ts - public re-exports for the CLI package.
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.writeJsonFile = exports.readJsonFile = exports.formatToolResponse = exports.runSynthesizeCommand = exports.runRecordCommand = void 0;
4
+ exports.writeJsonFile = exports.readJsonFile = exports.formatToolResponse = exports.runSynthesizeCommand = exports.runRecordCommand = exports.runMergeCommand = void 0;
5
+ var merge_ts_1 = require("./commands/merge.js");
6
+ Object.defineProperty(exports, "runMergeCommand", { enumerable: true, get: function () { return merge_ts_1.runMergeCommand; } });
5
7
  var record_ts_1 = require("./commands/record.js");
6
8
  Object.defineProperty(exports, "runRecordCommand", { enumerable: true, get: function () { return record_ts_1.runRecordCommand; } });
7
9
  var synthesize_ts_1 = require("./commands/synthesize.js");
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- // apps/policy-builder-cli/src/output.ts
2
+ // packages/policy-builder-cli/src/output.ts
3
3
  //
4
4
  // Output helpers for the CLI: formatToolResponse for the `--json` flag and
5
5
  // file I/O for `--out`. The CLI is intentionally tiny - no commander / yargs
@@ -119,20 +119,19 @@ function writeJsonFile(path, value) {
119
119
  * Throws CliError so the router can map it to a process exit code + a
120
120
  * structured JSON envelope under --json. */
121
121
  function formatToolResponse(res, flags, outLabel = 'result') {
122
- if (res.ok) {
123
- const envelope = { ok: true, data: res.data };
124
- if (flags.out)
125
- writeJsonFile(flags.out, envelope);
126
- if (flags.json) {
127
- // newline-terminated JSON so it pipes cleanly
128
- process.stdout.write(`${JSON.stringify(envelope)}\n`);
129
- }
130
- else if (!flags.quiet) {
131
- process.stdout.write(`${outLabel}: ok\n`);
132
- }
133
- return res.data;
122
+ if (!res.ok)
123
+ throw new CliError(res.error);
124
+ const envelope = { ok: true, data: res.data };
125
+ if (flags.out)
126
+ writeJsonFile(flags.out, envelope);
127
+ if (flags.json) {
128
+ // newline-terminated JSON so it pipes cleanly
129
+ process.stdout.write(`${JSON.stringify(envelope)}\n`);
130
+ }
131
+ else if (!flags.quiet) {
132
+ process.stdout.write(`${outLabel}: ok\n`);
134
133
  }
135
- throw new CliError(res.error);
134
+ return res.data;
136
135
  }
137
136
  /** CLI-local error class wrapping a (core or CLI) ToolError so the router can
138
137
  * map it to a non-zero exit. The error is preserved verbatim for --json. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-builder-cli",
3
- "version": "0.1.17",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "description": "CLI wrapper around the OZ policy-synth core (record + synthesize) for solo-dev and CI workflows.",
6
6
  "type": "module",
@@ -12,12 +12,12 @@
12
12
  },
13
13
  "repository": {
14
14
  "type": "git",
15
- "url": "https://github.com/untangledfinance/oz-policy-builder.git",
16
- "directory": "apps/policy-builder-cli"
15
+ "url": "https://github.com/untangledfinance/octogate.git",
16
+ "directory": "packages/policy-builder-cli"
17
17
  },
18
- "homepage": "https://github.com/untangledfinance/oz-policy-builder#readme",
18
+ "homepage": "https://github.com/untangledfinance/octogate#readme",
19
19
  "bugs": {
20
- "url": "https://github.com/untangledfinance/oz-policy-builder/issues"
20
+ "url": "https://github.com/untangledfinance/octogate/issues"
21
21
  },
22
22
  "keywords": [
23
23
  "stellar",
@@ -63,7 +63,7 @@
63
63
  "prepack": "bun run build"
64
64
  },
65
65
  "dependencies": {
66
- "@crediolabs/policy-synth": "0.1.16",
66
+ "@crediolabs/policy-synth": "0.2.0",
67
67
  "@stellar/stellar-sdk": "14.4.0",
68
68
  "zod": "3.25.76"
69
69
  },
@@ -0,0 +1,65 @@
1
+ // packages/policy-builder-cli/src/commands/merge.ts
2
+ //
3
+ // `policy-builder merge` subcommand. The tightening remedy for a cross-rule
4
+ // authority overlap.
5
+ //
6
+ // OpenZeppelin enforces only the policies of the context rule a signer NAMES,
7
+ // so a signer in several rules picks which one applies and their authority is
8
+ // the maximum over the matching rules. Adding a second, tighter rule therefore
9
+ // restricts nothing. To actually tighten, the restriction has to become ONE
10
+ // predicate: the conjunction of the installed one and the new one.
11
+ //
12
+ // Two transactions, in order, because `add_policy` refuses a policy already
13
+ // attached to the rule:
14
+ //
15
+ // policy-builder merge --step detach ... sign and submit, wait
16
+ // policy-builder merge --step reinstall ... sign and submit
17
+ //
18
+ // The detach uninstalls the policy, which resets every counter on the rule and
19
+ // leaves it unpoliced until the reinstall confirms. Both are reported as
20
+ // warnings rather than left for the operator to discover afterwards.
21
+
22
+ import { runMergePolicy } from '@crediolabs/policy-synth/run'
23
+ import { CliError, type CliFlags, formatToolResponse, parsePairs } from '../output.ts'
24
+
25
+ function missing(message: string): CliError {
26
+ return new CliError({
27
+ code: 'CLI_MISSING_ARG',
28
+ message,
29
+ severity: 'error',
30
+ retryable: false,
31
+ })
32
+ }
33
+
34
+ function required(pairs: Record<string, string>, name: string): string {
35
+ const value = pairs[name]
36
+ if (!value) throw missing(`merge: --${name} is required`)
37
+ return value
38
+ }
39
+
40
+ export async function runMergeCommand(argv: ReadonlyArray<string>, flags: CliFlags) {
41
+ const pairs = parsePairs(argv)
42
+
43
+ const step = required(pairs, 'step')
44
+ if (step !== 'detach' && step !== 'reinstall') {
45
+ throw missing("merge: --step must be 'detach' or 'reinstall'")
46
+ }
47
+
48
+ const ruleId = Number(required(pairs, 'rule-id'))
49
+ if (!Number.isInteger(ruleId) || ruleId < 0) {
50
+ throw missing('merge: --rule-id must be a non-negative integer')
51
+ }
52
+
53
+ const res = await runMergePolicy({
54
+ smartAccount: required(pairs, 'smart-account'),
55
+ sourceAccount: required(pairs, 'source-account'),
56
+ ruleId,
57
+ incomingPredicateBlobBase64: required(pairs, 'predicate'),
58
+ step,
59
+ ...(pairs.network ? { network: pairs.network } : {}),
60
+ ...(pairs['rpc-url'] ? { rpcUrl: pairs['rpc-url'] } : {}),
61
+ ...(pairs['allow-unpinned-rpc-url'] === 'true' ? { allowUnpinnedRpcUrl: true } : {}),
62
+ })
63
+
64
+ return formatToolResponse(res, flags)
65
+ }
@@ -1,4 +1,4 @@
1
- // apps/policy-builder-cli/src/commands/record.ts
1
+ // packages/policy-builder-cli/src/commands/record.ts
2
2
  //
3
3
  // `policy-builder record` subcommand. Thin wrapper around the core
4
4
  // `recordTransaction` - no business logic, just argv -> RecordInput + the
@@ -1,4 +1,4 @@
1
- // apps/policy-builder-cli/src/commands/synthesize.ts
1
+ // packages/policy-builder-cli/src/commands/synthesize.ts
2
2
  //
3
3
  // `policy-builder synthesize` subcommand. Dispatches to ONE of the two
4
4
  // front-ends (mandate / recording) based on which file flag is supplied:
@@ -72,13 +72,7 @@ export async function runSynthesizeCommand(
72
72
  if (hasMandate) {
73
73
  const mandate = readJsonFile(pairs.mandate as string) as Record<string, unknown>
74
74
  const args: Record<string, unknown> = { source: 'mandate', mandate }
75
- if (pairs['oz-config'] !== undefined) {
76
- args.ozConfig = readOzConfigFile(pairs['oz-config'] as string)
77
- }
78
- if (pairs.confidence !== undefined) {
79
- args.confidenceOverride = { threshold: parseConfidence(pairs.confidence as string) }
80
- }
81
- if (explain) args.explain = true
75
+ applySharedFlags(args, pairs, explain)
82
76
  const res = await runSynthesizePolicy(args)
83
77
  if (explain) emitExplainBlock(res, flags)
84
78
  return formatToolResponse(res, flags, 'synthesize(mandate)')
@@ -109,9 +103,7 @@ export async function runSynthesizeCommand(
109
103
  const userResponses: Record<string, unknown> = {}
110
104
  if (pairs.responses) {
111
105
  const file = readJsonFile(pairs.responses)
112
- if (file !== null && typeof file === 'object' && !Array.isArray(file)) {
113
- Object.assign(userResponses, file as Record<string, unknown>)
114
- } else {
106
+ if (file === null || typeof file !== 'object' || Array.isArray(file)) {
115
107
  throw new CliError({
116
108
  code: 'CLI_INVALID_JSON',
117
109
  message: `synthesize: --responses ${pairs.responses} must be a JSON object`,
@@ -119,58 +111,42 @@ export async function runSynthesizeCommand(
119
111
  retryable: false,
120
112
  })
121
113
  }
114
+ Object.assign(userResponses, file as Record<string, unknown>)
122
115
  }
123
- if (pairs['window-seconds'] !== undefined) {
124
- userResponses.windowSeconds = parsePositiveInt(
125
- pairs['window-seconds'] as string,
126
- '--window-seconds'
127
- )
128
- }
129
- if (pairs['valid-until'] !== undefined) {
130
- userResponses.validUntilLedger = parsePositiveInt(
131
- pairs['valid-until'] as string,
132
- '--valid-until'
133
- )
134
- }
135
- if (pairs['limit-amount'] !== undefined) {
136
- userResponses.limitAmount = parseI128String(pairs['limit-amount'] as string, '--limit-amount')
137
- }
138
- if (pairs['invocation-limit'] !== undefined) {
139
- userResponses.invocationLimit = parsePositiveInt(
140
- pairs['invocation-limit'] as string,
141
- '--invocation-limit'
142
- )
116
+ // Per-field overrides. Each entry: argv flag name, userResponses key,
117
+ // and a parser that validates the raw string.
118
+ const userResponseFlags: Array<[string, string, (raw: string, flag: string) => unknown]> = [
119
+ ['window-seconds', 'windowSeconds', parsePositiveInt],
120
+ ['valid-until', 'validUntilLedger', parsePositiveInt],
121
+ ['limit-amount', 'limitAmount', parseI128String],
122
+ ['invocation-limit', 'invocationLimit', parsePositiveInt],
123
+ ]
124
+ for (const [flag, key, parse] of userResponseFlags) {
125
+ if (pairs[flag] !== undefined) {
126
+ userResponses[key] = parse(pairs[flag] as string, `--${flag}`)
127
+ }
143
128
  }
144
129
  // --recipient <C...|G...> is REPEATABLE (parsePairs collapses duplicate keys,
145
130
  // so it is collected straight from argv). Each value builds the swap-recipient
146
131
  // allowlist; supplying it REPLACES the default pin to the recorded recipient.
147
132
  const recipients = collectRepeated(argv, 'recipient')
148
- if (recipients.length > 0) {
149
- for (const r of recipients) {
150
- // Same validator the run-layer schema applies (SDK StrKey underneath) - a
151
- // swap recipient may be a G... wallet or a C... contract. Shared rather
152
- // than re-inlined so the CLI and the schema cannot drift apart.
153
- if (!isStellarAddress(r)) {
154
- throw new CliError({
155
- code: 'CLI_MISSING_ARG',
156
- message: `synthesize: --recipient "${r}" is not a valid Stellar address (expected a G... wallet or C... contract)`,
157
- severity: 'error',
158
- retryable: false,
159
- })
160
- }
133
+ for (const r of recipients) {
134
+ // Same validator the run-layer schema applies (SDK StrKey underneath) - a
135
+ // swap recipient may be a G... wallet or a C... contract. Shared rather
136
+ // than re-inlined so the CLI and the schema cannot drift apart.
137
+ if (!isStellarAddress(r)) {
138
+ throw new CliError({
139
+ code: 'CLI_MISSING_ARG',
140
+ message: `synthesize: --recipient "${r}" is not a valid Stellar address (expected a G... wallet or C... contract)`,
141
+ severity: 'error',
142
+ retryable: false,
143
+ })
161
144
  }
162
- userResponses.swapRecipientAllowlist = recipients
163
- }
164
- if (Object.keys(userResponses).length > 0) {
165
- args.userResponses = userResponses
166
145
  }
146
+ if (recipients.length > 0) userResponses.swapRecipientAllowlist = recipients
147
+ if (Object.keys(userResponses).length > 0) args.userResponses = userResponses
167
148
 
168
- if (pairs['oz-config'] !== undefined) {
169
- args.ozConfig = readOzConfigFile(pairs['oz-config'] as string)
170
- }
171
- if (pairs.confidence !== undefined) {
172
- args.confidenceOverride = { threshold: parseConfidence(pairs.confidence as string) }
173
- }
149
+ applySharedFlags(args, pairs, explain)
174
150
 
175
151
  // --smart-account <C...> opts into the interpreter adapter, so constraints OZ
176
152
  // cannot express (per-method scoping, invocation-count windows, oracle bounds,
@@ -181,34 +157,32 @@ export async function runSynthesizeCommand(
181
157
  // without `--smart-account` are rejected up front instead of being silently
182
158
  // dropped. The foot-gun: an empty value previously produced an "ok" envelope
183
159
  // with 0 policyDocuments, so callers thought the constraint had been enforced
184
- // when in fact it had been silently skipped.
160
+ // when it had been silently skipped.
185
161
  const smartAccountRaw = pairs['smart-account']
186
162
  const installNonceRaw = pairs['install-nonce']
187
163
  const oracleStalenessRaw = pairs['oracle-max-staleness']
188
164
  const oracleDeviationRaw = pairs['oracle-max-deviation']
189
- if (installNonceRaw !== undefined && smartAccountRaw === undefined) {
190
- throw new CliError({
191
- code: 'CLI_MISSING_ARG',
192
- message: 'synthesize: --install-nonce requires --smart-account <C...> (interpreter opt-in)',
193
- severity: 'error',
194
- retryable: false,
195
- })
196
- }
197
- // Oracle params are an interpreter-only knob; reject up front so they cannot
198
- // be silently dropped when --smart-account is absent.
199
- if (
200
- (oracleStalenessRaw !== undefined || oracleDeviationRaw !== undefined) &&
201
- smartAccountRaw === undefined
202
- ) {
203
- throw new CliError({
204
- code: 'CLI_MISSING_ARG',
205
- message:
206
- 'synthesize: --oracle-max-staleness / --oracle-max-deviation require --smart-account <C...> (interpreter opt-in)',
207
- severity: 'error',
208
- retryable: false,
209
- })
210
- }
211
- if (smartAccountRaw !== undefined) {
165
+ // --install-nonce and the oracle knobs are interpreter-only. Reject up
166
+ // front so they cannot be silently dropped when --smart-account is absent.
167
+ if (smartAccountRaw === undefined) {
168
+ if (installNonceRaw !== undefined) {
169
+ throw new CliError({
170
+ code: 'CLI_MISSING_ARG',
171
+ message: 'synthesize: --install-nonce requires --smart-account <C...> (interpreter opt-in)',
172
+ severity: 'error',
173
+ retryable: false,
174
+ })
175
+ }
176
+ if (oracleStalenessRaw !== undefined || oracleDeviationRaw !== undefined) {
177
+ throw new CliError({
178
+ code: 'CLI_MISSING_ARG',
179
+ message:
180
+ 'synthesize: --oracle-max-staleness / --oracle-max-deviation require --smart-account <C...> (interpreter opt-in)',
181
+ severity: 'error',
182
+ retryable: false,
183
+ })
184
+ }
185
+ } else {
212
186
  const smartAccount = smartAccountRaw.trim()
213
187
  if (smartAccount.length === 0) {
214
188
  throw new CliError({
@@ -271,6 +245,23 @@ export async function runSynthesizeCommand(
271
245
  return formatToolResponse(res, flags, 'synthesize(recording)')
272
246
  }
273
247
 
248
+ /** Apply flags shared between the mandate and recording synthesize paths:
249
+ * --oz-config, --confidence, --explain. Pulled out so adding a shared flag
250
+ * adds one branch here rather than two copies in the command body. */
251
+ function applySharedFlags(
252
+ args: Record<string, unknown>,
253
+ pairs: Record<string, string>,
254
+ explain: boolean
255
+ ): void {
256
+ if (pairs['oz-config'] !== undefined) {
257
+ args.ozConfig = readOzConfigFile(pairs['oz-config'] as string)
258
+ }
259
+ if (pairs.confidence !== undefined) {
260
+ args.confidenceOverride = { threshold: parseConfidence(pairs.confidence as string) }
261
+ }
262
+ if (explain) args.explain = true
263
+ }
264
+
274
265
  /** Augment the tool response envelope with the --explain fields and
275
266
  * (in non-JSON mode) print the review card readably. The CLI is the
276
267
  * single seam that places `review` + `predicateTree` on the wire
@@ -366,9 +357,11 @@ function parseConfidence(raw: string): number {
366
357
 
367
358
  /** Parse a strictly positive integer (windowSeconds, validUntilLedger,
368
359
  * invocationLimit, oracleParams bounds). The core re-validates these with
369
- * field-specific caps; the CLI just enforces "looks like an integer > 0". */
360
+ * field-specific caps; the CLI just enforces "looks like an integer > 0".
361
+ * The `^[0-9]+$` regex already pins the shape to a non-negative integer, so
362
+ * the only thing left to check is "not zero". */
370
363
  function parsePositiveInt(raw: string, flagName: string): number {
371
- if (!POSITIVE_INT_RE.test(raw)) {
364
+ if (!POSITIVE_INT_RE.test(raw) || raw === '0') {
372
365
  throw new CliError({
373
366
  code: 'CLI_MISSING_ARG',
374
367
  message: `synthesize: ${flagName} "${raw}" must be a positive integer`,
@@ -376,16 +369,7 @@ function parsePositiveInt(raw: string, flagName: string): number {
376
369
  retryable: false,
377
370
  })
378
371
  }
379
- const n = Number(raw)
380
- if (!Number.isInteger(n) || n <= 0) {
381
- throw new CliError({
382
- code: 'CLI_MISSING_ARG',
383
- message: `synthesize: ${flagName} "${raw}" must be a positive integer`,
384
- severity: 'error',
385
- retryable: false,
386
- })
387
- }
388
- return n
372
+ return Number(raw)
389
373
  }
390
374
 
391
375
  /** Collect ALL values for a repeatable `--<name> <value>` / `--<name>=<value>`
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
- // apps/policy-builder-cli/src/index.ts - public re-exports for the CLI package.
1
+ // packages/policy-builder-cli/src/index.ts - public re-exports for the CLI package.
2
2
 
3
+ export { runMergeCommand } from './commands/merge.ts'
3
4
  export { runRecordCommand } from './commands/record.ts'
4
5
  export { runSynthesizeCommand } from './commands/synthesize.ts'
5
6
  export { formatToolResponse, readJsonFile, writeJsonFile } from './output.ts'