@solidnumber/cli 2.15.1 → 2.17.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.
@@ -47,6 +47,7 @@ const chalk_1 = __importDefault(require("chalk"));
47
47
  const config_1 = require("../lib/config");
48
48
  const api_client_1 = require("../lib/api-client");
49
49
  const json_output_1 = require("../lib/json-output");
50
+ const json_arg_1 = require("../lib/json-arg");
50
51
  function requireAuth() {
51
52
  if (!config_1.config.isLoggedIn()) {
52
53
  console.error(chalk_1.default.red('Not logged in. Run `solid auth login` first.'));
@@ -58,31 +59,54 @@ exports.formsCommand = new commander_1.Command('forms')
58
59
  .alias('surveys')
59
60
  .description('Forms & surveys — CRUD, AI generate, export CSV/Excel/PDF');
60
61
  {
61
- const { withListFlags } = require('../lib/command-kit');
62
- const listCmd = exports.formsCommand.command('list').alias('ls').description('List forms/surveys');
63
- withListFlags(listCmd);
62
+ // `list` GOES THROUGH VERBS. It used to read /api/v1/surveys directly, so it
63
+ // showed a different set than `solid forms status` (no lifecycle, native only,
64
+ // and blind to lead forms). Two commands in one CLI disagreeing about what
65
+ // forms exist is worse than either being wrong. One seam, one answer.
66
+ const listCmd = exports.formsCommand.command('list').alias('ls')
67
+ .description('Every form, with its lifecycle — across every connected provider');
68
+ listCmd.option('--provider <name>', 'Only this provider');
69
+ listCmd.option('--json', 'Output as JSON');
64
70
  listCmd.action(async (opts) => {
65
- const { runListCommand } = await Promise.resolve().then(() => __importStar(require('../lib/command-kit')));
66
- await runListCommand(opts, {
67
- spinnerText: 'Loading forms...',
68
- errorText: 'Failed to load forms',
69
- fetch: async (offset, limit) => (await api_client_1.apiClient.get('/api/v1/surveys', { params: { limit, offset } })).data,
70
- extract: (page) => {
71
- if (Array.isArray(page))
72
- return page;
73
- const d = page;
74
- return (d.surveys || d.items || []);
75
- },
76
- render: (items) => {
77
- if (!items.length) {
78
- console.log(chalk_1.default.dim(' No forms yet.'));
79
- return;
71
+ requireAuth();
72
+ const s2 = (0, ora_1.default)('Loading forms...').start();
73
+ try {
74
+ const providers = opts.provider
75
+ ? [opts.provider]
76
+ : ((await callVerb('form.sources')).connected ?? [])
77
+ .map((c) => String(c.provider));
78
+ const rows = [];
79
+ for (const provider of providers.length ? providers : ['native']) {
80
+ try {
81
+ const out = await callVerb('form.list', { provider });
82
+ for (const f of (out.forms ?? [])) {
83
+ rows.push({ ...f, provider });
84
+ }
80
85
  }
81
- for (const f of items) {
82
- console.log(` ${chalk_1.default.bold(String(f.id))} ${f.title || f.name} ${chalk_1.default.dim(String(f.created_at || '').split('T')[0])}`);
83
- }
84
- },
85
- });
86
+ catch { /* one provider down must not blank the list */ }
87
+ }
88
+ if ((0, json_output_1.isJsonOutput)(opts)) {
89
+ s2.stop();
90
+ console.log(JSON.stringify({ forms: rows }, null, 2));
91
+ return;
92
+ }
93
+ s2.stop();
94
+ if (!rows.length) {
95
+ console.log(chalk_1.default.dim(' No forms yet.'));
96
+ console.log(chalk_1.default.dim(' Build one: solid forms build --intent intake --save'));
97
+ return;
98
+ }
99
+ for (const f of rows) {
100
+ const state = lifecycleOf(f);
101
+ const answered = Number(f.response_count ?? 0);
102
+ console.log(` ${chalk_1.default.bold(String(f.external_id).padEnd(5))} ${lifecycleTag(state).padEnd(16)} ` +
103
+ `${String(f.title ?? '').slice(0, 34).padEnd(34)} ` +
104
+ `${chalk_1.default.dim(`${answered} answered`)} ${chalk_1.default.dim(String(f.provider))}`);
105
+ }
106
+ }
107
+ catch (e) {
108
+ fail(s2, 'Failed to load forms', e);
109
+ }
86
110
  });
87
111
  }
88
112
  exports.formsCommand
@@ -93,17 +117,15 @@ exports.formsCommand
93
117
  requireAuth();
94
118
  const s = (0, ora_1.default)(`Loading ${id}...`).start();
95
119
  try {
96
- const res = await api_client_1.apiClient.get(`/api/v1/surveys/${id}`);
97
- if ((0, json_output_1.isJsonOutput)(opts)) {
98
- s.stop();
99
- console.log(JSON.stringify(res.data, null, 2));
100
- return;
101
- }
102
- s.succeed(chalk_1.default.green(`Form ${id}`));
103
- console.log(JSON.stringify(res.data, null, 2));
120
+ // Through the verb, so `get` and `describe` can never disagree about a
121
+ // form. The verb also carries the lifecycle and the public link, which
122
+ // /api/v1/surveys/<id> knows nothing about.
123
+ const out = await callVerb('form.describe', { form_id: String(id), provider: 'native' });
124
+ s.stop();
125
+ console.log(JSON.stringify(out, null, 2));
104
126
  }
105
127
  catch (e) {
106
- fail(s, 'Failed', e);
128
+ fail(s, 'Failed to load the form', e);
107
129
  }
108
130
  });
109
131
  exports.formsCommand
@@ -122,13 +144,37 @@ exports.formsCommand
122
144
  }
123
145
  const s = (0, ora_1.default)('Creating form...').start();
124
146
  try {
125
- const res = await api_client_1.apiClient.post('/api/v1/surveys/create', body);
126
- s.succeed(chalk_1.default.green(`Form created: ${res.data.id}`));
147
+ const out = await callVerbConfirmed('survey.create', {
148
+ title: body.title,
149
+ questions: body.questions ?? body.fields ?? [],
150
+ ...(body.description ? { description: body.description } : {}),
151
+ });
152
+ if (out.status === 'error' || out.status === 'invalid') {
153
+ s.fail(chalk_1.default.red(String(out.summary ?? 'Could not create it')));
154
+ process.exitCode = 1;
155
+ return;
156
+ }
157
+ s.succeed(chalk_1.default.green(`Form created: ${out.survey_id ?? out.id ?? ''}`));
158
+ console.log(chalk_1.default.dim(' It starts as a draft. Publish when ready:'));
159
+ console.log(chalk_1.default.dim(` solid forms publish ${out.survey_id ?? out.id ?? '<id>'}`));
127
160
  }
128
161
  catch (e) {
129
- fail(s, 'Failed', e);
162
+ fail(s, 'Failed to create the form', e);
130
163
  }
131
164
  });
165
+ // ⛔ WHAT IS STILL ON REST, AND WHY — checked 2026-08-26, not assumed.
166
+ //
167
+ // update / delete no survey.update or survey.delete verb exists. Inventing
168
+ // a CLI-only path to a destructive operation is worse than
169
+ // an honest REST call; these move when the verbs do.
170
+ // export no verb — it streams a file (csv/excel/pdf), which the
171
+ // JSON verb envelope has no shape for.
172
+ // followup /surveys/<id>/followup drafts follow-ups to RESPONSES.
173
+ // review.followup is a different thing entirely (it takes a
174
+ // rating and drafts a REVIEW reply), so routing one to the
175
+ // other would silently change what the command does.
176
+ //
177
+ // Everything else in this file goes through the verb layer.
132
178
  exports.formsCommand
133
179
  .command('update <id>')
134
180
  .description('Update a form')
@@ -173,46 +219,68 @@ exports.formsCommand
173
219
  requireAuth();
174
220
  const s = (0, ora_1.default)('Generating form...').start();
175
221
  try {
176
- const res = await api_client_1.apiClient.post('/api/v1/surveys/generate', { prompt });
177
- s.succeed(chalk_1.default.green(`Form generated: ${res.data.id}`));
222
+ const out = await callVerbConfirmed('survey.generate', { prompt });
223
+ if (out.status === 'error' || out.status === 'invalid') {
224
+ s.fail(chalk_1.default.red(String(out.summary ?? 'Could not generate it')));
225
+ process.exitCode = 1;
226
+ return;
227
+ }
228
+ s.succeed(chalk_1.default.green(`Form generated: ${out.survey_id ?? out.id ?? ''}`));
229
+ console.log(chalk_1.default.dim(' Read it before publishing: solid forms describe '
230
+ + String(out.survey_id ?? out.id ?? '<id>')));
178
231
  }
179
232
  catch (e) {
180
- fail(s, 'Failed', e);
233
+ fail(s, 'Failed to generate the form', e);
181
234
  }
182
235
  });
183
236
  exports.formsCommand
184
237
  .command('optimize <id>')
185
- .description('AI-optimize an existing form (shorter, better copy)')
186
- .action(async (id) => {
238
+ .description('ADA reviews the questions — suggestions only, nothing changes')
239
+ .option('--json', 'Output as JSON')
240
+ .action(async (id, opts) => {
187
241
  requireAuth();
188
- const s = (0, ora_1.default)('Optimizing...').start();
242
+ const sp = (0, ora_1.default)('Looking it over...').start();
189
243
  try {
190
- await api_client_1.apiClient.post(`/api/v1/surveys/${id}/optimize`);
191
- s.succeed(chalk_1.default.green('Optimized'));
244
+ const out = await callVerb('survey.optimize', { survey_id: Number(id) });
245
+ if ((0, json_output_1.isJsonOutput)(opts)) {
246
+ sp.stop();
247
+ console.log(JSON.stringify(out, null, 2));
248
+ return;
249
+ }
250
+ sp.stop();
251
+ const analysis = (out.analysis ?? {});
252
+ const suggestions = (analysis.suggested_improvements ?? []);
253
+ if (!suggestions.length) {
254
+ console.log(chalk_1.default.dim(' Nothing to change — it reads well.'));
255
+ return;
256
+ }
257
+ console.log(chalk_1.default.dim(' Suggestions only — nothing changes until you change it.'));
258
+ for (const sg of suggestions) {
259
+ console.log(` ${chalk_1.default.yellow('*')} ${sg.issue}`);
260
+ if (sg.fix)
261
+ console.log(` ${chalk_1.default.dim('fix:')} ${sg.fix}`);
262
+ if (sg.impact)
263
+ console.log(` ${chalk_1.default.dim('why:')} ${sg.impact}`);
264
+ }
192
265
  }
193
266
  catch (e) {
194
- fail(s, 'Failed', e);
267
+ fail(sp, 'Failed to look it over', e);
195
268
  }
196
269
  });
197
270
  exports.formsCommand
198
271
  .command('analyze <id>')
199
- .description('AI analyze submissions for insights')
272
+ .description('What the answers add up to')
200
273
  .option('--json', 'Output as JSON')
201
- .action(async (id, opts) => {
274
+ .action(async (id) => {
202
275
  requireAuth();
203
- const s = (0, ora_1.default)('Analyzing...').start();
276
+ const sp = (0, ora_1.default)('Analyzing...').start();
204
277
  try {
205
- const res = await api_client_1.apiClient.post(`/api/v1/surveys/${id}/analyze`);
206
- if ((0, json_output_1.isJsonOutput)(opts)) {
207
- s.stop();
208
- console.log(JSON.stringify(res.data, null, 2));
209
- return;
210
- }
211
- s.succeed(chalk_1.default.green('Analysis'));
212
- console.log(JSON.stringify(res.data, null, 2));
278
+ const out = await callVerb('survey.analyze', { survey_id: Number(id) });
279
+ sp.stop();
280
+ console.log(JSON.stringify(out, null, 2));
213
281
  }
214
282
  catch (e) {
215
- fail(s, 'Failed', e);
283
+ fail(sp, 'Failed to analyze', e);
216
284
  }
217
285
  });
218
286
  exports.formsCommand
@@ -232,17 +300,47 @@ exports.formsCommand
232
300
  });
233
301
  exports.formsCommand
234
302
  .command('embed <id>')
235
- .description('Get embed code / public link for a form')
236
- .action(async (id) => {
303
+ .description('Paste-ready embed for a live form — iframe, link, or button')
304
+ .option('--provider <name>', 'Form provider', 'native')
305
+ .option('--as <kind>', 'iframe | link | button', 'iframe')
306
+ .action(async (id, opts) => {
237
307
  requireAuth();
238
- const s = (0, ora_1.default)('Loading embed...').start();
308
+ // THE DOOR, NOT THE LEGACY LINK. This read /api/v1/surveys/<id>/embed
309
+ // the pre-lifecycle system's public URL — so after the standing door shipped
310
+ // (2026-08-20) `solid forms embed` was handing out the OLD address while
311
+ // `solid forms link` handed out the new one. Two commands, two answers, one
312
+ // of them wrong.
313
+ const sp = (0, ora_1.default)('Building the embed...').start();
314
+ let out;
239
315
  try {
240
- const res = await api_client_1.apiClient.get(`/api/v1/surveys/${id}/embed`);
241
- s.succeed(chalk_1.default.green('Embed'));
242
- console.log(JSON.stringify(res.data, null, 2));
316
+ out = await callVerb('form.describe', { form_id: String(id), provider: opts.provider });
243
317
  }
244
318
  catch (e) {
245
- fail(s, 'Failed', e);
319
+ fail(sp, 'Failed to build the embed', e);
320
+ return;
321
+ }
322
+ const url = out.public_url ?? out.public_path;
323
+ if (!url) {
324
+ sp.fail(chalk_1.default.yellow(`No public address — this form is ${String(out.lifecycle ?? 'not live')}.`));
325
+ console.error(chalk_1.default.dim(' Only a live form can be embedded. Publish it first:'));
326
+ console.error(chalk_1.default.dim(` solid forms publish ${id}`));
327
+ process.exitCode = 1;
328
+ return;
329
+ }
330
+ sp.stop();
331
+ const title = String(out.title ?? 'Form');
332
+ const kind = String(opts.as).toLowerCase();
333
+ if (kind === 'link') {
334
+ console.log(`<a href="${url}">${title}</a>`);
335
+ }
336
+ else if (kind === 'button') {
337
+ console.log(`<a href="${url}" style="display:inline-block;padding:12px 20px;border-radius:8px;` +
338
+ `background:#0f5346;color:#fff;text-decoration:none;font-weight:600">${title}</a>`);
339
+ }
340
+ else {
341
+ console.log(`<!-- ${title} — answers land in your CRM -->\n` +
342
+ `<iframe src="${url}" title="${title}" width="100%" height="640" ` +
343
+ `style="border:0;border-radius:12px" loading="lazy"></iframe>`);
246
344
  }
247
345
  });
248
346
  exports.formsCommand
@@ -274,11 +372,675 @@ exports.formsCommand
274
372
  fail(s, 'Failed', e);
275
373
  }
276
374
  });
375
+ // ── the verb seam ──────────────────────────────────────────────────────────
376
+ //
377
+ // ⛔ EVERYTHING BELOW CALLS VERBS, NOT REST. The commands above this line wrap
378
+ // /api/v1/surveys — the authoring endpoints that predate the forms platform —
379
+ // and they are why `solid forms` knew nothing about the lifecycle, the
380
+ // respondent-facing reads, or a form's public address. New work goes through
381
+ // the verb layer so the CLI, the dashboard, MCP and an agent on a live call
382
+ // all get the same validation, consent and tenant scoping.
383
+ //
384
+ // ⛔ NEVER SEND company_id. The backend binds the tenant from the authenticated
385
+ // principal; a CLI that could pass one is a CLI that could pass someone else's.
386
+ async function callVerb(name, payload = {}) {
387
+ // ⛔ ONLY THE VERB IS HYPHENATED, NEVER THE NAMESPACE. The backend routes
388
+ // `<ns>/<verb-with-hyphens>` (controllers/agent_verb_index.py::_verb_http_endpoint),
389
+ // and plenty of namespaces carry underscores — call_flow, comms_workflow,
390
+ // agent_config. Hyphenating the whole name would 404 every one of them.
391
+ const [ns, ...rest] = name.split('.');
392
+ const verb = rest.join('.').replace(/_/g, '-');
393
+ const endpoint = verb ? `/api/v1/agent/${ns}/${verb}` : `/api/v1/agent/${ns}`;
394
+ const res = await api_client_1.apiClient.post(endpoint, payload);
395
+ const body = res.data;
396
+ // The envelope is {ok, result} on some surfaces and the bare result on others.
397
+ return (body && typeof body === 'object' && 'result' in body ? body.result : body);
398
+ }
399
+ /** A write verb. Consent travels as `confirm: true` — the backend refuses without it. */
400
+ async function callVerbConfirmed(name, payload = {}) {
401
+ return callVerb(name, { ...payload, confirm: true });
402
+ }
403
+ function lifecycleOf(f) {
404
+ if ((f.status ?? 'published') === 'draft')
405
+ return 'draft';
406
+ return f.is_active === false ? 'paused' : 'live';
407
+ }
408
+ function lifecycleTag(state) {
409
+ return state === 'live' ? chalk_1.default.green('live')
410
+ : state === 'draft' ? chalk_1.default.yellow('draft')
411
+ : chalk_1.default.dim('paused');
412
+ }
413
+ exports.formsCommand
414
+ .command('describe <id>')
415
+ .description('The questions as a respondent meets them, plus lifecycle + public link')
416
+ .option('--provider <name>', 'Form provider', 'native')
417
+ .option('--json', 'Output as JSON')
418
+ .action(async (id, opts) => {
419
+ requireAuth();
420
+ const s = (0, ora_1.default)(`Reading ${id}...`).start();
421
+ try {
422
+ const out = await callVerb('form.describe', { form_id: String(id), provider: opts.provider });
423
+ if ((0, json_output_1.isJsonOutput)(opts)) {
424
+ s.stop();
425
+ console.log(JSON.stringify(out, null, 2));
426
+ return;
427
+ }
428
+ s.stop();
429
+ const questions = (out.questions ?? []);
430
+ console.log(` ${chalk_1.default.bold(String(out.title ?? id))} ${lifecycleTag(String(out.lifecycle ?? ''))}`);
431
+ if (out.public_url)
432
+ console.log(` ${chalk_1.default.dim('public link')} ${chalk_1.default.cyan(String(out.public_url))}`);
433
+ else if (out.public_path)
434
+ console.log(` ${chalk_1.default.dim('public path')} ${String(out.public_path)}`);
435
+ console.log(` ${chalk_1.default.dim(`${questions.length} question${questions.length === 1 ? '' : 's'}`)}`);
436
+ questions.forEach((q, i) => {
437
+ const req = q.required ? chalk_1.default.dim(' (required)') : '';
438
+ console.log(` ${String(i + 1).padStart(2, '0')} ${chalk_1.default.dim(String(q.kind).padEnd(8))} ${q.prompt}${req}`);
439
+ });
440
+ }
441
+ catch (e) {
442
+ fail(s, 'Failed to read the form', e);
443
+ }
444
+ });
445
+ exports.formsCommand
446
+ .command('publish <id>')
447
+ .description('Publish a draft — it starts taking answers')
448
+ .action(async (id) => {
449
+ requireAuth();
450
+ const s = (0, ora_1.default)(`Publishing ${id}...`).start();
451
+ try {
452
+ const out = await callVerbConfirmed('survey.publish', { survey_id: Number(id) });
453
+ if (out.status === 'error' || out.status === 'not_found') {
454
+ s.fail(chalk_1.default.red(String(out.summary ?? 'Could not publish')));
455
+ process.exit(1);
456
+ }
457
+ s.succeed(chalk_1.default.green('Published — live and taking answers'));
458
+ try {
459
+ const d = await callVerb('form.describe', { form_id: String(id), provider: 'native' });
460
+ if (d.public_url)
461
+ console.log(` ${chalk_1.default.dim('public link')} ${chalk_1.default.cyan(String(d.public_url))}`);
462
+ }
463
+ catch { /* the publish still succeeded */ }
464
+ }
465
+ catch (e) {
466
+ fail(s, 'Failed to publish', e);
467
+ }
468
+ });
469
+ exports.formsCommand
470
+ .command('pause <id>')
471
+ .description('Stop taking new answers. Every answer already given is kept')
472
+ .action(async (id) => {
473
+ requireAuth();
474
+ const s = (0, ora_1.default)(`Pausing ${id}...`).start();
475
+ try {
476
+ const out = await callVerbConfirmed('survey.set_live', { survey_id: Number(id), live: false });
477
+ if (out.status === 'invalid' || out.status === 'error') {
478
+ s.fail(chalk_1.default.red(String(out.summary ?? 'Could not pause it')));
479
+ process.exit(1);
480
+ }
481
+ s.succeed(chalk_1.default.green('Paused — every answer kept, no new ones'));
482
+ }
483
+ catch (e) {
484
+ fail(s, 'Failed to pause', e);
485
+ }
486
+ });
487
+ exports.formsCommand
488
+ .command('resume <id>')
489
+ .description('Take answers again on a paused form')
490
+ .action(async (id) => {
491
+ requireAuth();
492
+ const s = (0, ora_1.default)(`Resuming ${id}...`).start();
493
+ try {
494
+ const out = await callVerbConfirmed('survey.set_live', { survey_id: Number(id), live: true });
495
+ if (out.status === 'invalid' || out.status === 'error') {
496
+ s.fail(chalk_1.default.red(String(out.summary ?? 'Could not resume it')));
497
+ process.exit(1);
498
+ }
499
+ s.succeed(chalk_1.default.green('Live — taking answers again'));
500
+ }
501
+ catch (e) {
502
+ fail(s, 'Failed to resume', e);
503
+ }
504
+ });
505
+ exports.formsCommand
506
+ .command('link <id>')
507
+ .description("The form's standing public URL — the one address you can share anywhere")
508
+ .option('--provider <name>', 'Form provider', 'native')
509
+ .action(async (id, opts) => {
510
+ requireAuth();
511
+ const s = (0, ora_1.default)('Resolving...').start();
512
+ // ⛔ THE EXIT LIVES OUTSIDE THE try. An intentional process.exit inside it
513
+ // is caught by the error handler below and reported as "Failed to resolve
514
+ // the link" — the command would then exit 0 with a misleading message.
515
+ let out;
516
+ try {
517
+ out = await callVerb('form.describe', { form_id: String(id), provider: opts.provider });
518
+ }
519
+ catch (e) {
520
+ fail(s, 'Failed to resolve the link', e);
521
+ return;
522
+ }
523
+ if (out.public_url || out.public_path) {
524
+ s.stop();
525
+ // Bare URL on stdout so it pipes into pbcopy, a QR generator, anything.
526
+ console.log(String(out.public_url ?? out.public_path));
527
+ return;
528
+ }
529
+ s.fail(chalk_1.default.yellow(`No public link — this form is ${String(out.lifecycle ?? 'not live')}.`));
530
+ console.error(chalk_1.default.dim(' Only a live form has a public address. Publish it first:'));
531
+ console.error(chalk_1.default.dim(` solid forms publish ${id}`));
532
+ process.exit(1);
533
+ });
534
+ exports.formsCommand
535
+ .command('responses <id>')
536
+ .description('What people actually answered')
537
+ .option('--provider <name>', 'Form provider', 'native')
538
+ .option('--limit <n>', 'How many to show', '25')
539
+ .option('--json', 'Output as JSON')
540
+ .action(async (id, opts) => {
541
+ requireAuth();
542
+ const s = (0, ora_1.default)('Loading responses...').start();
543
+ try {
544
+ const out = await callVerb('form.responses', {
545
+ form_id: String(id), provider: opts.provider, per_page: Number(opts.limit),
546
+ });
547
+ if ((0, json_output_1.isJsonOutput)(opts)) {
548
+ s.stop();
549
+ console.log(JSON.stringify(out, null, 2));
550
+ return;
551
+ }
552
+ s.stop();
553
+ const rows = (out.responses ?? []);
554
+ if (!rows.length) {
555
+ console.log(chalk_1.default.dim(' Nothing answered yet.'));
556
+ return;
557
+ }
558
+ for (const r of rows) {
559
+ const when = String(r.completed_at ?? r.created_at ?? '').split('T')[0];
560
+ const channels = Array.isArray(r.channels) ? r.channels.join('+') : '';
561
+ console.log(` ${chalk_1.default.bold(String(r.external_id ?? r.id ?? ''))} ${chalk_1.default.dim(when)} ${chalk_1.default.dim(channels)}`);
562
+ for (const [k, v] of Object.entries((r.answers ?? {}))) {
563
+ console.log(` ${chalk_1.default.dim(k)}: ${String(v)}`);
564
+ }
565
+ }
566
+ }
567
+ catch (e) {
568
+ fail(s, 'Failed to load responses', e);
569
+ }
570
+ });
571
+ exports.formsCommand
572
+ .command('status')
573
+ .description('Every form with its lifecycle — the CLI view of the library')
574
+ .option('--provider <name>', 'Form provider', 'native')
575
+ .option('--json', 'Output as JSON')
576
+ .action(async (opts) => {
577
+ requireAuth();
578
+ const s = (0, ora_1.default)('Loading...').start();
579
+ try {
580
+ const out = await callVerb('form.list', { provider: opts.provider });
581
+ if ((0, json_output_1.isJsonOutput)(opts)) {
582
+ s.stop();
583
+ console.log(JSON.stringify(out, null, 2));
584
+ return;
585
+ }
586
+ s.stop();
587
+ const rows = (out.forms ?? []);
588
+ if (!rows.length) {
589
+ console.log(chalk_1.default.dim(' No forms yet.'));
590
+ return;
591
+ }
592
+ for (const f of rows) {
593
+ const state = lifecycleOf(f);
594
+ const answered = Number(f.response_count ?? 0);
595
+ console.log(` ${chalk_1.default.bold(String(f.external_id).padEnd(5))} ${lifecycleTag(state).padEnd(16)} ` +
596
+ `${String(f.title ?? '').padEnd(34)} ${chalk_1.default.dim(`${answered} answered`)}`);
597
+ }
598
+ }
599
+ catch (e) {
600
+ fail(s, 'Failed to load forms', e);
601
+ }
602
+ });
603
+ // ── moments: WHEN a form goes out ──────────────────────────────────────────
604
+ //
605
+ // The dashboard's most-prompted next step after publishing, and it had no CLI
606
+ // surface at all — so an AI could publish a form and then nothing could ever
607
+ // send it.
608
+ const momentsCmd = exports.formsCommand.command('moments')
609
+ .description('When a form goes out — the tenant\'s moments');
610
+ momentsCmd
611
+ .command('list', { isDefault: true })
612
+ .description('Every moment, and which form (if any) it sends')
613
+ .option('--json', 'Output as JSON')
614
+ .action(async (opts) => {
615
+ requireAuth();
616
+ const sp = (0, ora_1.default)('Loading moments...').start();
617
+ try {
618
+ const out = await callVerb('form.triggers');
619
+ if ((0, json_output_1.isJsonOutput)(opts)) {
620
+ sp.stop();
621
+ console.log(JSON.stringify(out, null, 2));
622
+ return;
623
+ }
624
+ sp.stop();
625
+ const rows = (out.triggers ?? []);
626
+ if (!rows.length) {
627
+ console.log(chalk_1.default.dim(' No moments available.'));
628
+ return;
629
+ }
630
+ for (const t of rows) {
631
+ const on = t.configured ? chalk_1.default.green('on ') : chalk_1.default.dim('off');
632
+ const what = t.configured && t.form_id
633
+ ? `sends form ${t.form_id} ${t.stage === 'before' ? 'beforehand' : 'afterwards'}`
634
+ : chalk_1.default.dim('nothing set');
635
+ console.log(` ${on} ${String(t.event).padEnd(24)} ${what}`);
636
+ }
637
+ console.log(chalk_1.default.dim('\n Wire one: solid forms moments set <event> --form <id>'));
638
+ }
639
+ catch (e) {
640
+ fail(sp, 'Failed to load moments', e);
641
+ }
642
+ });
643
+ momentsCmd
644
+ .command('set <event>')
645
+ .description('Send a form at this moment')
646
+ .requiredOption('--form <id>', 'The form to send')
647
+ .option('--provider <name>', 'Form provider', 'native')
648
+ .option('--stage <when>', 'before | after')
649
+ .action(async (event, opts) => {
650
+ requireAuth();
651
+ const sp = (0, ora_1.default)(`Wiring ${event}...`).start();
652
+ try {
653
+ const out = await callVerbConfirmed('form.configure_trigger', {
654
+ event, form_id: String(opts.form), provider: opts.provider,
655
+ enabled: true, ...(opts.stage ? { stage: opts.stage } : {}),
656
+ });
657
+ if (out.status === 'invalid' || out.status === 'error') {
658
+ sp.fail(chalk_1.default.red(String(out.summary ?? 'Could not wire it')));
659
+ process.exitCode = 1;
660
+ return;
661
+ }
662
+ sp.succeed(chalk_1.default.green(`${event} now sends form ${opts.form}`));
663
+ }
664
+ catch (e) {
665
+ fail(sp, 'Failed to wire the moment', e);
666
+ }
667
+ });
668
+ momentsCmd
669
+ .command('off <event>')
670
+ .description('Stop sending anything at this moment')
671
+ .action(async (event) => {
672
+ requireAuth();
673
+ const sp = (0, ora_1.default)(`Switching ${event} off...`).start();
674
+ try {
675
+ const out = await callVerbConfirmed('form.configure_trigger', { event, enabled: false });
676
+ if (out.status === 'invalid' || out.status === 'error') {
677
+ sp.fail(chalk_1.default.red(String(out.summary ?? 'Could not switch it off')));
678
+ process.exitCode = 1;
679
+ return;
680
+ }
681
+ sp.succeed(chalk_1.default.green(`${event} sends nothing now`));
682
+ }
683
+ catch (e) {
684
+ fail(sp, 'Failed to switch it off', e);
685
+ }
686
+ });
687
+ // ── reviews: where a happy customer is sent ────────────────────────────────
688
+ const reviewsCmd = exports.formsCommand.command('reviews')
689
+ .description('Where happy customers are asked to leave a review');
690
+ reviewsCmd
691
+ .command('list', { isDefault: true })
692
+ .description('The review destinations this tenant has set')
693
+ .option('--json', 'Output as JSON')
694
+ .action(async (opts) => {
695
+ requireAuth();
696
+ const sp = (0, ora_1.default)('Loading destinations...').start();
697
+ try {
698
+ const out = await callVerb('review.destinations');
699
+ if ((0, json_output_1.isJsonOutput)(opts)) {
700
+ sp.stop();
701
+ console.log(JSON.stringify(out, null, 2));
702
+ return;
703
+ }
704
+ sp.stop();
705
+ const rows = (out.destinations ?? []);
706
+ if (!rows.length) {
707
+ console.log(chalk_1.default.dim(' No review destinations yet.'));
708
+ console.log(chalk_1.default.dim(' Set one: solid forms reviews set google <url>'));
709
+ return;
710
+ }
711
+ for (const d of rows)
712
+ console.log(` ${String(d.platform).padEnd(12)} ${d.url}`);
713
+ }
714
+ catch (e) {
715
+ fail(sp, 'Failed to load destinations', e);
716
+ }
717
+ });
718
+ reviewsCmd
719
+ .command('set <platform> <url>')
720
+ .description('Point future review requests at this link')
721
+ .action(async (platform, url) => {
722
+ requireAuth();
723
+ const sp = (0, ora_1.default)('Saving...').start();
724
+ try {
725
+ const out = await callVerbConfirmed('review.set_destination', { platform, url });
726
+ if (out.status === 'invalid' || out.status === 'error') {
727
+ sp.fail(chalk_1.default.red(String(out.summary ?? 'Could not save it')));
728
+ process.exitCode = 1;
729
+ return;
730
+ }
731
+ sp.succeed(chalk_1.default.green(`${platform} review link saved`));
732
+ }
733
+ catch (e) {
734
+ fail(sp, 'Failed to save', e);
735
+ }
736
+ });
737
+ // ── build: a starter playbook, in THIS tenant's words ──────────────────────
738
+ exports.formsCommand
739
+ .command('build')
740
+ .description("Draft a form for this business — in its own industry's words")
741
+ .option('--intent <kind>', 'intake | feedback | lead_qualify | onboarding', 'intake')
742
+ .option('--save', 'Save it as a draft (nothing is saved without this)')
743
+ .option('--title <text>', 'Title to save it under')
744
+ .option('--json', 'Output as JSON')
745
+ .action(async (opts) => {
746
+ requireAuth();
747
+ const sp = (0, ora_1.default)('Drafting...').start();
748
+ try {
749
+ // ⛔ kb_sub_code IS NOT PASSED. The verb resolves THIS tenant's industry
750
+ // from their own company row; sending one from the CLI would be guessing
751
+ // at someone's business. See mcp/tools/form_session_verbs._tenant_kb_sub_code.
752
+ const draft = await callVerb('playbook.suggest', { intent: opts.intent });
753
+ if (draft.status === 'invalid' || draft.status === 'error') {
754
+ sp.fail(chalk_1.default.red(String(draft.summary ?? 'Could not draft it')));
755
+ process.exitCode = 1;
756
+ return;
757
+ }
758
+ if ((0, json_output_1.isJsonOutput)(opts) && !opts.save) {
759
+ sp.stop();
760
+ console.log(JSON.stringify(draft, null, 2));
761
+ return;
762
+ }
763
+ sp.stop();
764
+ const preview = (draft.preview ?? draft.steps ?? []);
765
+ console.log(` ${chalk_1.default.bold(String(draft.title ?? opts.intent))} ${chalk_1.default.dim(`(${preview.length} steps)`)}`);
766
+ preview.forEach((st, i) => {
767
+ console.log(` ${String(i + 1).padStart(2, '0')} ${chalk_1.default.dim(String(st.kind ?? 'question').padEnd(8))} ${st.prompt ?? ''}`);
768
+ });
769
+ if (!opts.save) {
770
+ console.log(chalk_1.default.dim('\n Nothing saved. Add --save to keep it as a draft.'));
771
+ return;
772
+ }
773
+ const sp2 = (0, ora_1.default)('Saving as a draft...').start();
774
+ const saved = await callVerbConfirmed('playbook.save', {
775
+ title: opts.title ?? String(draft.title ?? `${opts.intent} form`),
776
+ steps: draft.steps ?? [],
777
+ ...(draft.outcomes ? { outcomes: draft.outcomes } : {}),
778
+ });
779
+ if (saved.status === 'invalid' || saved.status === 'error') {
780
+ sp2.fail(chalk_1.default.red(String(saved.summary ?? 'Could not save it')));
781
+ process.exitCode = 1;
782
+ return;
783
+ }
784
+ sp2.succeed(chalk_1.default.green(`Saved as a draft (${saved.playbook_id ?? saved.form_id ?? '?'})`));
785
+ console.log(chalk_1.default.dim(` Publish when it's ready: solid forms publish ${saved.playbook_id ?? saved.form_id ?? '<id>'}`));
786
+ }
787
+ catch (e) {
788
+ fail(sp, 'Failed to build', e);
789
+ }
790
+ });
791
+ // ── walk: answer a form from the terminal, exactly as an agent does ─────────
792
+ //
793
+ // The CLI equivalent of the Live Form bench: next_question → capture → submit,
794
+ // the same three verbs a voice agent uses mid-call. Scriptable on purpose, so
795
+ // an AI can drive it non-interactively.
796
+ exports.formsCommand
797
+ .command('walk <id>')
798
+ .description('Answer a form the way an agent does — next question, capture, submit')
799
+ .option('--provider <name>', 'Form provider', 'native')
800
+ .option('--session <ref>', 'Continue an existing session')
801
+ .option('--question <id>', 'The question being answered (with --answer)')
802
+ .option('--answer <value>', 'Record an answer, then show what is next')
803
+ .option('--submit', 'Finish and persist the response')
804
+ .option('--branching', 'Walk the PLAYBOOK — branches, message and action steps, skips honoured')
805
+ .option('--answers <json>', 'With --branching: answers so far, as {"q1":"yes"} or @file.json')
806
+ .option('--json', 'Output as JSON')
807
+ .action(async (id, opts) => {
808
+ requireAuth();
809
+ const base = { form_id: String(id), provider: opts.provider };
810
+ const sp = (0, ora_1.default)('Working...').start();
811
+ // ⛔ BRANCHES DO NOT TRAVEL THROUGH form.next_question. That verb walks by
812
+ // POSITION — a branched playbook served through it asks every question in
813
+ // order (the superset), which is safe for a form but wrong for a quiz whose
814
+ // skips are the point. playbook.next_step is the real walk, and it takes the
815
+ // answers as a MAP rather than a session ref, so this mode is stateless and
816
+ // scriptable: pass back what you have, get the next step.
817
+ if (opts.branching) {
818
+ let answered = {};
819
+ if (opts.answers) {
820
+ const parsed = (0, json_arg_1.parseJsonArg)('answers', opts.answers);
821
+ if (parsed && typeof parsed === 'object')
822
+ answered = parsed;
823
+ }
824
+ if (opts.answer !== undefined) {
825
+ if (!opts.question) {
826
+ sp.fail(chalk_1.default.red('--answer needs --question <id>'));
827
+ process.exitCode = 1;
828
+ return;
829
+ }
830
+ answered[String(opts.question)] = opts.answer;
831
+ }
832
+ try {
833
+ const out = await callVerb('playbook.next_step', {
834
+ playbook_id: String(id), answered,
835
+ });
836
+ sp.stop();
837
+ if (out.status === 'error' || out.status === 'invalid') {
838
+ console.error(chalk_1.default.red(String(out.summary ?? 'Could not walk it')));
839
+ process.exitCode = 1;
840
+ return;
841
+ }
842
+ if ((0, json_output_1.isJsonOutput)(opts)) {
843
+ console.log(JSON.stringify({ ...out, answered }, null, 2));
844
+ return;
845
+ }
846
+ console.log(` ${chalk_1.default.bold(String(out.title ?? id))} ${chalk_1.default.dim(`${out.answered_count ?? 0} answered`)}`);
847
+ if (out.complete) {
848
+ console.log(chalk_1.default.green(' The playbook is finished.'));
849
+ console.log(chalk_1.default.dim(` Score it: solid forms quiz ${id} --answers '${JSON.stringify(answered)}'`));
850
+ return;
851
+ }
852
+ const step = (out.step ?? {});
853
+ const kind = String(step.kind ?? 'question');
854
+ console.log(` ${chalk_1.default.dim(kind.padEnd(8))} ${chalk_1.default.bold(String(step.prompt ?? ''))}`);
855
+ if (kind === 'action') {
856
+ // The verb NAME comes back; the AGENT calls it. Say so rather than
857
+ // implying the CLI just did something.
858
+ console.log(` ${chalk_1.default.dim('the agent would call')} ${chalk_1.default.cyan(String(step.verb ?? '—'))}`);
859
+ if (step.verb_args && Object.keys(step.verb_args).length) {
860
+ console.log(` ${chalk_1.default.dim('with')} ${JSON.stringify(step.verb_args)}`);
861
+ }
862
+ }
863
+ if (Array.isArray(step.choices) && step.choices.length) {
864
+ console.log(` ${chalk_1.default.dim('choices:')} ${step.choices.join(' / ')}`);
865
+ }
866
+ const next = { ...answered, [String(step.id ?? 'q')]: '...' };
867
+ console.log(chalk_1.default.dim(`\n Answer it: solid forms walk ${id} --branching --question ${step.id}` +
868
+ ` --answer "..." --answers '${JSON.stringify(answered)}'`));
869
+ void next;
870
+ return;
871
+ }
872
+ catch (e) {
873
+ fail(sp, 'Failed to walk the playbook', e);
874
+ return;
875
+ }
876
+ }
877
+ try {
878
+ if (opts.submit) {
879
+ const out = await callVerbConfirmed('form.submit', {
880
+ ...base, ...(opts.session ? { session_ref: opts.session } : {}),
881
+ });
882
+ sp.stop();
883
+ if (out.status === 'invalid') {
884
+ console.error(chalk_1.default.yellow(String(out.summary)));
885
+ process.exitCode = 1;
886
+ return;
887
+ }
888
+ if ((0, json_output_1.isJsonOutput)(opts)) {
889
+ console.log(JSON.stringify(out, null, 2));
890
+ return;
891
+ }
892
+ console.log(chalk_1.default.green(` Submitted — response ${out.response_id ?? ''}`));
893
+ if (out.contact_id) {
894
+ console.log(` ${chalk_1.default.dim('landed on contact')} ${out.contact_id}` +
895
+ (out.contact_created ? chalk_1.default.dim(' (new lead)') : ''));
896
+ }
897
+ return;
898
+ }
899
+ let session = opts.session;
900
+ if (opts.answer !== undefined) {
901
+ if (!opts.question) {
902
+ sp.fail(chalk_1.default.red('--answer needs --question <id>'));
903
+ process.exitCode = 1;
904
+ return;
905
+ }
906
+ const cap = await callVerbConfirmed('form.capture', {
907
+ ...base, question_id: String(opts.question), value: opts.answer,
908
+ channel: 'cli', ...(session ? { session_ref: session } : {}),
909
+ });
910
+ session = String(cap.session_ref ?? session ?? '');
911
+ }
912
+ const next = await callVerb('form.next_question', {
913
+ ...base, ...(session ? { session_ref: session } : {}),
914
+ });
915
+ sp.stop();
916
+ if ((0, json_output_1.isJsonOutput)(opts)) {
917
+ console.log(JSON.stringify({ ...next, session_ref: session }, null, 2));
918
+ return;
919
+ }
920
+ if (session)
921
+ console.log(` ${chalk_1.default.dim('session')} ${session}`);
922
+ if (next.complete) {
923
+ console.log(chalk_1.default.green(' Every question answered.'));
924
+ console.log(chalk_1.default.dim(` Finish it: solid forms walk ${id} --session ${session ?? ''} --submit`));
925
+ return;
926
+ }
927
+ const q = (next.next_question ?? {});
928
+ console.log(` ${chalk_1.default.bold(String(q.prompt ?? ''))}`);
929
+ console.log(` ${chalk_1.default.dim(`${q.kind}${q.required ? ', required' : ''}`)}`);
930
+ if (Array.isArray(q.choices) && q.choices.length) {
931
+ console.log(` ${chalk_1.default.dim('choices:')} ${q.choices.join(' / ')}`);
932
+ }
933
+ console.log(chalk_1.default.dim(`\n Answer it: solid forms walk ${id} --question ${q.id}` +
934
+ ` --answer "..."${session ? ` --session ${session}` : ''}`));
935
+ }
936
+ catch (e) {
937
+ fail(sp, 'Failed', e);
938
+ }
939
+ });
940
+ // ── quiz: score a run and see which band it lands in ───────────────────────
941
+ //
942
+ // A quiz's scoring is server-side ONLY — the answer key never travels with the
943
+ // questions, which is right, and it meant a tenant could not check their own
944
+ // bands without finding a real respondent. This scores a hypothetical run.
945
+ exports.formsCommand
946
+ .command('quiz <id>')
947
+ .description('Score a set of answers against a quiz and show the outcome band')
948
+ .requiredOption('--answers <json>', 'The answers to score: \'{"q1":"yes"}\' or @file.json')
949
+ .option('--json', 'Output as JSON')
950
+ .action(async (id, opts) => {
951
+ requireAuth();
952
+ const answered = (0, json_arg_1.parseJsonArg)('answers', opts.answers);
953
+ if (!answered || typeof answered !== 'object' || Array.isArray(answered)) {
954
+ console.error(chalk_1.default.red('--answers must be a JSON object of question_id -> value'));
955
+ console.error(chalk_1.default.dim(' e.g. --answers \'{"experience":"none","budget":"high"}\''));
956
+ process.exitCode = 1;
957
+ return;
958
+ }
959
+ const sp = (0, ora_1.default)('Scoring...').start();
960
+ let out;
961
+ try {
962
+ // kb_sub_code omitted — the saved playbook carries its own industry.
963
+ out = await callVerb('quiz.result', { playbook_id: String(id), answered });
964
+ }
965
+ catch (e) {
966
+ fail(sp, 'Failed to score it', e);
967
+ return;
968
+ }
969
+ if (out.status === 'error' || out.status === 'invalid') {
970
+ sp.fail(chalk_1.default.red(String(out.summary ?? 'Could not score it')));
971
+ process.exitCode = 1;
972
+ return;
973
+ }
974
+ if ((0, json_output_1.isJsonOutput)(opts)) {
975
+ sp.stop();
976
+ console.log(JSON.stringify(out, null, 2));
977
+ return;
978
+ }
979
+ sp.stop();
980
+ console.log(` ${chalk_1.default.bold(String(out.title ?? id))}`);
981
+ if (out.is_quiz === false) {
982
+ console.log(chalk_1.default.yellow(' This playbook does not score — no points on its steps and no outcome bands.'));
983
+ console.log(chalk_1.default.dim(' Add scoring to its steps, or walk it as a form: solid forms walk ' + id));
984
+ return;
985
+ }
986
+ const pct = out.percent !== undefined && out.percent !== null ? ` (${Math.round(Number(out.percent))}%)` : '';
987
+ console.log(` ${chalk_1.default.dim('score')} ${out.score ?? 0}${out.max !== undefined ? `/${out.max}` : ''}${pct}`);
988
+ const band = (out.outcome ?? null);
989
+ if (!band || (!band.title && !band.message)) {
990
+ // ⛔ A SCORE THAT LANDS NOWHERE IS THE DEFECT WORTH SEEING. The dashboard
991
+ // flags it too: a respondent who finishes and is told nothing.
992
+ console.log(chalk_1.default.yellow(' ⚠ This score lands in NO outcome band — a respondent would be told nothing.'));
993
+ console.log(chalk_1.default.dim(' Check the band bounds cover every possible score.'));
994
+ process.exitCode = 1;
995
+ return;
996
+ }
997
+ if (band.title)
998
+ console.log(` ${chalk_1.default.green(String(band.title))}`);
999
+ if (band.message)
1000
+ console.log(` ${String(band.message)}`);
1001
+ if (band.verb) {
1002
+ console.log(` ${chalk_1.default.dim('the agent would then call')} ${chalk_1.default.cyan(String(band.verb))}` +
1003
+ (band.verb_args && Object.keys(band.verb_args).length
1004
+ ? ` ${chalk_1.default.dim(JSON.stringify(band.verb_args))}` : ''));
1005
+ }
1006
+ });
1007
+ // ── vocabulary: the nouns this tenant's customers actually use ──────────────
1008
+ exports.formsCommand
1009
+ .command('vocabulary')
1010
+ .alias('words')
1011
+ .description("This business's own words — what its customers are called, and its appointments")
1012
+ .option('--lang <code>', 'Language', 'en')
1013
+ .option('--json', 'Output as JSON')
1014
+ .action(async (opts) => {
1015
+ requireAuth();
1016
+ const sp = (0, ora_1.default)('Loading...').start();
1017
+ try {
1018
+ // kb_sub_code omitted deliberately — the verb resolves this tenant's own.
1019
+ const out = await callVerb('playbook.vocabulary', { lang: opts.lang });
1020
+ if ((0, json_output_1.isJsonOutput)(opts)) {
1021
+ sp.stop();
1022
+ console.log(JSON.stringify(out, null, 2));
1023
+ return;
1024
+ }
1025
+ sp.stop();
1026
+ const terms = (out.terms ?? {});
1027
+ console.log(` ${chalk_1.default.dim('industry code')} ${out.kb_sub_code ?? chalk_1.default.dim('none set')}`);
1028
+ for (const [k, v] of Object.entries(terms)) {
1029
+ console.log(` ${chalk_1.default.dim(k.padEnd(16))} ${v}`);
1030
+ }
1031
+ }
1032
+ catch (e) {
1033
+ fail(sp, 'Failed to load the vocabulary', e);
1034
+ }
1035
+ });
277
1036
  const command_kit_1 = require("../lib/command-kit");
278
1037
  (0, command_kit_1.appendExamples)(exports.formsCommand, [
279
- { cmd: 'solid forms list', why: 'All forms + surveys' },
280
- { cmd: 'solid forms create --title "Contact" --prompt "email + message"', why: 'AI-generate a form' },
281
- { cmd: 'solid forms export <id> --format csv', why: 'Dump responses to CSV' },
282
- { cmd: 'solid forms responses <id> --since 7d', why: 'Recent submissions' },
1038
+ { cmd: 'solid forms list', why: 'Every form with its lifecycle, across every provider' },
1039
+ { cmd: 'solid forms build --intent intake --save', why: "A starter in this industry's own words" },
1040
+ { cmd: 'solid forms publish <id>', why: 'A draft starts taking answers' },
1041
+ { cmd: 'solid forms link <id>', why: "The form's shareable URL, bare on stdout" },
1042
+ { cmd: 'solid forms moments set appointment_booked --form <id>', why: 'Make something actually send it' },
1043
+ { cmd: 'solid forms walk <id>', why: 'Answer it the way an agent does, from the terminal' },
1044
+ { cmd: 'solid forms responses <id>', why: 'What people actually answered' },
283
1045
  ]);
284
1046
  //# sourceMappingURL=forms.js.map