@controllerai/cli 0.2.9 → 0.2.11

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.
Files changed (57) hide show
  1. package/dist/api/client.js +66 -13
  2. package/dist/api/client.js.map +1 -1
  3. package/dist/argv.js +330 -29
  4. package/dist/argv.js.map +1 -1
  5. package/dist/commands/agent.js +208 -8
  6. package/dist/commands/agent.js.map +1 -1
  7. package/dist/commands/auth.js +21 -5
  8. package/dist/commands/auth.js.map +1 -1
  9. package/dist/commands/connect.js +26 -7
  10. package/dist/commands/connect.js.map +1 -1
  11. package/dist/commands/doctor.js +71 -4
  12. package/dist/commands/doctor.js.map +1 -1
  13. package/dist/commands/exec.js +161 -49
  14. package/dist/commands/exec.js.map +1 -1
  15. package/dist/commands/expr.js +284 -53
  16. package/dist/commands/expr.js.map +1 -1
  17. package/dist/commands/flow.js +58 -14
  18. package/dist/commands/flow.js.map +1 -1
  19. package/dist/commands/node.js +746 -173
  20. package/dist/commands/node.js.map +1 -1
  21. package/dist/commands/organize.js +1 -1
  22. package/dist/commands/organize.js.map +1 -1
  23. package/dist/commands/run.js +165 -33
  24. package/dist/commands/run.js.map +1 -1
  25. package/dist/commands/schema.js +8 -7
  26. package/dist/commands/schema.js.map +1 -1
  27. package/dist/commands/signup.js +250 -0
  28. package/dist/commands/signup.js.map +1 -0
  29. package/dist/commands/workflow.js +151 -24
  30. package/dist/commands/workflow.js.map +1 -1
  31. package/dist/config.js +160 -9
  32. package/dist/config.js.map +1 -1
  33. package/dist/context.js +66 -5
  34. package/dist/context.js.map +1 -1
  35. package/dist/errors.js +41 -8
  36. package/dist/errors.js.map +1 -1
  37. package/dist/index.js +13 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/nodeProps.js +333 -10
  40. package/dist/nodeProps.js.map +1 -1
  41. package/dist/output.js +6 -1
  42. package/dist/output.js.map +1 -1
  43. package/package.json +1 -1
  44. package/skills/controllerai-agents/SKILL.md +30 -10
  45. package/skills/controllerai-agents/references/instructions-and-versioning.md +1 -1
  46. package/skills/controllerai-data/SKILL.md +17 -0
  47. package/skills/controllerai-data/references/data-rails.md +1 -1
  48. package/skills/controllerai-expressions/SKILL.md +7 -3
  49. package/skills/controllerai-flow-control/SKILL.md +2 -0
  50. package/skills/controllerai-node-config/SKILL.md +33 -6
  51. package/skills/controllerai-node-config/references/prop-and-readiness.md +34 -0
  52. package/skills/controllerai-runs/SKILL.md +14 -4
  53. package/skills/controllerai-runs/references/debugging.md +1 -1
  54. package/skills/controllerai-start/SKILL.md +4 -1
  55. package/skills/controllerai-workflows/SKILL.md +14 -8
  56. package/skills/controllerai-workflows/references/command-map.md +3 -2
  57. package/skills/manifest.json +59 -59
@@ -1,14 +1,18 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { requireContext } from '../context.js';
3
- import { validationFailed, validationMessage } from './expr.js';
3
+ import { prepareWritableProp, requireReadbackProp, validationFailed, validationMessage, } from './expr.js';
4
4
  import { CaiError, userError } from '../errors.js';
5
5
  import { resolveJsSource } from '../jsSource.js';
6
- import { buildNodeReadiness, expressionReadbackChannel, nodePropsFromPayload, normalizeNodePropWrites, } from '../nodeProps.js';
6
+ import { arrayExpressionReplacementWarning, assertArrayExpressionWriteSupported, buildNodeReadiness, expressionPersistenceMismatchMessage, nodePropsFromPayload, normalizeNodePropWrites, remoteOptionsCompiledLiteralNote, subflowTargetFromPayload, verifyExpressionPersistence, } from '../nodeProps.js';
7
7
  import { emit, printKeyValues, printTable, truncate, warn } from '../output.js';
8
8
  const readUtf8 = (source, encoding) => readFileSync(source, encoding);
9
9
  const loadProps = async (api, workflowId, nodeId) => {
10
- const props = await api.tool(workflowId, 'get_node_props', { nodeId });
11
- return nodePropsFromPayload(props);
10
+ const payload = await api.tool(workflowId, 'get_node_props', { nodeId });
11
+ const subflowTarget = subflowTargetFromPayload(payload);
12
+ return {
13
+ props: nodePropsFromPayload(payload),
14
+ ...(subflowTarget ? { subflowTarget } : {}),
15
+ };
12
16
  };
13
17
  /**
14
18
  * get_node exposes processOutput after a successful typed run or documented
@@ -35,19 +39,16 @@ const addResolvedOutputType = async (api, workflowId, record) => {
35
39
  return record;
36
40
  }
37
41
  };
38
- /** Fail closed: a write without current prop metadata is unsafe. */
39
- const findProp = async (api, workflowId, nodeId, propName) => {
40
- const rows = await loadProps(api, workflowId, nodeId);
41
- const prop = rows.find(p => p.name === propName);
42
- if (!prop) {
43
- throw userError(`Node ${nodeId} has no prop "${propName}". Re-read \`cai node props ${nodeId}\`; ` +
44
- 'a selector, connection, or reload may need to be configured first.', 'UNKNOWN_PROP');
45
- }
46
- if (prop.enabled === false || prop.source === 'propsCatalog') {
47
- throw userError(`Prop "${propName}" is optional but not materialized. Run ` +
48
- `\`cai node enable-prop ${nodeId} --prop ${propName}\`, then re-read props.`, 'PROP_NOT_ENABLED');
49
- }
50
- return prop;
42
+ const loadNodeReadiness = async (api, workflowId, nodeId) => {
43
+ const [record, loadedProps] = await Promise.all([
44
+ api.tool(workflowId, 'get_node', { nodeId }),
45
+ loadProps(api, workflowId, nodeId),
46
+ ]);
47
+ const resolvedRecord = await addResolvedOutputType(api, workflowId, record);
48
+ return {
49
+ record: resolvedRecord,
50
+ readiness: buildNodeReadiness(nodeId, resolvedRecord, loadedProps.props, loadedProps.subflowTarget),
51
+ };
51
52
  };
52
53
  /**
53
54
  * Prop types whose stored shape pairs a value with the human label the UI displayed.
@@ -63,6 +64,9 @@ const isOptionBackedProp = (prop) => ['DROPDOWN', 'MULTI_SELECT', 'MULTI_SELECT_
63
64
  * Getting this wrong saves cleanly and shows blank chips in the UI (found by CFG-012).
64
65
  */
65
66
  const isMultiSelectProp = (prop) => ['MULTI_SELECT', 'MULTI_SELECT_DROPDOWN'].includes(String(prop.inputType ?? ''));
67
+ const optionValueLabel = (prop, options) => isMultiSelectProp(prop)
68
+ ? options.map(option => ({ value: option.value, label: option.label }))
69
+ : options[0]?.label;
66
70
  /**
67
71
  * Remote option providers commonly search human labels rather than opaque ids.
68
72
  * A supplied --label is therefore the lookup query, never an unverified value
@@ -93,12 +97,69 @@ const isTextExpressionBackedProp = (prop) => ['JSON', 'LONG_TEXT', 'SHORT_TEXT',
93
97
  const fetchPropOptions = async (api, workflowId, nodeId, propName, query) => {
94
98
  const data = await api.tool(workflowId, 'get_prop_options', { nodeId, propName, ...(query ? { query } : {}) });
95
99
  const rows = Array.isArray(data) ? data : (data?.options ?? []);
96
- return rows
97
- .map(o => ({
98
- value: String(o.value ?? o.id ?? ''),
99
- label: String(o.label ?? o.name ?? o.value ?? o.id ?? ''),
100
- }))
101
- .filter(o => o.value !== '');
100
+ const error = !Array.isArray(data) && typeof data?.error === 'string' ? data.error : undefined;
101
+ return {
102
+ options: rows
103
+ .map(o => ({
104
+ value: String(o.value ?? o.id ?? ''),
105
+ label: String(o.label ?? o.name ?? o.value ?? o.id ?? ''),
106
+ }))
107
+ .filter(o => o.value !== ''),
108
+ ...(error ? { error } : {}),
109
+ };
110
+ };
111
+ export const parseNodeOptionsPrevContext = (raw) => {
112
+ let value;
113
+ try {
114
+ value = JSON.parse(raw);
115
+ }
116
+ catch (error) {
117
+ throw userError(`--prev-context must be a JSON object: ${error instanceof Error ? error.message : error}`, 'BAD_OPTION_CONTEXT');
118
+ }
119
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
120
+ throw userError('--prev-context must be a JSON object.', 'BAD_OPTION_CONTEXT');
121
+ }
122
+ return value;
123
+ };
124
+ export const parseNodeOptionOverrides = (entries) => {
125
+ const overrides = new Map();
126
+ for (const entry of entries) {
127
+ const separator = entry.indexOf('=');
128
+ const name = separator >= 0 ? entry.slice(0, separator).trim() : '';
129
+ if (separator < 0 || !name) {
130
+ throw userError(`--for must use <prop>=<value>; received ${JSON.stringify(entry)}.`, 'BAD_OPTION_OVERRIDE');
131
+ }
132
+ const rawValue = entry.slice(separator + 1);
133
+ let value;
134
+ try {
135
+ value = JSON.parse(rawValue);
136
+ }
137
+ catch {
138
+ value = rawValue;
139
+ }
140
+ overrides.set(name, value);
141
+ }
142
+ return Object.fromEntries(overrides);
143
+ };
144
+ const collectNodeOptionOverride = (value, previous = []) => [
145
+ ...previous,
146
+ value,
147
+ ];
148
+ const dynamicUpstreamOptionError = (value) => typeof value === 'string' &&
149
+ /^Upstream prop "[^"]+" holds a dynamic value, so no option list exists\./i.test(value.trim());
150
+ const DYNAMIC_UPSTREAM_RAW_VALUE_NOTE = 'Value stored WITHOUT validation against provider options because an upstream prop holds a dynamic value.';
151
+ const dynamicUpstreamOptionNext = (nodeId, propName) => `cai node set ${nodeId} --prop ${propName} --value <exact-id>; ` +
152
+ 'options cannot be listed while an upstream prop is dynamic (or use --js for an expression)';
153
+ const blockedUpstreamOptionMessage = (value) => {
154
+ if (!(value instanceof CaiError))
155
+ return undefined;
156
+ const match = /Cannot load options for "([^"]+)": earlier prop "([^"]+)" is unset\b/i.exec(value.message);
157
+ return match ? { propName: match[1], blockedBy: match[2] } : undefined;
158
+ };
159
+ const throwBlockedUpstreamOption = (nodeId, propName, blockedBy) => {
160
+ const next = `cai node set ${nodeId} --prop ${blockedBy} --value <value>`;
161
+ throw userError(`Cannot load options for "${propName}": earlier prop "${blockedBy}" is unset. ` +
162
+ `Set it first with \`${next}\`; nothing was written.`, 'OPTIONS_UNAVAILABLE');
102
163
  };
103
164
  const readObjectOption = (source, flag) => {
104
165
  const raw = source.trimStart().startsWith('{') ? source : readFileSync(source, 'utf8');
@@ -131,8 +192,10 @@ export const readNodeOutputSample = (source, reader = readUtf8) => {
131
192
  catch (error) {
132
193
  throw userError(`--sample-file must contain valid JSON: ${error instanceof Error ? error.message : error}`, 'BAD_SAMPLE');
133
194
  }
134
- if (!sample || typeof sample !== 'object' || Array.isArray(sample)) {
135
- throw userError('--sample-file must contain a JSON object.', 'BAD_SAMPLE');
195
+ if (sample === null ||
196
+ typeof sample !== 'object' ||
197
+ (Array.isArray(sample) && sample.length === 0)) {
198
+ throw userError('--sample-file must contain documented provider response data: a JSON object or a non-empty JSON array of items.', 'BAD_SAMPLE');
136
199
  }
137
200
  return sample;
138
201
  };
@@ -140,14 +203,14 @@ const nodeProcessOutput = (record) => record.processOutput ?? record.data?.proce
140
203
  const renderNodeOutputType = (dataType, declared) => declared && typeof dataType === 'string'
141
204
  ? `${dataType} (declared — unverified)`
142
205
  : dataType;
143
- const shellQuote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
144
- const requireCompletedPropReload = (result, nodeId, propName, retryCommand) => {
206
+ const requireCompletedPropReload = (result, nodeId, propName) => {
145
207
  const reload = result;
146
208
  if (reload?.propsReloaded !== false)
147
209
  return;
148
210
  const reloadError = typeof reload.reloadError === 'string' && reload.reloadError.trim()
149
211
  ? reload.reloadError.trim()
150
212
  : 'The backend did not provide a reload error.';
213
+ const retryCommand = `cai node reload ${nodeId}`;
151
214
  const inspectCommand = `cai node props ${nodeId} --json`;
152
215
  throw new CaiError(`Prop "${propName}" was updated, but its dependent props were not reloaded: ` +
153
216
  `${reloadError}\nThe node may still contain stale dependent values. Retry the reload with ` +
@@ -162,6 +225,36 @@ const requireCompletedPropReload = (result, nodeId, propName, retryCommand) => {
162
225
  },
163
226
  });
164
227
  };
228
+ const requireCompletedExplicitPropReload = (result, nodeId) => {
229
+ const reload = result;
230
+ if (reload?.reconcileSkipped === 'unset_selector')
231
+ return;
232
+ if (reload?.propsReloaded !== false)
233
+ return;
234
+ const reloadError = typeof reload.reloadError === 'string' && reload.reloadError.trim()
235
+ ? reload.reloadError.trim()
236
+ : 'The backend did not provide a reload error.';
237
+ const retryCommand = `cai node reload ${nodeId}`;
238
+ const inspectCommand = `cai node props ${nodeId} --json`;
239
+ throw new CaiError(`Node "${nodeId}" dependent props were not reloaded: ${reloadError}\n` +
240
+ `The node may still contain stale dependent values. Retry with \`${retryCommand}\`, then ` +
241
+ `inspect \`${inspectCommand}\` before configuring or running it.`, {
242
+ code: 'PROPS_RELOAD_FAILED',
243
+ detail: {
244
+ nodeId,
245
+ propsReloaded: false,
246
+ reloadError,
247
+ recoveryCommands: [retryCommand, inspectCommand],
248
+ },
249
+ });
250
+ };
251
+ const nodeReadinessDelta = (before, after) => ({
252
+ before: before.readinessState,
253
+ after: after.readinessState,
254
+ blockersResolved: before.blockedBy.filter(blocker => !after.blockedBy.includes(blocker)),
255
+ blockersAdded: after.blockedBy.filter(blocker => !before.blockedBy.includes(blocker)),
256
+ nextChanged: before.next === after.next ? null : { before: before.next, after: after.next },
257
+ });
165
258
  const searchScore = (intent, component) => {
166
259
  const normalize = (value) => value
167
260
  .toLowerCase()
@@ -169,14 +262,22 @@ const searchScore = (intent, component) => {
169
262
  .trim();
170
263
  const query = normalize(intent);
171
264
  const terms = query.split(/\s+/).filter(Boolean);
265
+ if (terms.length === 0)
266
+ return 0;
172
267
  const name = normalize(component.name ?? '');
268
+ const nodeName = normalize(component.nodeName ?? '');
173
269
  const description = normalize(component.description ?? '');
174
270
  const props = normalize((component.propNames ?? []).join(' '));
175
- let score = name === query ? 100 : name.includes(query) ? 50 : 0;
271
+ const searchableText = [name, nodeName, description];
272
+ let score = searchableText.some(value => value === query)
273
+ ? 100
274
+ : searchableText.some(value => value.includes(query))
275
+ ? 50
276
+ : 0;
176
277
  for (const term of terms) {
177
- if (name.split(' ').includes(term))
278
+ if ([name, nodeName].some(value => value.split(' ').includes(term)))
178
279
  score += 12;
179
- else if (name.includes(term))
280
+ else if ([name, nodeName].some(value => value.includes(term)))
180
281
  score += 7;
181
282
  if (description.includes(term))
182
283
  score += 3;
@@ -185,6 +286,79 @@ const searchScore = (intent, component) => {
185
286
  }
186
287
  return score;
187
288
  };
289
+ const conceptHintDefinitions = [
290
+ {
291
+ concept: 'approval',
292
+ skill: 'controllerai-agents',
293
+ phrases: [
294
+ 'approval',
295
+ 'approvals',
296
+ 'confirmation',
297
+ 'confirmations',
298
+ 'hitl',
299
+ 'human in the loop',
300
+ ],
301
+ message: 'Configure confirmation on agent tools, then approve or deny pending requests in the conversation.',
302
+ commands: [
303
+ 'cai agent tools <agentId>',
304
+ 'cai agent add-action <agentId> --integration <slug> --action <key> --connection <id> --require-confirmation',
305
+ 'cai agent add-workflow <agentId> --workflow-id <workflowId> --flow <flowId> --require-confirmation',
306
+ 'cai agent update-tool <agentId> --tool <toolId> --require-confirmation',
307
+ 'cai agent conversation approve <conversationId> --request <requestId>',
308
+ 'cai agent conversation deny <conversationId> --request <requestId>',
309
+ ],
310
+ },
311
+ {
312
+ concept: 'memory',
313
+ skill: 'controllerai-data',
314
+ phrases: ['memory', 'memories'],
315
+ message: 'Use workflow data for durable structured state; the CLI has no separate agent-memory command.',
316
+ commands: [
317
+ 'cai schema list',
318
+ 'cai data collections',
319
+ 'cai data query --type <dataType>',
320
+ ],
321
+ },
322
+ {
323
+ concept: 'knowledge',
324
+ skill: 'controllerai-agents',
325
+ phrases: ['knowledge'],
326
+ message: 'List or add files in an agent knowledge space.',
327
+ commands: [
328
+ 'cai agent files <agentId> --space knowledge',
329
+ 'cai agent add-file <agentId> --file <path> --space knowledge',
330
+ ],
331
+ },
332
+ {
333
+ concept: 'trigger',
334
+ skill: 'controllerai-triggers',
335
+ phrases: ['trigger', 'triggers', 'webhook', 'webhooks', 'schedule', 'schedules'],
336
+ message: 'If you are looking for trigger nodes rather than actions, use `cai trigger search`.',
337
+ commands: ['cai trigger search <query>'],
338
+ },
339
+ ];
340
+ const conceptHintsFor = (query) => {
341
+ const normalized = query
342
+ .toLowerCase()
343
+ .replace(/[^a-z0-9]+/g, ' ')
344
+ .trim();
345
+ if (!normalized)
346
+ return [];
347
+ const padded = ` ${normalized} `;
348
+ return conceptHintDefinitions
349
+ .filter(definition => definition.phrases.some(phrase => padded.includes(` ${phrase} `)))
350
+ .map(({ phrases: _phrases, ...hint }) => hint);
351
+ };
352
+ const printConceptHints = (hints) => {
353
+ if (hints.length === 0)
354
+ return;
355
+ process.stdout.write('\nConcept hints:\n');
356
+ for (const hint of hints) {
357
+ process.stdout.write(`${hint.concept} (${hint.skill}): ${hint.message}\n`);
358
+ for (const command of hint.commands)
359
+ process.stdout.write(` ${command}\n`);
360
+ }
361
+ };
188
362
  /**
189
363
  * `add_node` needs BOTH nodeSlug and nodeKey, but component keys returned by
190
364
  * search are already `<nodeSlug>-<component>` — so the slug is recoverable.
@@ -212,6 +386,95 @@ const resolveSlug = async (api, workflowId, componentKey, explicit) => {
212
386
  throw userError(`Cannot infer the node slug from component key "${componentKey}". Pass --node <slug> ` +
213
387
  '(the `nodeSlug` column from `cai node components`).', 'UNRESOLVED_SLUG');
214
388
  };
389
+ const rethrowNodeAddOptionalPropError = (error) => {
390
+ if (error instanceof CaiError) {
391
+ const match = /^Optional prop "([^"]+)" not found on this component — available optional props:\s*(.*)$/s.exec(error.message);
392
+ if (match) {
393
+ const propName = match[1];
394
+ const available = match[2].trim() || '(empty)';
395
+ throw new CaiError(`Optional prop "${propName}" is not in this component's STATIC catalog; ` +
396
+ `available optional props: ${available}. Dynamically-generated props only materialize ` +
397
+ 'after creation. Create the node without that --enable-prop, then run ' +
398
+ `\`cai node set <newNodeId> --prop ${propName} --value <value>\` (which auto-enables ` +
399
+ `it) or \`cai node enable-prop <newNodeId> --prop ${propName}\`.`, {
400
+ exitCode: error.exitCode,
401
+ code: 'OPTIONAL_PROP_NOT_STATIC',
402
+ detail: error.detail,
403
+ scrubbed: error.scrubbed,
404
+ });
405
+ }
406
+ }
407
+ throw error;
408
+ };
409
+ const textExpressionsIn = (value) => {
410
+ if (Array.isArray(value))
411
+ return value.flatMap(textExpressionsIn);
412
+ if (!value || typeof value !== 'object')
413
+ return [];
414
+ const record = value;
415
+ if (record.type === 'TextExpression')
416
+ return [record];
417
+ return Object.values(record).flatMap(textExpressionsIn);
418
+ };
419
+ const textExpressionTerminalType = (expression) => {
420
+ const chips = (Array.isArray(expression.entries) ? expression.entries : [])
421
+ .flatMap(row => (Array.isArray(row) ? row : []))
422
+ .filter((entry) => entry !== null && typeof entry === 'object' && !Array.isArray(entry));
423
+ let terminal = chips.at(-1);
424
+ let inferredType;
425
+ while (terminal) {
426
+ if (typeof terminal.returnDataType === 'string')
427
+ inferredType = terminal.returnDataType;
428
+ terminal =
429
+ terminal.next && typeof terminal.next === 'object' && !Array.isArray(terminal.next)
430
+ ? terminal.next
431
+ : undefined;
432
+ }
433
+ return inferredType;
434
+ };
435
+ /**
436
+ * Protect older backends from advanced add_node expression writes when the
437
+ * component's static catalog is available. Dynamic props and catalog lookup
438
+ * failures deliberately fall through to the backend's authoritative guard.
439
+ */
440
+ const guardInitialArrayExpressionWrites = async (api, workflowId, nodeSlug, nodeKey, writes) => {
441
+ const expressionWrites = Object.entries(writes ?? {}).filter(([, write]) => {
442
+ const valueExpressions = textExpressionsIn(write.value);
443
+ return (typeof write.valueJs === 'string' ||
444
+ typeof write.expressionValueJs === 'string' ||
445
+ write.expressionValue !== undefined ||
446
+ valueExpressions.length > 0 ||
447
+ write.useExpression === true);
448
+ });
449
+ if (expressionWrites.length === 0)
450
+ return;
451
+ let component;
452
+ try {
453
+ component = await api.tool(workflowId, 'get_node_component', { nodeSlug, nodeKey });
454
+ }
455
+ catch {
456
+ return;
457
+ }
458
+ const catalogProps = nodePropsFromPayload(component?.props ?? component?.component?.props ?? []);
459
+ for (const [propName, write] of expressionWrites) {
460
+ const prop = catalogProps.find(row => (row.name ?? row.key) === propName);
461
+ if (!prop || !String(prop.inputType ?? '').endsWith('_ARRAY'))
462
+ continue;
463
+ if (typeof write.valueJs === 'string') {
464
+ assertArrayExpressionWriteSupported('<newNodeId>', propName, prop, undefined);
465
+ }
466
+ if (typeof write.expressionValueJs === 'string' ||
467
+ write.expressionValue !== undefined ||
468
+ write.useExpression === true) {
469
+ assertArrayExpressionWriteSupported('<newNodeId>', propName, prop, undefined, 'expressionValue');
470
+ }
471
+ const valueExpressions = textExpressionsIn(write.value);
472
+ for (const expression of valueExpressions) {
473
+ assertArrayExpressionWriteSupported('<newNodeId>', propName, prop, textExpressionTerminalType(expression), 'value');
474
+ }
475
+ }
476
+ };
477
+ const isUnknownToolError = (error) => error instanceof CaiError && /unknown tool|method not found/i.test(error.message);
215
478
  export const registerNode = (program) => {
216
479
  const node = program.command('node').description('Nodes — search, add, configure, wire');
217
480
  node
@@ -223,6 +486,7 @@ export const registerNode = (program) => {
223
486
  .description('Search registry + catalog nodes (ranked by featuredWeight, not relevance)')
224
487
  .action(async (query, opts, command) => {
225
488
  const { api, workflowId } = requireContext(command);
489
+ const conceptHints = conceptHintsFor(query);
226
490
  const result = await api.tool(workflowId, 'search_nodes', {
227
491
  query,
228
492
  limit: Number(opts.limit),
@@ -230,15 +494,18 @@ export const registerNode = (program) => {
230
494
  ...(opts.events ? { hasEvents: true } : {}),
231
495
  });
232
496
  const rows = result?.data ?? [];
233
- emit({ items: rows, pagination: result?.pagination ?? null }, () => printTable(['slug', 'name', 'source', 'actions', 'events', 'auth', 'weight'], rows.map((n) => [
234
- n.slug,
235
- n.name,
236
- n.source,
237
- n.actionsCount,
238
- n.eventsCount,
239
- n.authRequired ? 'yes' : 'no',
240
- n.featuredWeight,
241
- ])));
497
+ emit({ items: rows, pagination: result?.pagination ?? null, conceptHints }, () => {
498
+ printTable(['slug', 'name', 'source', 'actions', 'events', 'auth', 'weight'], rows.map((n) => [
499
+ n.slug,
500
+ n.name,
501
+ n.source,
502
+ n.actionsCount,
503
+ n.eventsCount,
504
+ n.authRequired ? 'yes' : 'no',
505
+ n.featuredWeight,
506
+ ]));
507
+ printConceptHints(conceptHints);
508
+ });
242
509
  });
243
510
  node
244
511
  .command('find')
@@ -248,6 +515,7 @@ export const registerNode = (program) => {
248
515
  .description('Find ranked action components and return exact nodeSlug/nodeKey pairs')
249
516
  .action(async (intent, opts, command) => {
250
517
  const { api, workflowId } = requireContext(command);
518
+ const conceptHints = conceptHintsFor(intent);
251
519
  let slugs;
252
520
  if (opts.node) {
253
521
  slugs = [opts.node];
@@ -266,23 +534,56 @@ export const registerNode = (program) => {
266
534
  query: intent,
267
535
  limit: 20,
268
536
  });
269
- return (result?.data ?? []).map((component) => ({
270
- ...component,
271
- nodeSlug: component.nodeSlug ?? nodeSlug,
272
- }));
537
+ return {
538
+ rows: (result?.data ?? []).map((component) => ({
539
+ ...component,
540
+ nodeSlug: component.nodeSlug ?? nodeSlug,
541
+ })),
542
+ hasMore: result?.pagination?.hasMore === true,
543
+ };
273
544
  }));
274
- const rows = batches
275
- .flat()
276
- .map(component => ({ ...component, score: searchScore(intent, component) }))
277
- .sort((left, right) => right.score - left.score || left.key.localeCompare(right.key))
278
- .slice(0, Math.max(1, Number(opts.limit) || 8));
279
- emit({ intent, items: rows }, () => printTable(['nodeSlug', 'nodeKey', 'action', 'auth', 'score'], rows.map(row => [
280
- row.nodeSlug,
281
- row.key,
282
- row.name,
283
- row.nodeAuthRequired ? 'yes' : 'no',
284
- row.score,
285
- ])));
545
+ const hasMore = batches.some(batch => batch.hasMore);
546
+ const scoredRows = batches
547
+ .flatMap(batch => batch.rows)
548
+ .filter(component => component.type === undefined ||
549
+ component.type === null ||
550
+ String(component.type).toLowerCase() === 'action')
551
+ .map(component => ({ ...component, score: searchScore(intent, component) }));
552
+ const hasPositiveScore = scoredRows.some(component => component.score > 0);
553
+ const rankedRows = hasPositiveScore
554
+ ? scoredRows
555
+ .filter(component => component.score > 0)
556
+ .sort((left, right) => right.score - left.score || left.key.localeCompare(right.key))
557
+ : scoredRows;
558
+ const rows = rankedRows.slice(0, Math.max(1, Number(opts.limit) || 8));
559
+ const rankingSource = scoredRows.length === 0 ? 'none' : hasPositiveScore ? 'local-literal' : 'server';
560
+ emit({
561
+ intent,
562
+ items: rows,
563
+ rankingSource,
564
+ pagination: { hasMore },
565
+ conceptHints,
566
+ }, () => {
567
+ if (rows.length === 0) {
568
+ process.stdout.write('No relevant components matched this intent.\n');
569
+ }
570
+ else {
571
+ printTable(['nodeSlug', 'nodeKey', 'action', 'auth', 'score'], rows.map(row => [
572
+ row.nodeSlug,
573
+ row.key,
574
+ row.name,
575
+ row.nodeAuthRequired ? 'yes' : 'no',
576
+ row.score,
577
+ ]));
578
+ }
579
+ if (rankingSource === 'server') {
580
+ process.stdout.write('Note: no local literal match; results remain in the server-ranked order.\n');
581
+ }
582
+ if (hasMore) {
583
+ process.stdout.write('Note: the server has more component results beyond this page; narrow the intent or use --node.\n');
584
+ }
585
+ printConceptHints(conceptHints);
586
+ });
286
587
  });
287
588
  node
288
589
  .command('components')
@@ -319,24 +620,32 @@ export const registerNode = (program) => {
319
620
  const props = opts.props
320
621
  ? normalizeNodePropWrites(readObjectOption(opts.props, '--props'))
321
622
  : undefined;
322
- const result = await api.mutate(workflowId, () => api.tool(workflowId, 'add_node', {
323
- flowId: opts.flow,
324
- nodeSlug,
325
- nodeKey: opts.key,
326
- connectionId: opts.connection ? Number(opts.connection) : null,
327
- ...(opts.name ? { displayName: opts.name } : {}),
328
- ...(props ? { props } : {}),
329
- ...(opts.enableProp?.length ? { enableOptionalProps: opts.enableProp } : {}),
330
- ...(opts.after
331
- ? { connectFrom: { sourceNodeId: opts.after, sourceOutput: opts.output } }
332
- : {}),
333
- }));
334
- const [rawRecord, currentProps] = await Promise.all([
623
+ await guardInitialArrayExpressionWrites(api, workflowId, nodeSlug, opts.key, props);
624
+ let result;
625
+ try {
626
+ result = await api.mutate(workflowId, () => api.tool(workflowId, 'add_node', {
627
+ flowId: opts.flow,
628
+ nodeSlug,
629
+ nodeKey: opts.key,
630
+ connectionId: opts.connection ? Number(opts.connection) : null,
631
+ ...(opts.name ? { displayName: opts.name } : {}),
632
+ ...(props ? { props } : {}),
633
+ ...(opts.enableProp?.length ? { enableOptionalProps: opts.enableProp } : {}),
634
+ ...(opts.after
635
+ ? { connectFrom: { sourceNodeId: opts.after, sourceOutput: opts.output } }
636
+ : {}),
637
+ }));
638
+ }
639
+ catch (error) {
640
+ rethrowNodeAddOptionalPropError(error);
641
+ }
642
+ const [rawRecord, loadedProps] = await Promise.all([
335
643
  api.tool(workflowId, 'get_node', { nodeId: result.nodeId }),
336
644
  loadProps(api, workflowId, result.nodeId),
337
645
  ]);
646
+ const currentProps = loadedProps.props;
338
647
  const record = await addResolvedOutputType(api, workflowId, rawRecord);
339
- const readiness = buildNodeReadiness(result.nodeId, record, currentProps);
648
+ const readiness = buildNodeReadiness(result.nodeId, record, currentProps, loadedProps.subflowTarget);
340
649
  const requestedPropNames = Object.keys(props ?? {});
341
650
  const propsReadback = currentProps.filter(prop => requestedPropNames.includes(prop.name ?? prop.key ?? ''));
342
651
  emit({ ...result, normalizedProps: props ?? {}, propsReadback, readiness }, () => printKeyValues([
@@ -457,15 +766,29 @@ export const registerNode = (program) => {
457
766
  node
458
767
  .command('declare-output')
459
768
  .argument('<nodeId>')
460
- .requiredOption('--sample-file <file|->', 'documented response data JSON object; `-` reads stdin')
769
+ .requiredOption('--sample-file <file|->', 'documented provider response data: a JSON object or a non-empty JSON array of items; `-` reads stdin')
461
770
  .description('Declare an unverified dynamic output contract from documented response data')
462
771
  .action(async (nodeId, opts, command) => {
463
772
  const sample = readNodeOutputSample(opts.sampleFile);
464
773
  const { api, workflowId } = requireContext(command);
465
- const result = await api.mutate(workflowId, () => api.tool(workflowId, 'declare_node_output_type', {
466
- nodeId,
467
- sample,
468
- }));
774
+ let result;
775
+ try {
776
+ result = await api.mutate(workflowId, () => api.tool(workflowId, 'declare_node_output_type', {
777
+ nodeId,
778
+ sample,
779
+ }));
780
+ }
781
+ catch (error) {
782
+ if (Array.isArray(sample) && error instanceof CaiError) {
783
+ throw new CaiError(`${error.message}\nArray samples require a newer backend with list-root output declaration support.`, {
784
+ exitCode: error.exitCode,
785
+ code: error.code,
786
+ detail: error.detail,
787
+ scrubbed: error.scrubbed,
788
+ });
789
+ }
790
+ throw error;
791
+ }
469
792
  emit(result, () => {
470
793
  printKeyValues([
471
794
  ['dataTypeName', result.dataTypeName],
@@ -484,7 +807,17 @@ export const registerNode = (program) => {
484
807
  const { api, workflowId } = requireContext(command);
485
808
  const data = await api.tool(workflowId, 'get_node_props', { nodeId });
486
809
  const props = nodePropsFromPayload(data);
487
- emit(data, () => printTable(['name', 'inputType', 'required', 'enabled', 'value', 'valueJs', 'expressionValueJs'], props.map(p => [
810
+ const hasAvailabilityMetadata = props.some(prop => Object.hasOwn(prop, 'optionsAvailability'));
811
+ emit(data, () => printTable([
812
+ 'name',
813
+ 'inputType',
814
+ 'required',
815
+ 'enabled',
816
+ 'value',
817
+ 'valueJs',
818
+ 'expressionValueJs',
819
+ ...(hasAvailabilityMetadata ? ['optionsAvailability', 'blockedBy'] : []),
820
+ ], props.map(p => [
488
821
  p.name ?? p.key,
489
822
  p.inputType,
490
823
  p.required ? 'yes' : '',
@@ -492,49 +825,99 @@ export const registerNode = (program) => {
492
825
  truncate(p.value, 40),
493
826
  truncate(p.valueJs, 40),
494
827
  truncate(p.expressionValueJs, 40),
828
+ ...(hasAvailabilityMetadata ? [p.optionsAvailability ?? '', p.blockedBy ?? ''] : []),
495
829
  ])));
496
830
  });
831
+ node
832
+ .command('reload')
833
+ .argument('<nodeId>')
834
+ .description('Reload and reconcile dependent props, then report the readiness change')
835
+ .action(async (nodeId, _opts, command) => {
836
+ const { api, workflowId } = requireContext(command);
837
+ const before = await loadNodeReadiness(api, workflowId, nodeId);
838
+ let result;
839
+ try {
840
+ result = await api.mutate(workflowId, () => api.tool(workflowId, 'reload_node_props', { nodeId }));
841
+ }
842
+ catch (error) {
843
+ if (isUnknownToolError(error)) {
844
+ const recoveryCommand = `cai node set ${nodeId} --prop <selector> --value <value>`;
845
+ throw userError('`cai node reload` is not supported by this backend. Re-set a selector value with ' +
846
+ `\`${recoveryCommand}\` to trigger the implicit reload.`, 'NODE_RELOAD_UNSUPPORTED');
847
+ }
848
+ throw error;
849
+ }
850
+ requireCompletedExplicitPropReload(result, nodeId);
851
+ const after = await loadNodeReadiness(api, workflowId, nodeId);
852
+ const readinessDelta = nodeReadinessDelta(before.readiness, after.readiness);
853
+ const resultFields = result && typeof result === 'object' && !Array.isArray(result)
854
+ ? result
855
+ : { result };
856
+ emit({ ...resultFields, readiness: after.readiness, readinessDelta }, () => printKeyValues([
857
+ ['propsReloaded', resultFields.propsReloaded ?? 'not reported'],
858
+ ['propsReconciled', resultFields.propsReconciled ?? 'not reported'],
859
+ ...(resultFields.reconcileSkipped !== undefined
860
+ ? [['reconcileSkipped', resultFields.reconcileSkipped]]
861
+ : []),
862
+ ...(resultFields.note !== undefined
863
+ ? [['note', resultFields.note]]
864
+ : []),
865
+ ['readiness', `${readinessDelta.before} -> ${readinessDelta.after}`],
866
+ ['blockers resolved', readinessDelta.blockersResolved.join(', ') || 'none'],
867
+ ['blockers added', readinessDelta.blockersAdded.join(', ') || 'none'],
868
+ ['next', after.readiness.next],
869
+ ]));
870
+ });
497
871
  node
498
872
  .command('status')
499
873
  .argument('<nodeId>')
500
874
  .description('Return deterministic configuration readiness and exact blockers')
501
875
  .action(async (nodeId, _opts, command) => {
502
876
  const { api, workflowId } = requireContext(command);
503
- const [record, props] = await Promise.all([
504
- api.tool(workflowId, 'get_node', { nodeId }),
505
- loadProps(api, workflowId, nodeId),
506
- ]);
507
- const resolvedRecord = await addResolvedOutputType(api, workflowId, record);
508
- const readiness = buildNodeReadiness(nodeId, resolvedRecord, props);
509
- const outputTypeDeclared = nodeProcessOutput(resolvedRecord)?.declared === true;
877
+ const { record, readiness } = await loadNodeReadiness(api, workflowId, nodeId);
878
+ const outputTypeDeclared = nodeProcessOutput(record)?.declared === true;
510
879
  const payload = {
511
880
  ...readiness,
512
881
  ...(outputTypeDeclared ? { outputTypeDeclared: true } : {}),
513
882
  };
514
- emit(payload, () => printKeyValues([
515
- ['nodeId', nodeId],
516
- [
517
- 'connection',
518
- payload.missingConnection ? 'missing' : (payload.connectionId ?? 'not required'),
519
- ],
520
- ['required props', payload.missingRequiredProps.join(', ') || 'complete'],
521
- ['reload selectors', payload.pendingReloadProps.join(', ') || 'complete'],
522
- [
523
- 'output type',
524
- `${payload.outputTypeStatus}${payload.outputTypes.length
525
- ? ` (${payload.outputTypes
526
- .map(type => renderNodeOutputType(type, outputTypeDeclared && payload.outputTypeSource === 'processOutput'))
527
- .join(', ')})`
528
- : ''} via ${payload.outputTypeSource}`,
529
- ],
530
- ['ready for expressions', payload.readyForExpressions ? 'yes' : 'no'],
531
- ['ready to run', payload.readyForRun ? 'yes' : 'no'],
532
- [
533
- 'ready for downstream expressions',
534
- payload.readyForDownstreamExpressions ? 'yes' : 'no',
535
- ],
536
- ['next', payload.next],
537
- ]));
883
+ emit(payload, () => {
884
+ printKeyValues([
885
+ ['nodeId', nodeId],
886
+ [
887
+ 'connection',
888
+ payload.missingConnection ? 'missing' : (payload.connectionId ?? 'not required'),
889
+ ],
890
+ ['required props', payload.missingRequiredProps.join(', ') || 'complete'],
891
+ [
892
+ 'required disabled props',
893
+ payload.disabledRequiredProps.join(', ') || 'none',
894
+ ],
895
+ ['reload selectors', payload.pendingReloadProps.join(', ') || 'complete'],
896
+ [
897
+ 'output type',
898
+ `${payload.outputTypeStatus}${payload.outputTypes.length
899
+ ? ` (${payload.outputTypes
900
+ .map(type => renderNodeOutputType(type, outputTypeDeclared && payload.outputTypeSource === 'processOutput'))
901
+ .join(', ')})`
902
+ : ''} via ${payload.outputTypeSource}`,
903
+ ],
904
+ ['readiness state', payload.readinessState],
905
+ ['blockers', payload.blockedBy.join(', ') || 'none'],
906
+ ['ready for expressions', payload.readyForExpressions ? 'yes' : 'no'],
907
+ ['ready to run', payload.readyForRun ? 'yes' : 'no'],
908
+ [
909
+ 'ready for downstream expressions',
910
+ payload.readyForDownstreamExpressions ? 'yes' : 'no',
911
+ ],
912
+ ['next', payload.next],
913
+ ]);
914
+ if (payload.dynamicSelectorNotes.length) {
915
+ process.stdout.write('\ndynamic selector notes\n');
916
+ for (const note of payload.dynamicSelectorNotes) {
917
+ process.stdout.write(` ${note}\n`);
918
+ }
919
+ }
920
+ });
538
921
  });
539
922
  node
540
923
  .command('set')
@@ -555,32 +938,52 @@ export const registerNode = (program) => {
555
938
  if (js !== undefined && opts.value !== undefined) {
556
939
  throw userError('--value is mutually exclusive with --js and --js-file.', 'CONFLICTING_VALUE');
557
940
  }
558
- const prop = await findProp(api, workflowId, nodeId, opts.prop);
559
- const persistedExpressionChannel = expressionReadbackChannel(prop);
941
+ let compiledJs = js;
942
+ const prop = (await prepareWritableProp(api, workflowId, nodeId, opts.prop, candidate => {
943
+ if (compiledJs === undefined && String(candidate.inputType ?? '') === 'CUSTOM_DATA') {
944
+ const literal = parseLiteral(opts.value);
945
+ const supported = typeof literal === 'string' ||
946
+ typeof literal === 'boolean' ||
947
+ (typeof literal === 'number' && Number.isFinite(literal));
948
+ if (!supported) {
949
+ throw userError(`--value for CUSTOM_DATA only supports string, finite number, or boolean literals. ` +
950
+ 'Use a typed --js expression for null, arrays, or objects; nothing was written.', 'CUSTOM_DATA_LITERAL_UNSUPPORTED');
951
+ }
952
+ compiledJs = JSON.stringify(literal);
953
+ }
954
+ if (compiledJs !== undefined) {
955
+ assertArrayExpressionWriteSupported(nodeId, opts.prop, candidate, undefined);
956
+ }
957
+ }));
560
958
  if (opts.expression) {
561
959
  warn('`--expression` is no longer needed; the CLI selects the correct storage channel.');
562
960
  }
563
- if (js !== undefined) {
961
+ let validation;
962
+ if (compiledJs !== undefined) {
564
963
  // Compile FIRST. update_node_prop would also compile, but validating as
565
964
  // a separate read-only call guarantees a failed expression writes
566
965
  // nothing and lets us print the compiler error verbatim.
567
- const validation = await api.tool(workflowId, 'validate_expression', {
568
- js,
966
+ validation = await api.tool(workflowId, 'validate_expression', {
967
+ js: compiledJs,
569
968
  nodeId,
570
969
  propName: opts.prop,
571
970
  });
572
971
  if (validationFailed(validation)) {
573
972
  throw userError(`Expression did not compile — nothing was written.\n\n${validationMessage(validation)}`, 'EXPRESSION_COMPILE_ERROR');
574
973
  }
974
+ assertArrayExpressionWriteSupported(nodeId, opts.prop, prop, validation?.inferredType);
975
+ const replacementWarning = arrayExpressionReplacementWarning(opts.prop, prop);
976
+ if (replacementWarning)
977
+ warn(replacementWarning);
575
978
  for (const w of validation?.warnings ?? [])
576
979
  warn(String(w));
577
980
  }
578
981
  const payload = { nodeId, key: opts.prop };
579
- if (js !== undefined) {
982
+ if (compiledJs !== undefined) {
580
983
  // valueJs is intentionally the one public write channel. The backend
581
984
  // resolves the prop's real compile target and routes the compiled value
582
985
  // into value or expressionValue as appropriate.
583
- payload.valueJs = js;
986
+ payload.valueJs = compiledJs;
584
987
  }
585
988
  else if (isTextExpressionBackedProp(prop)) {
586
989
  // These props persist as a TextExpression wrapping the raw TEXT. The backend does
@@ -606,73 +1009,146 @@ export const registerNode = (program) => {
606
1009
  // half-configured prop (CFG-004, CFG-012). When a provider does not index opaque ids,
607
1010
  // caller-supplied --label is used only as the search query; the returned value must still
608
1011
  // match before anything is persisted.
609
- if (js === undefined) {
1012
+ let skippedDynamicOptionValidation = false;
1013
+ if (compiledJs === undefined) {
610
1014
  const selected = Array.isArray(payload.value) ? payload.value : [payload.value];
611
- const queries = optionLookupQueries(selected, suppliedLabels);
612
- const optionBatches = prop.remoteOptions
613
- ? await Promise.all(queries.map(query => fetchPropOptions(api, workflowId, nodeId, opts.prop, query)))
614
- : [
615
- (prop.options ?? []).map(option => ({
616
- value: String(option.value ?? option.id ?? ''),
617
- label: String(option.label ?? option.name ?? option.value ?? option.id ?? ''),
618
- })),
619
- ];
620
- const options = [
621
- ...new Map(optionBatches.flat().map(option => [option.value, option])).values(),
622
- ];
623
- if (prop.remoteOptions && options.length === 0) {
624
- throw userError(`No live options were returned for ${opts.prop}. Check the connection and earlier ` +
625
- 'selector props. If you copied an opaque value from `cai node options`, also pass ' +
626
- 'its displayed label with `--label`; nothing was written.', 'OPTIONS_UNAVAILABLE');
1015
+ const metadataBlockedBy = typeof prop.blockedBy === 'string' && prop.blockedBy.trim()
1016
+ ? prop.blockedBy.trim()
1017
+ : undefined;
1018
+ if (prop.optionsAvailability === 'blocked_upstream' && metadataBlockedBy) {
1019
+ throwBlockedUpstreamOption(nodeId, opts.prop, metadataBlockedBy);
627
1020
  }
628
- if (options.length) {
629
- const labelFor = (value) => options.find(option => option.value === String(value));
630
- const unknown = selected.filter(value => !labelFor(value)).map(value => String(value));
631
- if (unknown.length) {
632
- throw userError(`${unknown.map(value => `"${value}"`).join(', ')} is not a valid option for ` +
633
- `${opts.prop}. Run \`cai node options ${nodeId} --prop ${opts.prop} --query ` +
634
- `"<display label>"\`, then retry with the exact returned --value and --label. ` +
635
- 'Nothing was written.', 'UNKNOWN_OPTION');
1021
+ skippedDynamicOptionValidation = prop.optionsAvailability === 'dynamic_upstream';
1022
+ if (!skippedDynamicOptionValidation) {
1023
+ const queries = optionLookupQueries(selected, suppliedLabels);
1024
+ let fetched = [];
1025
+ if (prop.remoteOptions) {
1026
+ try {
1027
+ fetched = await Promise.all(queries.map(query => fetchPropOptions(api, workflowId, nodeId, opts.prop, query)));
1028
+ }
1029
+ catch (error) {
1030
+ const blocked = blockedUpstreamOptionMessage(error);
1031
+ if (blocked) {
1032
+ throwBlockedUpstreamOption(nodeId, blocked.propName, blocked.blockedBy);
1033
+ }
1034
+ throw error;
1035
+ }
1036
+ }
1037
+ skippedDynamicOptionValidation = fetched.some(batch => dynamicUpstreamOptionError(batch.error));
1038
+ const optionBatches = prop.remoteOptions
1039
+ ? fetched.map(batch => batch.options)
1040
+ : [
1041
+ (prop.options ?? []).map(option => ({
1042
+ value: String(option.value ?? option.id ?? ''),
1043
+ label: String(option.label ?? option.name ?? option.value ?? option.id ?? ''),
1044
+ })),
1045
+ ];
1046
+ const options = [
1047
+ ...new Map(optionBatches.flat().map(option => [option.value, option])).values(),
1048
+ ];
1049
+ if (!skippedDynamicOptionValidation && prop.remoteOptions && options.length === 0) {
1050
+ throw userError(`No live options were returned for ${opts.prop}. Check the connection and earlier ` +
1051
+ 'selector props. If you copied an opaque value from `cai node options`, also pass ' +
1052
+ 'its displayed label with `--label`; nothing was written.', 'OPTIONS_UNAVAILABLE');
636
1053
  }
637
- if (isOptionBackedProp(prop)) {
638
- const resolved = selected.map(value => labelFor(value));
639
- payload.valueLabel = isMultiSelectProp(prop)
640
- ? resolved.map(option => ({ value: option.value, label: option.label }))
641
- : resolved[0].label;
1054
+ if (!skippedDynamicOptionValidation && options.length) {
1055
+ const labelFor = (value) => options.find(option => option.value === String(value));
1056
+ const unknown = selected.filter(value => !labelFor(value)).map(value => String(value));
1057
+ if (unknown.length) {
1058
+ throw userError(`${unknown.map(value => `"${value}"`).join(', ')} is not a valid option for ` +
1059
+ `${opts.prop}. Run \`cai node options ${nodeId} --prop ${opts.prop} --query ` +
1060
+ `"<display label>"\`, then retry with the exact returned --value and --label. ` +
1061
+ 'Nothing was written.', 'UNKNOWN_OPTION');
1062
+ }
1063
+ if (isOptionBackedProp(prop)) {
1064
+ const resolved = selected.map(value => labelFor(value));
1065
+ payload.valueLabel = optionValueLabel(prop, resolved);
1066
+ }
1067
+ }
1068
+ }
1069
+ if (skippedDynamicOptionValidation && prop.withLabel === true) {
1070
+ const canBuildValueLabel = !isMultiSelectProp(prop) ||
1071
+ (Array.isArray(payload.value) &&
1072
+ payload.value.every(value => typeof value === 'string' ||
1073
+ (typeof value === 'number' && Number.isFinite(value))));
1074
+ if (canBuildValueLabel) {
1075
+ const labels = optionLookupQueries(selected, suppliedLabels);
1076
+ payload.valueLabel = optionValueLabel(prop, selected.map((value, index) => ({
1077
+ value,
1078
+ label: labels[index],
1079
+ })));
642
1080
  }
643
1081
  }
644
1082
  }
645
1083
  const result = await api.mutate(workflowId, () => api.tool(workflowId, 'update_node_prop', payload));
646
- const retryCommand = `cai node set ${nodeId} --prop ${opts.prop}` +
647
- (opts.value !== undefined
648
- ? ` --value ${shellQuote(String(opts.value))}` +
649
- (suppliedLabels.length
650
- ? ` --label ${suppliedLabels.map(label => shellQuote(label)).join(' ')}`
651
- : '')
652
- : ' --value <same-value>');
653
- requireCompletedPropReload(result, nodeId, opts.prop, retryCommand);
654
- const readback = await findProp(api, workflowId, nodeId, opts.prop);
655
- const persistedChannel = js !== undefined ? persistedExpressionChannel : 'value';
656
- if (js !== undefined && !readback[persistedChannel]) {
657
- throw userError(`The write returned successfully but ${opts.prop}.${persistedChannel} did not read back.`, 'READBACK_MISMATCH');
658
- }
659
- if (js === undefined && readback.value === undefined) {
1084
+ if (skippedDynamicOptionValidation)
1085
+ warn(DYNAMIC_UPSTREAM_RAW_VALUE_NOTE);
1086
+ requireCompletedPropReload(result, nodeId, opts.prop);
1087
+ const readback = (await requireReadbackProp(api, workflowId, nodeId, opts.prop));
1088
+ let persistedChannel = 'value';
1089
+ let persistenceKind;
1090
+ let persistedValue;
1091
+ let note;
1092
+ if (compiledJs !== undefined) {
1093
+ const verification = verifyExpressionPersistence(prop, readback, validation?.expression);
1094
+ if (!verification.verified) {
1095
+ throw new CaiError(expressionPersistenceMismatchMessage(opts.prop, verification), {
1096
+ code: 'READBACK_MISMATCH',
1097
+ detail: {
1098
+ nodeId,
1099
+ prop: opts.prop,
1100
+ expected: verification.expected,
1101
+ observed: verification.observed,
1102
+ },
1103
+ });
1104
+ }
1105
+ persistedChannel = verification.channel;
1106
+ persistenceKind = verification.kind;
1107
+ persistedValue = verification.persistedValue;
1108
+ note = remoteOptionsCompiledLiteralNote(prop, verification);
1109
+ }
1110
+ if (compiledJs === undefined && readback?.value === undefined) {
660
1111
  throw userError(`The write returned successfully but ${opts.prop}.value did not read back.`, 'READBACK_MISMATCH');
661
1112
  }
1113
+ const mode = js !== undefined ? 'js' : compiledJs !== undefined ? 'literal (compiled)' : 'static';
1114
+ const humanMode = js !== undefined ? 'expression (compiled)' : mode;
1115
+ const source = js !== undefined
1116
+ ? 'explicit --js'
1117
+ : compiledJs !== undefined
1118
+ ? 'synthesized from --value'
1119
+ : 'explicit --value';
1120
+ const rawDynamicSelectorNote = result && typeof result === 'object' && !Array.isArray(result)
1121
+ ? result.dynamicSelectorNote
1122
+ : undefined;
1123
+ const dynamicSelectorNote = typeof rawDynamicSelectorNote === 'string' && rawDynamicSelectorNote.trim()
1124
+ ? rawDynamicSelectorNote
1125
+ : undefined;
662
1126
  emit({
663
1127
  nodeId,
664
1128
  prop: opts.prop,
665
- mode: js !== undefined ? 'js' : 'static',
1129
+ mode,
1130
+ source,
666
1131
  persistedChannel,
1132
+ ...(persistenceKind ? { kind: persistenceKind, persistedValue } : {}),
1133
+ ...(note ? { note } : {}),
1134
+ ...(dynamicSelectorNote ? { dynamicSelectorNote } : {}),
667
1135
  readback,
668
1136
  result,
669
- }, () => printKeyValues([
670
- ['nodeId', nodeId],
671
- ['prop', opts.prop],
672
- ['mode', js !== undefined ? 'expression (compiled)' : 'static'],
673
- ['persisted channel', persistedChannel],
674
- ['verified', 'yes'],
675
- ]));
1137
+ }, () => {
1138
+ printKeyValues([
1139
+ ['nodeId', nodeId],
1140
+ ['prop', opts.prop],
1141
+ ['mode', humanMode],
1142
+ ['source', source],
1143
+ ['persisted channel', persistedChannel],
1144
+ ...(persistenceKind ? [['kind', persistenceKind]] : []),
1145
+ ['verified', 'yes'],
1146
+ ]);
1147
+ if (note)
1148
+ process.stdout.write(`\n${note}\n`);
1149
+ if (dynamicSelectorNote)
1150
+ process.stdout.write(`\n${dynamicSelectorNote}\n`);
1151
+ });
676
1152
  });
677
1153
  node
678
1154
  .command('set-connection')
@@ -785,6 +1261,59 @@ export const registerNode = (program) => {
785
1261
  const result = await api.mutate(workflowId, () => api.tool(workflowId, 'enable_optional_prop', { nodeId, propName: opts.prop }));
786
1262
  emit(result, () => printKeyValues([['enabled', `${nodeId}.${opts.prop}`]]));
787
1263
  });
1264
+ node
1265
+ .command('disable-prop')
1266
+ .argument('<nodeId>')
1267
+ .requiredOption('--prop <name>', 'enabled optional prop key from `cai node props`')
1268
+ .description('Disable an optional prop. Its stored value is removed when present; a reload prop may prune dependent props')
1269
+ .action(async (nodeId, opts, command) => {
1270
+ const { api, workflowId } = requireContext(command);
1271
+ let result;
1272
+ try {
1273
+ result = await api.mutate(workflowId, () => api.tool(workflowId, 'disable_optional_prop', { nodeId, propName: opts.prop }));
1274
+ }
1275
+ catch (error) {
1276
+ if (isUnknownToolError(error)) {
1277
+ throw new CaiError(`${error.message}\nThis backend predates disable_optional_prop. Disable ` +
1278
+ `"${opts.prop}" on ${nodeId} in the builder instead.`, {
1279
+ exitCode: error.exitCode,
1280
+ code: error.code,
1281
+ detail: error.detail,
1282
+ scrubbed: error.scrubbed,
1283
+ });
1284
+ }
1285
+ throw error;
1286
+ }
1287
+ const { props } = await loadProps(api, workflowId, nodeId);
1288
+ const readback = props.find(row => (row.name ?? row.key) === opts.prop);
1289
+ const disabled = readback === undefined || readback.source === 'propsCatalog' || readback.enabled === false;
1290
+ if (!disabled) {
1291
+ throw new CaiError(`Prop "${opts.prop}" did not read back as disabled after the backend accepted the ` +
1292
+ 'request; its stored value may still be present. ' +
1293
+ `Inspect \`cai node props ${nodeId} --json\` before continuing.`, {
1294
+ code: 'READBACK_MISMATCH',
1295
+ detail: { nodeId, prop: opts.prop, readback },
1296
+ });
1297
+ }
1298
+ const resultFields = result && typeof result === 'object' && !Array.isArray(result)
1299
+ ? result
1300
+ : { result };
1301
+ emit({
1302
+ ...resultFields,
1303
+ nodeId,
1304
+ prop: opts.prop,
1305
+ enabled: false,
1306
+ verified: true,
1307
+ readback: readback ?? null,
1308
+ }, () => printKeyValues([
1309
+ ['disabled', `${nodeId}.${opts.prop}`],
1310
+ [
1311
+ 'stored value',
1312
+ resultFields.storedValueRemoved === true ? 'removed' : 'no stored value existed',
1313
+ ],
1314
+ ['verified', 'yes'],
1315
+ ]));
1316
+ });
788
1317
  node
789
1318
  .command('options')
790
1319
  .argument('<nodeId>')
@@ -792,18 +1321,62 @@ export const registerNode = (program) => {
792
1321
  // keep in sync with fetchPropOptions() below, which reuses this tool
793
1322
  .option('--query <q>', 'search term — strongly preferred over paging')
794
1323
  .option('--page <n>', '0-based page when not searching')
1324
+ .option('--prev-context <json>', 'pagination context returned by a previous options response')
1325
+ .option('--for <prop>=<value>', 'override an earlier prop value for option resolution; JSON literals are accepted, bare text is passed as a string; repeatable', collectNodeOptionOverride)
795
1326
  .description('Resolved remote dropdown options for a prop')
796
1327
  .action(async (nodeId, opts, command) => {
797
- const { api, workflowId } = requireContext(command);
798
1328
  const page = parseNodeOptionPage(opts.page);
799
- const data = await api.tool(workflowId, 'get_prop_options', {
800
- nodeId,
801
- propName: opts.prop,
802
- ...(opts.query ? { query: opts.query } : {}),
803
- ...(page !== undefined ? { page } : {}),
804
- });
1329
+ const prevContext = opts.prevContext !== undefined
1330
+ ? parseNodeOptionsPrevContext(opts.prevContext)
1331
+ : undefined;
1332
+ const formValueOverrides = parseNodeOptionOverrides(opts.for ?? []);
1333
+ const { api, workflowId } = requireContext(command);
1334
+ let data;
1335
+ try {
1336
+ data = await api.tool(workflowId, 'get_prop_options', {
1337
+ nodeId,
1338
+ propName: opts.prop,
1339
+ ...(opts.query ? { query: opts.query } : {}),
1340
+ ...(page !== undefined ? { page } : {}),
1341
+ ...(prevContext ? { prevContext } : {}),
1342
+ ...(Object.keys(formValueOverrides).length ? { formValueOverrides } : {}),
1343
+ });
1344
+ }
1345
+ catch (error) {
1346
+ const blocked = blockedUpstreamOptionMessage(error);
1347
+ if (blocked) {
1348
+ throwBlockedUpstreamOption(nodeId, blocked.propName, blocked.blockedBy);
1349
+ }
1350
+ throw error;
1351
+ }
1352
+ const dynamicUpstreamNext = dynamicUpstreamOptionError(data?.error)
1353
+ ? dynamicUpstreamOptionNext(nodeId, opts.prop)
1354
+ : undefined;
1355
+ const outputData = dynamicUpstreamNext && data && typeof data === 'object' && !Array.isArray(data)
1356
+ ? { ...data, next: dynamicUpstreamNext }
1357
+ : data;
805
1358
  const rows = data?.options ?? (Array.isArray(data) ? data : []);
806
- emit(data, () => printTable(['value', 'label'], rows.map(o => [o.value ?? o.id, o.label ?? o.name])));
1359
+ emit(outputData, () => {
1360
+ if (!Array.isArray(data) && typeof data?.error === 'string' && data.error.trim()) {
1361
+ printKeyValues([
1362
+ ['error', data.error],
1363
+ ...(dynamicUpstreamNext
1364
+ ? [['next', dynamicUpstreamNext]]
1365
+ : []),
1366
+ ]);
1367
+ process.stdout.write('\n');
1368
+ }
1369
+ printTable(['value', 'label'], rows.map(o => [o.value ?? o.id, o.label ?? o.name]));
1370
+ if (!Array.isArray(data) &&
1371
+ data &&
1372
+ typeof data === 'object' &&
1373
+ Object.hasOwn(data, 'context') &&
1374
+ data.context !== undefined &&
1375
+ data.context !== null) {
1376
+ process.stdout.write(`\ncontext ${JSON.stringify(data.context)}\n`);
1377
+ process.stdout.write('Pass this context back with --prev-context to continue.\n');
1378
+ }
1379
+ });
807
1380
  });
808
1381
  node
809
1382
  .command('continue-on-error')