@controllerai/cli 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +180 -0
- package/dist/api/client.js +330 -0
- package/dist/api/client.js.map +1 -0
- package/dist/appLinks.js +99 -0
- package/dist/appLinks.js.map +1 -0
- package/dist/argv.js +124 -0
- package/dist/argv.js.map +1 -0
- package/dist/commands/agent.js +813 -0
- package/dist/commands/agent.js.map +1 -0
- package/dist/commands/auth.js +327 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/branch.js +441 -0
- package/dist/commands/branch.js.map +1 -0
- package/dist/commands/connect.js +148 -0
- package/dist/commands/connect.js.map +1 -0
- package/dist/commands/connection.js +148 -0
- package/dist/commands/connection.js.map +1 -0
- package/dist/commands/data.js +441 -0
- package/dist/commands/data.js.map +1 -0
- package/dist/commands/deps.js +63 -0
- package/dist/commands/deps.js.map +1 -0
- package/dist/commands/doctor.js +102 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/events.js +72 -0
- package/dist/commands/events.js.map +1 -0
- package/dist/commands/exec.js +306 -0
- package/dist/commands/exec.js.map +1 -0
- package/dist/commands/expr.js +346 -0
- package/dist/commands/expr.js.map +1 -0
- package/dist/commands/flow.js +447 -0
- package/dist/commands/flow.js.map +1 -0
- package/dist/commands/integration.js +102 -0
- package/dist/commands/integration.js.map +1 -0
- package/dist/commands/node.js +885 -0
- package/dist/commands/node.js.map +1 -0
- package/dist/commands/organize.js +234 -0
- package/dist/commands/organize.js.map +1 -0
- package/dist/commands/run.js +437 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/schema.js +269 -0
- package/dist/commands/schema.js.map +1 -0
- package/dist/commands/skills.js +1156 -0
- package/dist/commands/skills.js.map +1 -0
- package/dist/commands/trigger.js +992 -0
- package/dist/commands/trigger.js.map +1 -0
- package/dist/commands/workflow.js +503 -0
- package/dist/commands/workflow.js.map +1 -0
- package/dist/config.js +74 -0
- package/dist/config.js.map +1 -0
- package/dist/context.js +39 -0
- package/dist/context.js.map +1 -0
- package/dist/errors.js +59 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.js +125 -0
- package/dist/index.js.map +1 -0
- package/dist/jsSource.js +32 -0
- package/dist/jsSource.js.map +1 -0
- package/dist/nodeProps.js +177 -0
- package/dist/nodeProps.js.map +1 -0
- package/dist/output.js +85 -0
- package/dist/output.js.map +1 -0
- package/dist/version.js +7 -0
- package/dist/version.js.map +1 -0
- package/package.json +41 -0
- package/skills/controllerai-agents/SKILL.md +169 -0
- package/skills/controllerai-agents/agents/openai.yaml +4 -0
- package/skills/controllerai-agents/references/instructions-and-versioning.md +76 -0
- package/skills/controllerai-ai-nodes/SKILL.md +83 -0
- package/skills/controllerai-ai-nodes/agents/openai.yaml +4 -0
- package/skills/controllerai-ai-nodes/references/selection-and-testing.md +46 -0
- package/skills/controllerai-data/SKILL.md +121 -0
- package/skills/controllerai-data/agents/openai.yaml +4 -0
- package/skills/controllerai-data/references/data-rails.md +46 -0
- package/skills/controllerai-expressions/SKILL.md +115 -0
- package/skills/controllerai-expressions/agents/openai.yaml +4 -0
- package/skills/controllerai-expressions/references/dsl-and-recipes.md +120 -0
- package/skills/controllerai-flow-control/SKILL.md +132 -0
- package/skills/controllerai-flow-control/agents/openai.yaml +4 -0
- package/skills/controllerai-flow-control/references/patterns.md +61 -0
- package/skills/controllerai-integration-actions/SKILL.md +95 -0
- package/skills/controllerai-integration-actions/agents/openai.yaml +4 -0
- package/skills/controllerai-node-config/SKILL.md +138 -0
- package/skills/controllerai-node-config/agents/openai.yaml +4 -0
- package/skills/controllerai-node-config/references/prop-and-readiness.md +72 -0
- package/skills/controllerai-runs/SKILL.md +109 -0
- package/skills/controllerai-runs/agents/openai.yaml +4 -0
- package/skills/controllerai-runs/references/debugging.md +53 -0
- package/skills/controllerai-start/SKILL.md +44 -0
- package/skills/controllerai-start/agents/openai.yaml +4 -0
- package/skills/controllerai-triggers/SKILL.md +174 -0
- package/skills/controllerai-triggers/agents/openai.yaml +4 -0
- package/skills/controllerai-triggers/references/lifecycle.md +54 -0
- package/skills/controllerai-workflows/SKILL.md +86 -0
- package/skills/controllerai-workflows/agents/openai.yaml +4 -0
- package/skills/controllerai-workflows/references/command-map.md +64 -0
- package/skills/manifest.json +248 -0
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { requireContext } from '../context.js';
|
|
3
|
+
import { validationFailed, validationMessage } from './expr.js';
|
|
4
|
+
import { CaiError, userError } from '../errors.js';
|
|
5
|
+
import { resolveJsSource } from '../jsSource.js';
|
|
6
|
+
import { buildNodeReadiness, expressionReadbackChannel, nodePropsFromPayload, normalizeNodePropWrites, } from '../nodeProps.js';
|
|
7
|
+
import { emit, printKeyValues, printTable, truncate, warn } from '../output.js';
|
|
8
|
+
const readUtf8 = (source, encoding) => readFileSync(source, encoding);
|
|
9
|
+
const loadProps = async (api, workflowId, nodeId) => {
|
|
10
|
+
const props = await api.tool(workflowId, 'get_node_props', { nodeId });
|
|
11
|
+
return nodePropsFromPayload(props);
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* get_node exposes processOutput after a successful typed run or documented
|
|
15
|
+
* declaration. Until then, add the component's curated/observed type as
|
|
16
|
+
* diagnostic context; readiness deliberately remains unresolved until the
|
|
17
|
+
* runtime adopts it.
|
|
18
|
+
*/
|
|
19
|
+
const addResolvedOutputType = async (api, workflowId, record) => {
|
|
20
|
+
const nodeSlug = record.nodeSlug ?? record.data?.nodeSlug;
|
|
21
|
+
const nodeKey = record.key ?? record.data?.key;
|
|
22
|
+
if (!nodeSlug || !nodeKey)
|
|
23
|
+
return record;
|
|
24
|
+
try {
|
|
25
|
+
const component = await api.tool(workflowId, 'get_node_component', {
|
|
26
|
+
nodeSlug,
|
|
27
|
+
nodeKey,
|
|
28
|
+
});
|
|
29
|
+
const resolvedOutputType = component?.component?.outputDataType ?? component?.observedOutputType ?? null;
|
|
30
|
+
return resolvedOutputType ? { ...record, resolvedOutputType } : record;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Status is still useful for configuration blockers. Fail closed on output
|
|
34
|
+
// readiness when the component type cannot be resolved.
|
|
35
|
+
return record;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
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;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Prop types whose stored shape pairs a value with the human label the UI displayed.
|
|
54
|
+
*
|
|
55
|
+
* `STATIC_DROPDOWN` is deliberately EXCLUDED: its options are fixed in the node definition,
|
|
56
|
+
* so the UI stores the value alone and inventing a `valueLabel` is wrong (CFG-005 asserts
|
|
57
|
+
* exactly that). Only remotely-resolved options carry a label.
|
|
58
|
+
*/
|
|
59
|
+
const isOptionBackedProp = (prop) => ['DROPDOWN', 'MULTI_SELECT', 'MULTI_SELECT_DROPDOWN', 'REMOTE_OPTIONS'].includes(String(prop.inputType ?? ''));
|
|
60
|
+
/**
|
|
61
|
+
* Multi-selects store `value` as an array AND `valueLabel` as an array of `{value, label}`
|
|
62
|
+
* pairs — not a parallel array of label strings. Single-selects store a plain label string.
|
|
63
|
+
* Getting this wrong saves cleanly and shows blank chips in the UI (found by CFG-012).
|
|
64
|
+
*/
|
|
65
|
+
const isMultiSelectProp = (prop) => ['MULTI_SELECT', 'MULTI_SELECT_DROPDOWN'].includes(String(prop.inputType ?? ''));
|
|
66
|
+
/**
|
|
67
|
+
* Remote option providers commonly search human labels rather than opaque ids.
|
|
68
|
+
* A supplied --label is therefore the lookup query, never an unverified value
|
|
69
|
+
* written directly to the node.
|
|
70
|
+
*/
|
|
71
|
+
export const optionLookupQueries = (selected, labels) => {
|
|
72
|
+
const supplied = labels === undefined ? [] : Array.isArray(labels) ? labels : [labels];
|
|
73
|
+
if (supplied.length > 0 && supplied.length !== selected.length) {
|
|
74
|
+
throw userError(`Pass one --label per selected value (${selected.length} value(s), ${supplied.length} label(s)).`, 'OPTION_LABEL_COUNT_MISMATCH');
|
|
75
|
+
}
|
|
76
|
+
return selected.map((value, index) => supplied[index]?.trim() || String(value));
|
|
77
|
+
};
|
|
78
|
+
export const parseNodeOptionPage = (raw) => {
|
|
79
|
+
if (raw === undefined)
|
|
80
|
+
return undefined;
|
|
81
|
+
const page = Number(raw);
|
|
82
|
+
if (!Number.isInteger(page) || page < 0) {
|
|
83
|
+
throw userError('--page must be a non-negative integer.', 'BAD_OPTION_PAGE');
|
|
84
|
+
}
|
|
85
|
+
return page;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Prop types persisted as a `TextExpression` wrapping raw text. Their `--value` must reach
|
|
89
|
+
* the backend as a STRING; parsing it first (e.g. a JSON body, which is legal JSON) makes
|
|
90
|
+
* the backend store a bare object the runtime cannot evaluate.
|
|
91
|
+
*/
|
|
92
|
+
const isTextExpressionBackedProp = (prop) => ['JSON', 'LONG_TEXT', 'SHORT_TEXT', 'SQL', 'TEXT'].includes(String(prop.inputType ?? ''));
|
|
93
|
+
const fetchPropOptions = async (api, workflowId, nodeId, propName, query) => {
|
|
94
|
+
const data = await api.tool(workflowId, 'get_prop_options', { nodeId, propName, ...(query ? { query } : {}) });
|
|
95
|
+
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 !== '');
|
|
102
|
+
};
|
|
103
|
+
const readObjectOption = (source, flag) => {
|
|
104
|
+
const raw = source.trimStart().startsWith('{') ? source : readFileSync(source, 'utf8');
|
|
105
|
+
let value;
|
|
106
|
+
try {
|
|
107
|
+
value = JSON.parse(raw);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
throw userError(`${flag} must be a JSON object or a path to one: ${error instanceof Error ? error.message : error}`, 'BAD_JSON_OPTION');
|
|
111
|
+
}
|
|
112
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
113
|
+
throw userError(`${flag} must contain a JSON object.`, 'BAD_JSON_SHAPE');
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
};
|
|
117
|
+
/** Read a documented response sample before any workflow or network access. */
|
|
118
|
+
export const readNodeOutputSample = (source, reader = readUtf8) => {
|
|
119
|
+
let raw;
|
|
120
|
+
try {
|
|
121
|
+
raw = reader(source === '-' ? 0 : source, 'utf8');
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
throw userError(`--sample-file "${source}" is not a readable UTF-8 file: ` +
|
|
125
|
+
`${error instanceof Error ? error.message : error}`, 'BAD_SAMPLE');
|
|
126
|
+
}
|
|
127
|
+
let sample;
|
|
128
|
+
try {
|
|
129
|
+
sample = JSON.parse(raw);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
throw userError(`--sample-file must contain valid JSON: ${error instanceof Error ? error.message : error}`, 'BAD_SAMPLE');
|
|
133
|
+
}
|
|
134
|
+
if (!sample || typeof sample !== 'object' || Array.isArray(sample)) {
|
|
135
|
+
throw userError('--sample-file must contain a JSON object.', 'BAD_SAMPLE');
|
|
136
|
+
}
|
|
137
|
+
return sample;
|
|
138
|
+
};
|
|
139
|
+
const nodeProcessOutput = (record) => record.processOutput ?? record.data?.processOutput;
|
|
140
|
+
const renderNodeOutputType = (dataType, declared) => declared && typeof dataType === 'string'
|
|
141
|
+
? `${dataType} (declared — unverified)`
|
|
142
|
+
: dataType;
|
|
143
|
+
const shellQuote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
|
|
144
|
+
const requireCompletedPropReload = (result, nodeId, propName, retryCommand) => {
|
|
145
|
+
const reload = result;
|
|
146
|
+
if (reload?.propsReloaded !== false)
|
|
147
|
+
return;
|
|
148
|
+
const reloadError = typeof reload.reloadError === 'string' && reload.reloadError.trim()
|
|
149
|
+
? reload.reloadError.trim()
|
|
150
|
+
: 'The backend did not provide a reload error.';
|
|
151
|
+
const inspectCommand = `cai node props ${nodeId} --json`;
|
|
152
|
+
throw new CaiError(`Prop "${propName}" was updated, but its dependent props were not reloaded: ` +
|
|
153
|
+
`${reloadError}\nThe node may still contain stale dependent values. Retry the reload with ` +
|
|
154
|
+
`\`${retryCommand}\`, then inspect \`${inspectCommand}\` before configuring or running it.`, {
|
|
155
|
+
code: 'PROPS_RELOAD_FAILED',
|
|
156
|
+
detail: {
|
|
157
|
+
nodeId,
|
|
158
|
+
prop: propName,
|
|
159
|
+
propsReloaded: false,
|
|
160
|
+
reloadError,
|
|
161
|
+
recoveryCommands: [retryCommand, inspectCommand],
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
const searchScore = (intent, component) => {
|
|
166
|
+
const normalize = (value) => value
|
|
167
|
+
.toLowerCase()
|
|
168
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
169
|
+
.trim();
|
|
170
|
+
const query = normalize(intent);
|
|
171
|
+
const terms = query.split(/\s+/).filter(Boolean);
|
|
172
|
+
const name = normalize(component.name ?? '');
|
|
173
|
+
const description = normalize(component.description ?? '');
|
|
174
|
+
const props = normalize((component.propNames ?? []).join(' '));
|
|
175
|
+
let score = name === query ? 100 : name.includes(query) ? 50 : 0;
|
|
176
|
+
for (const term of terms) {
|
|
177
|
+
if (name.split(' ').includes(term))
|
|
178
|
+
score += 12;
|
|
179
|
+
else if (name.includes(term))
|
|
180
|
+
score += 7;
|
|
181
|
+
if (description.includes(term))
|
|
182
|
+
score += 3;
|
|
183
|
+
if (props.includes(term))
|
|
184
|
+
score += 2;
|
|
185
|
+
}
|
|
186
|
+
return score;
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* `add_node` needs BOTH nodeSlug and nodeKey, but component keys returned by
|
|
190
|
+
* search are already `<nodeSlug>-<component>` — so the slug is recoverable.
|
|
191
|
+
* Prefer an explicit --node, then a catalog lookup, then a prefix split.
|
|
192
|
+
*/
|
|
193
|
+
const resolveSlug = async (api, workflowId, componentKey, explicit) => {
|
|
194
|
+
if (explicit)
|
|
195
|
+
return explicit;
|
|
196
|
+
const guess = componentKey.includes('-') ? componentKey.slice(0, componentKey.indexOf('-')) : '';
|
|
197
|
+
// Registry slugs themselves contain underscores, not hyphens, so the first
|
|
198
|
+
// hyphen is the slug boundary for registry components. Verify before trusting.
|
|
199
|
+
for (const candidate of [guess, componentKey.replace(/-[^-]+$/, '')].filter(Boolean)) {
|
|
200
|
+
try {
|
|
201
|
+
const found = await api.tool(workflowId, 'search_node_components', {
|
|
202
|
+
nodeSlug: candidate,
|
|
203
|
+
limit: 100,
|
|
204
|
+
});
|
|
205
|
+
if ((found?.data ?? []).some(c => c.key === componentKey))
|
|
206
|
+
return candidate;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// wrong guess — try the next shape
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
throw userError(`Cannot infer the node slug from component key "${componentKey}". Pass --node <slug> ` +
|
|
213
|
+
'(the `nodeSlug` column from `cai node components`).', 'UNRESOLVED_SLUG');
|
|
214
|
+
};
|
|
215
|
+
export const registerNode = (program) => {
|
|
216
|
+
const node = program.command('node').description('Nodes — search, add, configure, wire');
|
|
217
|
+
node
|
|
218
|
+
.command('search')
|
|
219
|
+
.argument('<query>')
|
|
220
|
+
.option('--limit <n>', 'max rows', '10')
|
|
221
|
+
.option('--actions', 'only nodes that have actions')
|
|
222
|
+
.option('--events', 'only nodes that have trigger events')
|
|
223
|
+
.description('Search registry + catalog nodes (ranked by featuredWeight, not relevance)')
|
|
224
|
+
.action(async (query, opts, command) => {
|
|
225
|
+
const { api, workflowId } = requireContext(command);
|
|
226
|
+
const result = await api.tool(workflowId, 'search_nodes', {
|
|
227
|
+
query,
|
|
228
|
+
limit: Number(opts.limit),
|
|
229
|
+
...(opts.actions ? { hasActions: true } : {}),
|
|
230
|
+
...(opts.events ? { hasEvents: true } : {}),
|
|
231
|
+
});
|
|
232
|
+
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
|
+
])));
|
|
242
|
+
});
|
|
243
|
+
node
|
|
244
|
+
.command('find')
|
|
245
|
+
.argument('<intent>', 'capability or action, e.g. "send a Slack message"')
|
|
246
|
+
.option('--node <slug>', 'search only this integration')
|
|
247
|
+
.option('--limit <n>', 'max exact component references', '8')
|
|
248
|
+
.description('Find ranked action components and return exact nodeSlug/nodeKey pairs')
|
|
249
|
+
.action(async (intent, opts, command) => {
|
|
250
|
+
const { api, workflowId } = requireContext(command);
|
|
251
|
+
let slugs;
|
|
252
|
+
if (opts.node) {
|
|
253
|
+
slugs = [opts.node];
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
const nodes = await api.tool(workflowId, 'search_nodes', {
|
|
257
|
+
query: intent,
|
|
258
|
+
hasActions: true,
|
|
259
|
+
limit: 10,
|
|
260
|
+
});
|
|
261
|
+
slugs = (nodes?.data ?? []).map((row) => row.slug).filter(Boolean);
|
|
262
|
+
}
|
|
263
|
+
const batches = await Promise.all(slugs.map(async (nodeSlug) => {
|
|
264
|
+
const result = await api.tool(workflowId, 'search_node_components', {
|
|
265
|
+
nodeSlug,
|
|
266
|
+
query: intent,
|
|
267
|
+
limit: 20,
|
|
268
|
+
});
|
|
269
|
+
return (result?.data ?? []).map((component) => ({
|
|
270
|
+
...component,
|
|
271
|
+
nodeSlug: component.nodeSlug ?? nodeSlug,
|
|
272
|
+
}));
|
|
273
|
+
}));
|
|
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
|
+
])));
|
|
286
|
+
});
|
|
287
|
+
node
|
|
288
|
+
.command('components')
|
|
289
|
+
.argument('[query]')
|
|
290
|
+
.requiredOption('--node <slug>', 'node slug whose components to list')
|
|
291
|
+
.option('--limit <n>', 'max rows', '25')
|
|
292
|
+
.description('List a node’s action components (the --key values for `node add`)')
|
|
293
|
+
.action(async (query, opts, command) => {
|
|
294
|
+
const { api, workflowId } = requireContext(command);
|
|
295
|
+
const result = await api.tool(workflowId, 'search_node_components', {
|
|
296
|
+
nodeSlug: opts.node,
|
|
297
|
+
...(query ? { query } : {}),
|
|
298
|
+
limit: Number(opts.limit),
|
|
299
|
+
});
|
|
300
|
+
const rows = result?.data ?? [];
|
|
301
|
+
emit({ items: rows, pagination: result?.pagination ?? null }, () => printTable(['key', 'name', 'type', 'props'], rows.map(c => [c.key, c.name, c.type, (c.propNames ?? []).join(',')])));
|
|
302
|
+
});
|
|
303
|
+
node
|
|
304
|
+
.command('add')
|
|
305
|
+
.alias('init')
|
|
306
|
+
.requiredOption('--flow <flowId>')
|
|
307
|
+
.requiredOption('--key <componentKey>', 'component key, e.g. registry_http-http-request')
|
|
308
|
+
.option('--node <slug>', 'node slug (inferred from --key when omitted)')
|
|
309
|
+
.option('--name <displayName>', 'display name; also the outgoing connection label')
|
|
310
|
+
.option('--after <nodeId>', 'wire this upstream node into the new node at creation')
|
|
311
|
+
.option('--output <main|error>', 'which system output --after connects from', 'main')
|
|
312
|
+
.option('--connection <id>', 'bind an existing connection')
|
|
313
|
+
.option('--props <jsonOrFile>', 'initial prop map; direct values become {value:...}, advanced per-prop writes pass through')
|
|
314
|
+
.option('--enable-prop <names...>', 'optional prop names to materialize at creation')
|
|
315
|
+
.description('Initialize a node with its known connection, wiring, optional props, and values')
|
|
316
|
+
.action(async (opts, command) => {
|
|
317
|
+
const { api, workflowId } = requireContext(command);
|
|
318
|
+
const nodeSlug = await resolveSlug(api, workflowId, opts.key, opts.node);
|
|
319
|
+
const props = opts.props
|
|
320
|
+
? normalizeNodePropWrites(readObjectOption(opts.props, '--props'))
|
|
321
|
+
: 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([
|
|
335
|
+
api.tool(workflowId, 'get_node', { nodeId: result.nodeId }),
|
|
336
|
+
loadProps(api, workflowId, result.nodeId),
|
|
337
|
+
]);
|
|
338
|
+
const record = await addResolvedOutputType(api, workflowId, rawRecord);
|
|
339
|
+
const readiness = buildNodeReadiness(result.nodeId, record, currentProps);
|
|
340
|
+
const requestedPropNames = Object.keys(props ?? {});
|
|
341
|
+
const propsReadback = currentProps.filter(prop => requestedPropNames.includes(prop.name ?? prop.key ?? ''));
|
|
342
|
+
emit({ ...result, normalizedProps: props ?? {}, propsReadback, readiness }, () => printKeyValues([
|
|
343
|
+
['nodeId', result.nodeId],
|
|
344
|
+
['displayName', result.displayName],
|
|
345
|
+
['position', JSON.stringify(result.position ?? {})],
|
|
346
|
+
['propsSet', (result.propsSet ?? []).join(', ') || '(none)'],
|
|
347
|
+
[
|
|
348
|
+
'configuration blockers',
|
|
349
|
+
readiness.blockedBy.filter(blocker => blocker !== 'output type').join(', ') || '(none)',
|
|
350
|
+
],
|
|
351
|
+
['next', `cai node status ${result.nodeId} — verify before expressions`],
|
|
352
|
+
]));
|
|
353
|
+
});
|
|
354
|
+
node
|
|
355
|
+
.command('component')
|
|
356
|
+
.requiredOption('--node <slug>', 'integration node slug')
|
|
357
|
+
.requiredOption('--key <componentKey>', 'component key from `cai node components`')
|
|
358
|
+
.description('Full static prop schema for a component BEFORE adding it — no node needed')
|
|
359
|
+
.action(async (opts, command) => {
|
|
360
|
+
const { api, workflowId } = requireContext(command);
|
|
361
|
+
const data = await api.tool(workflowId, 'get_node_component', {
|
|
362
|
+
nodeSlug: opts.node,
|
|
363
|
+
nodeKey: opts.key,
|
|
364
|
+
});
|
|
365
|
+
// The payload nests: {node:{slug,name,authRequired}, component:{key,name,
|
|
366
|
+
// description,type,outputDataType}, props:[…]} — nothing useful is at the
|
|
367
|
+
// top level, which is easy to miss when reading only the tool description.
|
|
368
|
+
const props = data?.props ?? data?.component?.props ?? [];
|
|
369
|
+
const component = data?.component ?? {};
|
|
370
|
+
emit(data, () => {
|
|
371
|
+
printKeyValues([
|
|
372
|
+
['key', component.key ?? opts.key],
|
|
373
|
+
['name', component.name ?? '-'],
|
|
374
|
+
[
|
|
375
|
+
'node',
|
|
376
|
+
`${data?.node?.slug ?? opts.node}${data?.node?.authRequired ? ' (auth required)' : ''}`,
|
|
377
|
+
],
|
|
378
|
+
['outputType', component.outputDataType ?? data?.observedOutputType ?? '(unresolved)'],
|
|
379
|
+
['description', truncate(component.description, 70)],
|
|
380
|
+
]);
|
|
381
|
+
process.stdout.write('\nprops\n');
|
|
382
|
+
printTable(['name', 'inputType', 'required', 'default', 'remoteOptions', 'reloadProps'], props.map(p => [
|
|
383
|
+
p.name,
|
|
384
|
+
p.inputType,
|
|
385
|
+
p.required ? 'yes' : '',
|
|
386
|
+
truncate(p.defaultValue, 24),
|
|
387
|
+
p.remoteOptions ? 'yes — pick with `cai node options` after adding' : '',
|
|
388
|
+
p.reloadProps ? 'yes — reveals dependent props after adding' : '',
|
|
389
|
+
]));
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
node
|
|
393
|
+
.command('duplicate')
|
|
394
|
+
.argument('<nodeId>')
|
|
395
|
+
.description('Copy a node into the same flow (offset position, no incoming edges)')
|
|
396
|
+
.action(async (nodeId, _opts, command) => {
|
|
397
|
+
const { api, workflowId } = requireContext(command);
|
|
398
|
+
const result = await api.mutate(workflowId, () => api.tool(workflowId, 'duplicate_node', { nodeId }));
|
|
399
|
+
warn('A duplicate copies props but NOT the wiring: any prop expression that referenced an ' +
|
|
400
|
+
'input of the original now has no such input on the copy. Re-wire it with ' +
|
|
401
|
+
'`cai node connect` and re-read `cai expr context` before trusting its expressions.');
|
|
402
|
+
emit(result, () => printKeyValues([
|
|
403
|
+
['source', nodeId],
|
|
404
|
+
['copy', result?.newNodeId ?? result?.nodeId ?? JSON.stringify(result)],
|
|
405
|
+
]));
|
|
406
|
+
});
|
|
407
|
+
node
|
|
408
|
+
.command('set-flow')
|
|
409
|
+
.argument('<nodeId>', 'a Run flow / Run flow on list node')
|
|
410
|
+
.requiredOption('--flow <flowId>', 'target flow to call')
|
|
411
|
+
.description('Point a subflow node at a flow; its input props are generated from that flow')
|
|
412
|
+
.action(async (nodeId, opts, command) => {
|
|
413
|
+
const { api, workflowId } = requireContext(command);
|
|
414
|
+
const result = await api.mutate(workflowId, () => api.tool(workflowId, 'set_subflow_node_flow', { nodeId, flowId: opts.flow }));
|
|
415
|
+
warn('The mapping props are synthesized from the target flow’s CURRENT inputs. A flow input ' +
|
|
416
|
+
'added later does not appear on this node until it is re-linked — re-run this command ' +
|
|
417
|
+
'after changing the target flow’s inputs.');
|
|
418
|
+
emit(result, () => printKeyValues([
|
|
419
|
+
['nodeId', nodeId],
|
|
420
|
+
['flowId', opts.flow],
|
|
421
|
+
['next', 'cai node props ' + nodeId + ' — one mapping prop per flow input'],
|
|
422
|
+
]));
|
|
423
|
+
});
|
|
424
|
+
node
|
|
425
|
+
.command('get')
|
|
426
|
+
.argument('<nodeId>')
|
|
427
|
+
.description('Full node record: component, inputs, outputs, connection')
|
|
428
|
+
.action(async (nodeId, _opts, command) => {
|
|
429
|
+
const { api, workflowId } = requireContext(command);
|
|
430
|
+
const data = await api.tool(workflowId, 'get_node', { nodeId });
|
|
431
|
+
emit(data, () => {
|
|
432
|
+
const processOutput = nodeProcessOutput(data);
|
|
433
|
+
printKeyValues([
|
|
434
|
+
['id', data.id ?? nodeId],
|
|
435
|
+
['displayName', data.displayName ?? data.data?.displayName],
|
|
436
|
+
['component', data.nodeComponentKey ?? data.data?.nodeComponentKey],
|
|
437
|
+
['connectionId', data.connectionId ?? data.data?.connectionId ?? '(none)'],
|
|
438
|
+
[
|
|
439
|
+
'outputType',
|
|
440
|
+
processOutput?.dataType
|
|
441
|
+
? renderNodeOutputType(processOutput.dataType, processOutput.declared === true)
|
|
442
|
+
: '(none)',
|
|
443
|
+
],
|
|
444
|
+
]);
|
|
445
|
+
const inputs = data.inputs ?? data.data?.inputs ?? [];
|
|
446
|
+
if (inputs.length) {
|
|
447
|
+
process.stdout.write('\ninputs\n');
|
|
448
|
+
printTable(['id', 'displayName', 'dataType'], inputs.map((i) => [i.id, i.displayName, i.dataType]));
|
|
449
|
+
}
|
|
450
|
+
const outputs = data.outputs ?? data.data?.outputs ?? [];
|
|
451
|
+
if (outputs.length) {
|
|
452
|
+
process.stdout.write('\noutputs\n');
|
|
453
|
+
printTable(['id', 'displayName', 'dataType'], outputs.map((o) => [o.id, o.displayName, o.dataType]));
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
node
|
|
458
|
+
.command('declare-output')
|
|
459
|
+
.argument('<nodeId>')
|
|
460
|
+
.requiredOption('--sample-file <file|->', 'documented response data JSON object; `-` reads stdin')
|
|
461
|
+
.description('Declare an unverified dynamic output contract from documented response data')
|
|
462
|
+
.action(async (nodeId, opts, command) => {
|
|
463
|
+
const sample = readNodeOutputSample(opts.sampleFile);
|
|
464
|
+
const { api, workflowId } = requireContext(command);
|
|
465
|
+
const result = await api.mutate(workflowId, () => api.tool(workflowId, 'declare_node_output_type', {
|
|
466
|
+
nodeId,
|
|
467
|
+
sample,
|
|
468
|
+
}));
|
|
469
|
+
emit(result, () => {
|
|
470
|
+
printKeyValues([
|
|
471
|
+
['dataTypeName', result.dataTypeName],
|
|
472
|
+
['status', `${result.status} (unverified)`],
|
|
473
|
+
]);
|
|
474
|
+
process.stdout.write(`\nThis contract comes from documentation, not a run. ` +
|
|
475
|
+
`The first real completed run verifies it automatically. ` +
|
|
476
|
+
`Downstream: re-read \`cai node status ${nodeId}\` and \`cai expr context\`.\n`);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
node
|
|
480
|
+
.command('props')
|
|
481
|
+
.argument('<nodeId>')
|
|
482
|
+
.description('Visible + catalog props with current values and decompiled valueJs')
|
|
483
|
+
.action(async (nodeId, _opts, command) => {
|
|
484
|
+
const { api, workflowId } = requireContext(command);
|
|
485
|
+
const data = await api.tool(workflowId, 'get_node_props', { nodeId });
|
|
486
|
+
const props = nodePropsFromPayload(data);
|
|
487
|
+
emit(data, () => printTable(['name', 'inputType', 'required', 'enabled', 'value', 'valueJs', 'expressionValueJs'], props.map(p => [
|
|
488
|
+
p.name ?? p.key,
|
|
489
|
+
p.inputType,
|
|
490
|
+
p.required ? 'yes' : '',
|
|
491
|
+
p.enabled === false ? 'no' : 'yes',
|
|
492
|
+
truncate(p.value, 40),
|
|
493
|
+
truncate(p.valueJs, 40),
|
|
494
|
+
truncate(p.expressionValueJs, 40),
|
|
495
|
+
])));
|
|
496
|
+
});
|
|
497
|
+
node
|
|
498
|
+
.command('status')
|
|
499
|
+
.argument('<nodeId>')
|
|
500
|
+
.description('Return deterministic configuration readiness and exact blockers')
|
|
501
|
+
.action(async (nodeId, _opts, command) => {
|
|
502
|
+
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;
|
|
510
|
+
const payload = {
|
|
511
|
+
...readiness,
|
|
512
|
+
...(outputTypeDeclared ? { outputTypeDeclared: true } : {}),
|
|
513
|
+
};
|
|
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
|
+
]));
|
|
538
|
+
});
|
|
539
|
+
node
|
|
540
|
+
.command('set')
|
|
541
|
+
.argument('<nodeId>')
|
|
542
|
+
.requiredOption('--prop <name>', 'prop key from `cai node props`')
|
|
543
|
+
.option('--value <literal>', 'static value (JSON when it parses, else a string)')
|
|
544
|
+
.option('--js <src>', 'expression DSL source, compiled server-side before writing')
|
|
545
|
+
.option('--js-file <path|->', 'read expression DSL source from a UTF-8 file or stdin')
|
|
546
|
+
.option('--label <labels...>', 'live option label(s), one per value; used to search providers that do not index opaque ids')
|
|
547
|
+
.option('--expression', 'deprecated compatibility flag; storage is selected automatically', false)
|
|
548
|
+
.description('Set a prop, selecting and verifying the correct storage shape automatically')
|
|
549
|
+
.action(async (nodeId, opts, command) => {
|
|
550
|
+
const js = resolveJsSource(opts, { required: false });
|
|
551
|
+
const { api, workflowId } = requireContext(command);
|
|
552
|
+
if (js === undefined && opts.value === undefined) {
|
|
553
|
+
throw userError('Pass --value <literal>, --js <expression>, or --js-file <path|->.', 'MISSING_VALUE');
|
|
554
|
+
}
|
|
555
|
+
if (js !== undefined && opts.value !== undefined) {
|
|
556
|
+
throw userError('--value is mutually exclusive with --js and --js-file.', 'CONFLICTING_VALUE');
|
|
557
|
+
}
|
|
558
|
+
const prop = await findProp(api, workflowId, nodeId, opts.prop);
|
|
559
|
+
const persistedExpressionChannel = expressionReadbackChannel(prop);
|
|
560
|
+
if (opts.expression) {
|
|
561
|
+
warn('`--expression` is no longer needed; the CLI selects the correct storage channel.');
|
|
562
|
+
}
|
|
563
|
+
if (js !== undefined) {
|
|
564
|
+
// Compile FIRST. update_node_prop would also compile, but validating as
|
|
565
|
+
// a separate read-only call guarantees a failed expression writes
|
|
566
|
+
// nothing and lets us print the compiler error verbatim.
|
|
567
|
+
const validation = await api.tool(workflowId, 'validate_expression', {
|
|
568
|
+
js,
|
|
569
|
+
nodeId,
|
|
570
|
+
propName: opts.prop,
|
|
571
|
+
});
|
|
572
|
+
if (validationFailed(validation)) {
|
|
573
|
+
throw userError(`Expression did not compile — nothing was written.\n\n${validationMessage(validation)}`, 'EXPRESSION_COMPILE_ERROR');
|
|
574
|
+
}
|
|
575
|
+
for (const w of validation?.warnings ?? [])
|
|
576
|
+
warn(String(w));
|
|
577
|
+
}
|
|
578
|
+
const payload = { nodeId, key: opts.prop };
|
|
579
|
+
if (js !== undefined) {
|
|
580
|
+
// valueJs is intentionally the one public write channel. The backend
|
|
581
|
+
// resolves the prop's real compile target and routes the compiled value
|
|
582
|
+
// into value or expressionValue as appropriate.
|
|
583
|
+
payload.valueJs = js;
|
|
584
|
+
}
|
|
585
|
+
else if (isTextExpressionBackedProp(prop)) {
|
|
586
|
+
// These props persist as a TextExpression wrapping the raw TEXT. The backend does
|
|
587
|
+
// that wrapping only when it receives a string — hand it a parsed object and it
|
|
588
|
+
// stores the object verbatim, producing a shape the runtime cannot evaluate.
|
|
589
|
+
// JSON bodies are the common case: `{"a":1}` is valid JSON, so a general-purpose
|
|
590
|
+
// literal parser eats it. Found by CFG-010.
|
|
591
|
+
payload.value = opts.value;
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
payload.value = parseLiteral(opts.value);
|
|
595
|
+
}
|
|
596
|
+
const suppliedLabels = Array.isArray(opts.label)
|
|
597
|
+
? opts.label
|
|
598
|
+
: opts.label
|
|
599
|
+
? [opts.label]
|
|
600
|
+
: [];
|
|
601
|
+
if (suppliedLabels.length && !isOptionBackedProp(prop)) {
|
|
602
|
+
payload.valueLabel = suppliedLabels.length === 1 ? suppliedLabels[0] : suppliedLabels;
|
|
603
|
+
}
|
|
604
|
+
// An option-backed prop carries BOTH a value and the human label the UI showed. Resolve
|
|
605
|
+
// both from live options so a missing or wrongly-shaped label cannot write a
|
|
606
|
+
// half-configured prop (CFG-004, CFG-012). When a provider does not index opaque ids,
|
|
607
|
+
// caller-supplied --label is used only as the search query; the returned value must still
|
|
608
|
+
// match before anything is persisted.
|
|
609
|
+
if (js === undefined) {
|
|
610
|
+
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');
|
|
627
|
+
}
|
|
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');
|
|
636
|
+
}
|
|
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;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
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) {
|
|
660
|
+
throw userError(`The write returned successfully but ${opts.prop}.value did not read back.`, 'READBACK_MISMATCH');
|
|
661
|
+
}
|
|
662
|
+
emit({
|
|
663
|
+
nodeId,
|
|
664
|
+
prop: opts.prop,
|
|
665
|
+
mode: js !== undefined ? 'js' : 'static',
|
|
666
|
+
persistedChannel,
|
|
667
|
+
readback,
|
|
668
|
+
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
|
+
]));
|
|
676
|
+
});
|
|
677
|
+
node
|
|
678
|
+
.command('set-connection')
|
|
679
|
+
.argument('<nodeId>')
|
|
680
|
+
.requiredOption('--connection <id>', 'connection id from `cai connection list`')
|
|
681
|
+
.description('Bind a connection to an action node')
|
|
682
|
+
.action(async (nodeId, opts, command) => {
|
|
683
|
+
const { api, workflowId } = requireContext(command);
|
|
684
|
+
const result = await api.mutate(workflowId, () => api.tool(workflowId, 'update_node_connection', {
|
|
685
|
+
nodeId,
|
|
686
|
+
connectionId: String(opts.connection),
|
|
687
|
+
}));
|
|
688
|
+
emit({ nodeId, connectionId: opts.connection, result }, () => printKeyValues([
|
|
689
|
+
['nodeId', nodeId],
|
|
690
|
+
['connectionId', opts.connection],
|
|
691
|
+
]));
|
|
692
|
+
});
|
|
693
|
+
node
|
|
694
|
+
.command('connect')
|
|
695
|
+
.requiredOption('--from <nodeId>', 'upstream node')
|
|
696
|
+
.requiredOption('--to <nodeId>', 'downstream node')
|
|
697
|
+
.requiredOption('--flow <flowId>', 'flow containing both nodes')
|
|
698
|
+
.option('--output <main|error>', 'which system output to wire from', 'main')
|
|
699
|
+
.description('Wire two nodes; the source output becomes an input on the target')
|
|
700
|
+
.action(async (opts, command) => {
|
|
701
|
+
const { api, workflowId } = requireContext(command);
|
|
702
|
+
const result = await api.mutate(workflowId, () => api.tool(workflowId, 'connect_nodes', {
|
|
703
|
+
flowId: opts.flow,
|
|
704
|
+
sourceNodeId: opts.from,
|
|
705
|
+
targetNodeId: opts.to,
|
|
706
|
+
sourceOutput: opts.output,
|
|
707
|
+
}));
|
|
708
|
+
emit(result, () => printKeyValues([
|
|
709
|
+
['from', `${opts.from} (${opts.output})`],
|
|
710
|
+
['to', opts.to],
|
|
711
|
+
['result', JSON.stringify(result)],
|
|
712
|
+
]));
|
|
713
|
+
});
|
|
714
|
+
node
|
|
715
|
+
.command('disconnect')
|
|
716
|
+
.requiredOption('--edge <edgeId>')
|
|
717
|
+
.description('Remove an edge')
|
|
718
|
+
.action(async (opts, command) => {
|
|
719
|
+
const { api, workflowId } = requireContext(command);
|
|
720
|
+
const result = await api.mutate(workflowId, () => api.tool(workflowId, 'disconnect_nodes', { edgeId: opts.edge }));
|
|
721
|
+
emit(result, () => printKeyValues([['removed', opts.edge]]));
|
|
722
|
+
});
|
|
723
|
+
node
|
|
724
|
+
.command('only-when')
|
|
725
|
+
.argument('<nodeId>')
|
|
726
|
+
.option('--js <expr>', 'boolean expression; the node runs only when it is true')
|
|
727
|
+
.option('--js-file <path|->', 'read the boolean expression from a UTF-8 file or stdin')
|
|
728
|
+
.option('--clear', 'remove all conditions', false)
|
|
729
|
+
.description('Set or clear a node’s conditional-execution rule')
|
|
730
|
+
.action(async (nodeId, opts, command) => {
|
|
731
|
+
const js = resolveJsSource(opts, { required: false });
|
|
732
|
+
const { api, workflowId } = requireContext(command);
|
|
733
|
+
if (js === undefined && !opts.clear) {
|
|
734
|
+
throw userError('Pass --js <expression>, --js-file <path|->, or --clear.', 'MISSING_CONDITION');
|
|
735
|
+
}
|
|
736
|
+
if (js !== undefined && opts.clear) {
|
|
737
|
+
throw userError('--clear is mutually exclusive with --js and --js-file.', 'CONFLICTING_CONDITION');
|
|
738
|
+
}
|
|
739
|
+
if (js !== undefined) {
|
|
740
|
+
const validation = await api.tool(workflowId, 'validate_expression', {
|
|
741
|
+
js,
|
|
742
|
+
nodeId,
|
|
743
|
+
mode: 'onlyWhen',
|
|
744
|
+
});
|
|
745
|
+
if (validationFailed(validation)) {
|
|
746
|
+
throw userError(`onlyWhen expression did not compile — nothing was written.\n\n${validationMessage(validation)}`, 'EXPRESSION_COMPILE_ERROR');
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
const onlyWhen = opts.clear
|
|
750
|
+
? { type: 'group', operator: 'OR', rules: [] }
|
|
751
|
+
: {
|
|
752
|
+
type: 'group',
|
|
753
|
+
operator: 'OR',
|
|
754
|
+
rules: [
|
|
755
|
+
{
|
|
756
|
+
type: 'group',
|
|
757
|
+
operator: 'AND',
|
|
758
|
+
rules: [{ type: 'rule', value: js }],
|
|
759
|
+
},
|
|
760
|
+
],
|
|
761
|
+
};
|
|
762
|
+
const result = await api.mutate(workflowId, () => api.tool(workflowId, 'set_node_only_when', { nodeId, onlyWhen }));
|
|
763
|
+
emit({ nodeId, cleared: Boolean(opts.clear), js: js ?? null, result }, () => printKeyValues([
|
|
764
|
+
['nodeId', nodeId],
|
|
765
|
+
['onlyWhen', opts.clear ? '(cleared)' : js],
|
|
766
|
+
]));
|
|
767
|
+
});
|
|
768
|
+
node
|
|
769
|
+
.command('optional-props')
|
|
770
|
+
.argument('<nodeId>')
|
|
771
|
+
.description('List togglable optional props')
|
|
772
|
+
.action(async (nodeId, _opts, command) => {
|
|
773
|
+
const { api, workflowId } = requireContext(command);
|
|
774
|
+
const data = await api.tool(workflowId, 'get_node_optional_props', { nodeId });
|
|
775
|
+
const rows = Array.isArray(data) ? data : (data?.props ?? []);
|
|
776
|
+
emit(data, () => printTable(['name', 'displayName', 'inputType', 'enabled'], rows.map(p => [p.name, p.displayName, p.inputType, p.enabled ? 'yes' : 'no'])));
|
|
777
|
+
});
|
|
778
|
+
node
|
|
779
|
+
.command('enable-prop')
|
|
780
|
+
.argument('<nodeId>')
|
|
781
|
+
.requiredOption('--prop <name>')
|
|
782
|
+
.description('Enable an optional prop so it becomes settable')
|
|
783
|
+
.action(async (nodeId, opts, command) => {
|
|
784
|
+
const { api, workflowId } = requireContext(command);
|
|
785
|
+
const result = await api.mutate(workflowId, () => api.tool(workflowId, 'enable_optional_prop', { nodeId, propName: opts.prop }));
|
|
786
|
+
emit(result, () => printKeyValues([['enabled', `${nodeId}.${opts.prop}`]]));
|
|
787
|
+
});
|
|
788
|
+
node
|
|
789
|
+
.command('options')
|
|
790
|
+
.argument('<nodeId>')
|
|
791
|
+
.requiredOption('--prop <name>')
|
|
792
|
+
// keep in sync with fetchPropOptions() below, which reuses this tool
|
|
793
|
+
.option('--query <q>', 'search term — strongly preferred over paging')
|
|
794
|
+
.option('--page <n>', '0-based page when not searching')
|
|
795
|
+
.description('Resolved remote dropdown options for a prop')
|
|
796
|
+
.action(async (nodeId, opts, command) => {
|
|
797
|
+
const { api, workflowId } = requireContext(command);
|
|
798
|
+
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
|
+
});
|
|
805
|
+
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])));
|
|
807
|
+
});
|
|
808
|
+
node
|
|
809
|
+
.command('continue-on-error')
|
|
810
|
+
.argument('<nodeId>')
|
|
811
|
+
.option('--enable', 'ensure continue-on-error is enabled')
|
|
812
|
+
.option('--disable', 'ensure continue-on-error is disabled')
|
|
813
|
+
.description('Idempotently set continue-on-error after reading the current state')
|
|
814
|
+
.action(async (nodeId, opts, command) => {
|
|
815
|
+
const { api, workflowId } = requireContext(command);
|
|
816
|
+
if (opts.enable && opts.disable) {
|
|
817
|
+
throw userError('--enable and --disable are mutually exclusive.', 'CONFLICTING_STATE');
|
|
818
|
+
}
|
|
819
|
+
if (!opts.enable && !opts.disable) {
|
|
820
|
+
throw userError('Pass --enable or --disable.', 'MISSING_STATE');
|
|
821
|
+
}
|
|
822
|
+
const before = await api.tool(workflowId, 'get_node', { nodeId });
|
|
823
|
+
const current = Boolean(before.continueOnError ?? before.data?.continueOnError);
|
|
824
|
+
const desired = Boolean(opts.enable);
|
|
825
|
+
let result = { unchanged: true };
|
|
826
|
+
if (current !== desired) {
|
|
827
|
+
result = await api.mutate(workflowId, () => api.tool(workflowId, 'toggle_continue_on_error', { nodeId }));
|
|
828
|
+
}
|
|
829
|
+
const after = await api.tool(workflowId, 'get_node', { nodeId });
|
|
830
|
+
const verified = Boolean(after.continueOnError ?? after.data?.continueOnError) === desired;
|
|
831
|
+
if (!verified) {
|
|
832
|
+
throw userError('Continue-on-error did not read back as requested.', 'READBACK_MISMATCH');
|
|
833
|
+
}
|
|
834
|
+
emit({ nodeId, enabled: desired, changed: current !== desired, result }, () => printKeyValues([
|
|
835
|
+
['nodeId', nodeId],
|
|
836
|
+
['continue on error', desired ? 'enabled' : 'disabled'],
|
|
837
|
+
['changed', current !== desired ? 'yes' : 'no'],
|
|
838
|
+
['verified', 'yes'],
|
|
839
|
+
]));
|
|
840
|
+
});
|
|
841
|
+
node
|
|
842
|
+
.command('rename')
|
|
843
|
+
.argument('<nodeId>')
|
|
844
|
+
.requiredOption('--name <displayName>')
|
|
845
|
+
.description('Rename a node (also renames its outgoing connection label)')
|
|
846
|
+
.action(async (nodeId, opts, command) => {
|
|
847
|
+
const { api, workflowId } = requireContext(command);
|
|
848
|
+
const result = await api.mutate(workflowId, () => api.tool(workflowId, 'update_node_display_name', { nodeId, displayName: opts.name }));
|
|
849
|
+
emit(result, () => printKeyValues([
|
|
850
|
+
['nodeId', nodeId],
|
|
851
|
+
['displayName', opts.name],
|
|
852
|
+
]));
|
|
853
|
+
});
|
|
854
|
+
node
|
|
855
|
+
.command('delete')
|
|
856
|
+
.argument('<nodeId>')
|
|
857
|
+
.description('Delete a node')
|
|
858
|
+
.action(async (nodeId, _opts, command) => {
|
|
859
|
+
const { api, workflowId } = requireContext(command);
|
|
860
|
+
const result = await api.mutate(workflowId, () => api.tool(workflowId, 'delete_node', { nodeId }));
|
|
861
|
+
emit(result, () => printKeyValues([['deleted', nodeId]]));
|
|
862
|
+
});
|
|
863
|
+
};
|
|
864
|
+
/** `--value 42` should store a number, `--value hello` a string. */
|
|
865
|
+
export const parseLiteral = (raw) => {
|
|
866
|
+
if (raw === undefined)
|
|
867
|
+
return raw;
|
|
868
|
+
const trimmed = raw.trim();
|
|
869
|
+
if (trimmed === '')
|
|
870
|
+
return raw;
|
|
871
|
+
if (/^(true|false|null)$/.test(trimmed))
|
|
872
|
+
return JSON.parse(trimmed);
|
|
873
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed))
|
|
874
|
+
return Number(trimmed);
|
|
875
|
+
if (/^[[{"]/.test(trimmed)) {
|
|
876
|
+
try {
|
|
877
|
+
return JSON.parse(trimmed);
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
return raw;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return raw;
|
|
884
|
+
};
|
|
885
|
+
//# sourceMappingURL=node.js.map
|