@wabery/cli 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -8,11 +8,15 @@ import { createInterface } from "node:readline";
8
8
  import { WaberyApiClient, WaberyApiError } from "./api-client.js";
9
9
  import { createExampleConfig } from "./config-example.js";
10
10
  import { checkWaberyConnection } from "./diagnostics.js";
11
- import { getConfigPath, writeLocalConfig } from "./local-config.js";
11
+ import { clearCredentials, findCredentialByProject, getConfigPath, readCredentials, removeCredential, repoConfigWritePath, resolveProjectId, upsertCredential, writeRepoProjectId, } from "./local-config.js";
12
12
  import { runMcpServer } from "./mcp-server.js";
13
13
  async function main() {
14
14
  const parsed = parseArgs(process.argv.slice(2));
15
15
  const [command, subcommand, ...rest] = parsed.positionals;
16
+ // Global output shaping for scripting: `--id-only` or `--field <dot.path>`
17
+ // print a single raw value instead of JSON (no jq needed).
18
+ outputIdOnly = booleanFlag(parsed.flags, "id-only") === true;
19
+ outputField = stringFlag(parsed.flags, "field");
16
20
  if (!command ||
17
21
  command === "help" ||
18
22
  command === "--help" ||
@@ -29,6 +33,14 @@ async function main() {
29
33
  await handleLogin(parsed.flags);
30
34
  return;
31
35
  }
36
+ if (command === "logout") {
37
+ await handleLogout(parsed.flags);
38
+ return;
39
+ }
40
+ if (command === "use") {
41
+ await handleUse(subcommand ?? rest[0]);
42
+ return;
43
+ }
32
44
  if (command === "mcp-config") {
33
45
  await handleMcpConfig(subcommand, parsed.flags);
34
46
  return;
@@ -42,25 +54,42 @@ async function main() {
42
54
  await testDataExchange(parsed.flags);
43
55
  return;
44
56
  }
57
+ if (command === "flows" && subcommand === "simulate") {
58
+ await simulateFlow(parsed.flags);
59
+ return;
60
+ }
45
61
  const client = new WaberyApiClient({
46
62
  apiKey: stringFlag(parsed.flags, "api-key"),
47
63
  baseUrl: stringFlag(parsed.flags, "base-url"),
48
64
  apiVersion: stringFlag(parsed.flags, "api-version"),
65
+ projectId: stringFlag(parsed.flags, "project-id"),
49
66
  });
50
67
  if (command === "mcp") {
51
68
  await runMcpServer(client);
52
69
  return;
53
70
  }
71
+ if (command === "whoami") {
72
+ await handleWhoami(client);
73
+ return;
74
+ }
75
+ if (command === "init") {
76
+ await handleInit(client, parsed.flags);
77
+ return;
78
+ }
54
79
  if (command === "doctor" || command === "check") {
55
80
  await printJson(await checkWaberyConnection(client));
56
81
  return;
57
82
  }
83
+ if (command === "env") {
84
+ await handleEnv(client, parsed.flags);
85
+ return;
86
+ }
58
87
  if (command === "config") {
59
88
  await handleConfig(client, subcommand, rest, parsed.flags);
60
89
  return;
61
90
  }
62
91
  if (command === "projects") {
63
- await handleProjects(client, subcommand, parsed.flags);
92
+ await handleProjects(client, subcommand, rest, parsed.flags);
64
93
  return;
65
94
  }
66
95
  if (command === "channels" && subcommand === "list") {
@@ -131,12 +160,13 @@ async function handleConfig(client, subcommand, rest, flags) {
131
160
  throw new Error("Usage: wabery config schema|export|example|init|validate|diff|apply [wabery.config.json]");
132
161
  }
133
162
  async function handleLocalConfig(subcommand, rest, flags) {
163
+ const resolvedProjectId = stringFlag(flags, "project-id") ?? resolveProjectId({}).projectId;
134
164
  if (subcommand === "example") {
135
- await printJson(createExampleConfig(stringFlag(flags, "project-id")));
165
+ await printJson(createExampleConfig(resolvedProjectId));
136
166
  return;
137
167
  }
138
168
  const outputPath = rest[0] ?? stringFlag(flags, "file") ?? "wabery.config.json";
139
- const config = createExampleConfig(stringFlag(flags, "project-id"));
169
+ const config = createExampleConfig(resolvedProjectId);
140
170
  await writeFile(outputPath, `${JSON.stringify(config, null, 2)}\n`, {
141
171
  flag: booleanFlag(flags, "force") ? "w" : "wx",
142
172
  });
@@ -237,8 +267,98 @@ async function handleLogin(flags) {
237
267
  if (!apiKey.startsWith("wab_live_")) {
238
268
  throw new Error("That does not look like a Wabery CLI key (it should start with wab_live_). Run `wabery login` again.");
239
269
  }
240
- const configPath = writeLocalConfig({ apiKey, baseUrl });
241
- await printText(`\nLogged in to Wabery. Credentials saved to ${configPath}\n`);
270
+ // Identify which project this key is scoped to via /v1/me, then store the
271
+ // credential keyed by project so multiple projects can coexist locally.
272
+ const client = new WaberyApiClient({ apiKey, baseUrl });
273
+ const identity = (await client.get("/me"));
274
+ const projectId = identity.project?.id;
275
+ if (!projectId) {
276
+ throw new Error("This key is not scoped to a project. Create a key from a project's settings in the Wabery dashboard.");
277
+ }
278
+ const configPath = upsertCredential({
279
+ projectId,
280
+ projectName: identity.project?.name,
281
+ organizationId: identity.organization?.id,
282
+ apiKey,
283
+ baseUrl,
284
+ });
285
+ let boundLine = "";
286
+ if (!resolveProjectId({}).projectId) {
287
+ // No repo binding yet — bind this directory to the project we logged into.
288
+ const repoPath = writeRepoProjectId(repoConfigWritePath(), projectId);
289
+ boundLine = `Bound this directory to project ${projectId} (${repoPath}).\n`;
290
+ }
291
+ await printText(`\nLogged in to project ${identity.project?.name ?? projectId} (${projectId}).\nCredentials saved to ${configPath}\n${boundLine}`);
292
+ }
293
+ /** Clear stored credentials: one project, or all of them with --all. */
294
+ async function handleLogout(flags) {
295
+ if (booleanFlag(flags, "all")) {
296
+ clearCredentials();
297
+ await printText("Logged out. Removed all stored Wabery credentials.\n");
298
+ return;
299
+ }
300
+ const target = stringFlag(flags, "project") ?? stringFlag(flags, "project-id");
301
+ const credentials = readCredentials();
302
+ let projectId = target;
303
+ if (!projectId) {
304
+ // Default to the project resolved for the current directory.
305
+ projectId = resolveProjectId({}).projectId;
306
+ }
307
+ if (!projectId && credentials.length === 1) {
308
+ projectId = credentials[0].projectId;
309
+ }
310
+ if (!projectId) {
311
+ throw new Error("Specify which project to log out of: `wabery logout --project <id|name>`, or `wabery logout --all`.");
312
+ }
313
+ // Allow matching by name too.
314
+ const match = credentials.find((c) => c.projectId === projectId) ??
315
+ credentials.find((c) => c.projectName?.toLowerCase() === projectId?.toLowerCase());
316
+ if (!match) {
317
+ throw new Error(`No stored credential for "${projectId}".`);
318
+ }
319
+ removeCredential(match.projectId);
320
+ await printText(`Logged out of project ${match.projectName ?? match.projectId} (${match.projectId}).\n`);
321
+ }
322
+ /**
323
+ * Bind the current repo to one of the projects you've logged into, by writing
324
+ * a secret-free `.wabery.json`. Resolves a project id or (case-insensitive)
325
+ * name against the locally stored credentials.
326
+ */
327
+ async function handleUse(query) {
328
+ if (!query) {
329
+ throw new Error("Usage: wabery use <project-id-or-name>");
330
+ }
331
+ const credentials = readCredentials();
332
+ if (credentials.length === 0) {
333
+ throw new Error("No projects are logged in yet. Run `wabery login` first.");
334
+ }
335
+ const match = credentials.find((c) => c.projectId === query) ??
336
+ credentials.find((c) => c.projectName?.toLowerCase() === query.toLowerCase());
337
+ if (!match) {
338
+ const available = credentials
339
+ .map((c) => ` - ${c.projectName ?? "(unnamed)"} (${c.projectId})`)
340
+ .join("\n");
341
+ throw new Error(`No logged-in project matches "${query}". Available:\n${available}\n\nRun \`wabery login\` to add another project.`);
342
+ }
343
+ const path = writeRepoProjectId(repoConfigWritePath(), match.projectId);
344
+ await printJson({
345
+ object: "repo_default",
346
+ path,
347
+ project_id: match.projectId,
348
+ project_name: match.projectName ?? null,
349
+ });
350
+ }
351
+ /** Show the active key (masked), org, and bound project + how it resolved. */
352
+ async function handleWhoami(client) {
353
+ const identity = (await client.get("/me"));
354
+ const resolved = resolveProjectId({});
355
+ await printJson({
356
+ ...identity,
357
+ resolution: {
358
+ project_id: client.projectId ?? null,
359
+ source: resolved.source,
360
+ },
361
+ });
242
362
  }
243
363
  /** Read a single line from the terminal, echoing what the user types. */
244
364
  function promptLine(question) {
@@ -273,22 +393,163 @@ function openUrl(url) {
273
393
  function normalizeAppBaseUrl(value) {
274
394
  return value.replace(/\/api\/v1\/?$/, "").replace(/\/+$/, "");
275
395
  }
276
- async function handleProjects(client, subcommand, flags) {
396
+ async function handleProjects(client, subcommand, rest, flags) {
277
397
  if (subcommand === "list") {
278
398
  await printJson(await client.get("/projects"));
279
399
  return;
280
400
  }
281
401
  if (subcommand === "create") {
282
- await printJson(await client.post("/projects", {
283
- name: requiredStringFlag(flags, "name"),
284
- description: stringFlag(flags, "description"),
285
- webhook_url: stringFlag(flags, "webhook-url"),
286
- webhook_signing_enabled: booleanFlag(flags, "webhook-signing-enabled") ?? true,
287
- }));
402
+ throw new Error("API keys are scoped to one project and cannot create projects. Create a project in the Wabery dashboard, then run `wabery login` for that project.");
403
+ }
404
+ if (subcommand === "ensure") {
405
+ // API keys are scoped to one project. Ensure configures that scoped
406
+ // project idempotently; it does not create sibling projects.
407
+ const name = requiredStringFlag(flags, "name");
408
+ await printJson(await ensureProject(client, name, flags));
409
+ return;
410
+ }
411
+ const projectId = rest[0] ?? stringFlag(flags, "project-id");
412
+ if (subcommand === "get") {
413
+ await printJson(await client.get(`/projects/${requiredValue(projectId, "project-id")}`));
414
+ return;
415
+ }
416
+ if (subcommand === "update") {
417
+ const body = {};
418
+ const name = stringFlag(flags, "name");
419
+ if (name !== undefined)
420
+ body.name = name;
421
+ const description = stringFlag(flags, "description");
422
+ if (description !== undefined)
423
+ body.description = description;
424
+ const webhookUrl = stringFlag(flags, "webhook-url");
425
+ if (webhookUrl !== undefined)
426
+ body.webhook_url = webhookUrl;
427
+ const signing = booleanFlag(flags, "webhook-signing-enabled");
428
+ if (signing !== undefined)
429
+ body.webhook_signing_enabled = signing;
430
+ const secret = stringFlag(flags, "webhook-secret");
431
+ if (secret !== undefined)
432
+ body.webhook_secret = secret;
433
+ await printJson(await client.patch(`/projects/${requiredValue(projectId, "project-id")}`, body));
434
+ return;
435
+ }
436
+ if (subcommand === "rotate-secret") {
437
+ const secret = stringFlag(flags, "webhook-secret");
438
+ await printJson(await client.post(`/projects/${requiredValue(projectId, "project-id")}/rotate-webhook-secret`, secret ? { webhook_secret: secret } : undefined));
288
439
  return;
289
440
  }
290
- throw new Error("Usage: wabery projects list|create --name <name>");
441
+ throw new Error("Usage: wabery projects list|get|ensure|update|rotate-secret [<project_id>] [--name --description --webhook-url --webhook-signing-enabled --webhook-secret]");
442
+ }
443
+ /** Configure the project visible to this API key, then return it. Idempotent. */
444
+ async function ensureProject(client, name, flags) {
445
+ const list = (await client.get("/projects"));
446
+ const existing = list.data?.[0];
447
+ const id = existing?.project_id ?? existing?.id;
448
+ if (!existing || !id) {
449
+ throw new Error("No project is visible to this API key. Create a project in the Wabery dashboard, then run `wabery login` again.");
450
+ }
451
+ const webhookBody = {};
452
+ if (name !== undefined && existing.name !== name)
453
+ webhookBody.name = name;
454
+ const description = stringFlag(flags, "description");
455
+ if (description !== undefined)
456
+ webhookBody.description = description;
457
+ const webhookUrl = stringFlag(flags, "webhook-url");
458
+ if (webhookUrl !== undefined)
459
+ webhookBody.webhook_url = webhookUrl;
460
+ const signing = booleanFlag(flags, "webhook-signing-enabled");
461
+ if (signing !== undefined)
462
+ webhookBody.webhook_signing_enabled = signing;
463
+ const secret = stringFlag(flags, "webhook-secret");
464
+ if (secret !== undefined)
465
+ webhookBody.webhook_secret = secret;
466
+ if (Object.keys(webhookBody).length > 0) {
467
+ return client.patch(`/projects/${id}`, webhookBody);
468
+ }
469
+ return client.get(`/projects/${id}`);
470
+ }
471
+ /**
472
+ * Scaffold a runnable Wabery setup in the current directory:
473
+ * ensure a project, then write wabery.config.json, a .env block, and a webhook
474
+ * handler stub. Non-destructive — existing files are left untouched unless --force.
475
+ */
476
+ async function handleInit(client, flags) {
477
+ const name = stringFlag(flags, "name");
478
+ const force = booleanFlag(flags, "force") === true;
479
+ const ensured = (await ensureProject(client, name, flags));
480
+ const projectId = ensured.project_id ?? ensured.id;
481
+ if (!projectId)
482
+ throw new Error("Could not resolve a project id.");
483
+ // Single GET reveals the webhook secret + url for the env block.
484
+ const project = (await client.get(`/projects/${projectId}`));
485
+ const channelId = project.channel_id ?? project.enrollment_channel?.id ?? undefined;
486
+ const cred = findCredentialByProject(projectId);
487
+ const baseUrl = stringFlag(flags, "base-url") ??
488
+ process.env.WABERY_BASE_URL ??
489
+ cred?.baseUrl ??
490
+ "https://api.wabery.com/v1";
491
+ const apiKey = stringFlag(flags, "api-key") ??
492
+ process.env.WABERY_API_KEY ??
493
+ cred?.apiKey ??
494
+ "wab_live_...";
495
+ const written = [];
496
+ const skipped = [];
497
+ const writeFlag = force ? "w" : "wx";
498
+ const tryWrite = async (path, content) => {
499
+ try {
500
+ await writeFile(path, content, { flag: writeFlag });
501
+ written.push(path);
502
+ }
503
+ catch (error) {
504
+ if (error.code === "EEXIST") {
505
+ skipped.push(path);
506
+ return;
507
+ }
508
+ throw error;
509
+ }
510
+ };
511
+ const envLines = [
512
+ `WABERY_API_KEY=${apiKey}`,
513
+ `WABERY_BASE_URL=${baseUrl}`,
514
+ `WABERY_PROJECT_ID=${projectId}`,
515
+ `WABERY_CHANNEL_ID=${channelId ?? ""}`,
516
+ ];
517
+ if (project.webhook_url)
518
+ envLines.push(`WABERY_WEBHOOK_URL=${project.webhook_url}`);
519
+ envLines.push(`WABERY_WEBHOOK_SECRET=${project.webhook_secret ?? ""}`);
520
+ await tryWrite(".env.wabery", `${envLines.join("\n")}\n`);
521
+ await tryWrite("wabery.config.json", `${JSON.stringify(createExampleConfig(projectId), null, 2)}\n`);
522
+ await tryWrite("wabery-webhook-handler.ts", WEBHOOK_HANDLER_STUB);
523
+ await printText(`Initialized Wabery for project ${projectId}.\n` +
524
+ (written.length ? `Wrote: ${written.join(", ")}\n` : "") +
525
+ (skipped.length
526
+ ? `Skipped (already exist, use --force to overwrite): ${skipped.join(", ")}\n`
527
+ : "") +
528
+ "\nNext: load .env.wabery, edit the webhook URL, then `wabery config apply wabery.config.json`.\n");
291
529
  }
530
+ const WEBHOOK_HANDLER_STUB = `// Verify and handle Wabery webhook deliveries.
531
+ // npm i @wabery/sdk
532
+ import { Wabery } from "@wabery/sdk";
533
+
534
+ const wabery = new Wabery(); // reads WABERY_API_KEY
535
+ const secret = process.env.WABERY_WEBHOOK_SECRET ?? "";
536
+
537
+ // Express-style handler; adapt to your framework. Pass the RAW request body.
538
+ export async function handleWaberyWebhook(rawBody: string, signature: string) {
539
+ \tconst event = wabery.webhooks.constructEvent(rawBody, signature, secret);
540
+ \tswitch (event.event) {
541
+ \t\tcase "message.received":
542
+ \t\t\t// event.payload.text, .from, .conversation_id ...
543
+ \t\t\tbreak;
544
+ \t\tcase "flow.completed":
545
+ \t\t\t// event.payload.submission ...
546
+ \t\t\tbreak;
547
+ \t\tdefault:
548
+ \t\t\tbreak;
549
+ \t}
550
+ \treturn event;
551
+ }
552
+ `;
292
553
  async function handleContacts(client, subcommand, rest, flags) {
293
554
  if (subcommand === "list") {
294
555
  await printJson(await client.get("/contacts", {
@@ -305,7 +566,8 @@ async function handleContacts(client, subcommand, rest, flags) {
305
566
  if (subcommand === "enroll") {
306
567
  await printJson(await client.post("/contacts", {
307
568
  phone: requiredStringFlag(flags, "phone"),
308
- project_id: requiredStringFlag(flags, "project-id"),
569
+ // Keys are project-scoped; default to the key's project.
570
+ project_id: stringFlag(flags, "project-id") ?? client.projectId,
309
571
  name: stringFlag(flags, "name"),
310
572
  reference_id: stringFlag(flags, "reference-id"),
311
573
  metadata: optionalJsonFlag(flags, "metadata"),
@@ -402,7 +664,13 @@ async function handleFlows(client, subcommand, rest, flags) {
402
664
  return;
403
665
  }
404
666
  if (subcommand === "publish") {
405
- await printJson(await client.post(`/flows/${requiredValue(flowId, "flow-id")}/publish`));
667
+ const id = requiredValue(flowId, "flow-id");
668
+ const published = await client.post(`/flows/${id}/publish`);
669
+ if (booleanFlag(flags, "wait")) {
670
+ await printJson(await waitUntilPublished(client, id));
671
+ return;
672
+ }
673
+ await printJson(published);
406
674
  return;
407
675
  }
408
676
  if (subcommand === "send") {
@@ -423,6 +691,73 @@ async function handleFlows(client, subcommand, rest, flags) {
423
691
  // `flows test` is handled before client creation (no API key needed).
424
692
  throw new Error("Usage: wabery flows list|get|publish|send|test");
425
693
  }
694
+ /**
695
+ * Poll a flow until Meta reports PUBLISHED (sendable), or throw with the rejection
696
+ * reason on a terminal BLOCKED/DEPRECATED state or timeout. Publishing surfaces most
697
+ * validation errors synchronously; this covers Meta's short review window.
698
+ */
699
+ async function waitUntilPublished(client, flowId, { timeoutMs = 120_000, intervalMs = 3_000 } = {}) {
700
+ const deadline = Date.now() + timeoutMs;
701
+ for (;;) {
702
+ const flow = (await client.get(`/flows/${flowId}`));
703
+ const status = flow.meta_status ?? flow.status ?? null;
704
+ if (status === "PUBLISHED")
705
+ return flow;
706
+ if (status === "BLOCKED" || status === "DEPRECATED") {
707
+ throw new Error(`Flow ${flowId} is ${status} and cannot be published`);
708
+ }
709
+ if (Date.now() >= deadline) {
710
+ throw new Error(`Timed out waiting for flow ${flowId} to become publishable (last status: ${status ?? "unknown"})`);
711
+ }
712
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
713
+ }
714
+ }
715
+ /**
716
+ * Print a ready-to-paste env block for a project. Pulls project id, the authorized
717
+ * channel id, API key (from your CLI config / shell), and base URL. The webhook
718
+ * signing secret is never returned by the API for security — paste it from the
719
+ * dashboard (Project → Webhooks).
720
+ */
721
+ async function handleEnv(client, flags) {
722
+ let projectId = stringFlag(flags, "project-id");
723
+ let channelId = stringFlag(flags, "channel-id");
724
+ if (!projectId) {
725
+ const projects = (await client.get("/projects"));
726
+ projectId = projects.data?.[0]?.project_id ?? projects.data?.[0]?.id;
727
+ }
728
+ // Single GET reveals webhook_url + webhook_secret (the list never does).
729
+ let project;
730
+ if (projectId) {
731
+ project = (await client.get(`/projects/${projectId}`));
732
+ }
733
+ if (!channelId) {
734
+ channelId =
735
+ project?.channel_id ?? project?.enrollment_channel?.id ?? undefined;
736
+ }
737
+ if (!channelId) {
738
+ const channels = (await client.get("/channels"));
739
+ channelId = channels.data?.[0]?.channel_id ?? channels.data?.[0]?.id;
740
+ }
741
+ const cred = findCredentialByProject(client.projectId ?? projectId ?? "");
742
+ const apiKey = stringFlag(flags, "api-key") ??
743
+ process.env.WABERY_API_KEY ??
744
+ cred?.apiKey ??
745
+ "wab_live_...";
746
+ const baseUrl = stringFlag(flags, "base-url") ??
747
+ process.env.WABERY_BASE_URL ??
748
+ cred?.baseUrl ??
749
+ "https://api.wabery.com/v1";
750
+ const lines = [
751
+ `WABERY_API_KEY=${apiKey}`,
752
+ `WABERY_BASE_URL=${baseUrl}`,
753
+ `WABERY_PROJECT_ID=${projectId ?? "<no project found>"}`,
754
+ `WABERY_CHANNEL_ID=${channelId ?? "<no channel found>"}`,
755
+ ];
756
+ if (project?.webhook_url)
757
+ lines.push(`WABERY_WEBHOOK_URL=${project.webhook_url}`);
758
+ lines.push(`WABERY_WEBHOOK_SECRET=${project?.webhook_secret ?? ""}`);
759
+ await printText(`${lines.join("\n")}\n`);
760
+ }
426
761
  /** Sign a body the same way Wabery does: `sha256=<hex>` HMAC-SHA256. */
427
762
  function signBody(secret, body) {
428
763
  return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
@@ -475,6 +810,11 @@ async function testDataExchange(flags) {
475
810
  await postSigned(url, secret, body);
476
811
  }
477
812
  async function postSigned(url, secret, body) {
813
+ const { status, statusText, text } = await postSignedRaw(url, secret, body);
814
+ await printText(`→ POST ${url}\n${body}\n`);
815
+ await printText(`← ${status} ${statusText}\n${text}`);
816
+ }
817
+ async function postSignedRaw(url, secret, body) {
478
818
  const response = await fetch(url, {
479
819
  method: "POST",
480
820
  headers: {
@@ -483,9 +823,75 @@ async function postSigned(url, secret, body) {
483
823
  },
484
824
  body,
485
825
  });
486
- const text = await response.text();
487
- await printText(`→ POST ${url}\n${body}\n`);
488
- await printText(`← ${response.status} ${response.statusText}\n${text}`);
826
+ return {
827
+ status: response.status,
828
+ statusText: response.statusText,
829
+ text: await response.text(),
830
+ };
831
+ }
832
+ /**
833
+ * Walk a whole data-exchange flow locally, then fire the completion webhook — the full
834
+ * journey (open → field changes → branch → submit) without a deploy or Meta publish.
835
+ *
836
+ * wabery flows simulate --url <data_exchange_url> --secret <s> \
837
+ * [--webhook-url <flow.completed target>] \
838
+ * [--steps '[{"action":"INIT"},{"action":"data_exchange","screen":"SELECT","data":{"workspace":"ws_1"}}]'] \
839
+ * [--submission '{"workspace":"ws_1","project":"proj_a"}']
840
+ */
841
+ async function simulateFlow(flags) {
842
+ const url = requiredStringFlag(flags, "url");
843
+ const secret = requiredStringFlag(flags, "secret");
844
+ const webhookUrl = stringFlag(flags, "webhook-url");
845
+ const identity = {
846
+ flow_token: stringFlag(flags, "flow-token") ?? "sim_flow_token",
847
+ flow_id: stringFlag(flags, "flow-id") ?? "sim_flow_id",
848
+ agent_id: stringFlag(flags, "agent-id") ?? "sim_agent_id",
849
+ organization_id: stringFlag(flags, "organization-id") ?? "sim_org_id",
850
+ contact_id: stringFlag(flags, "contact-id") ?? "sim_contact_id",
851
+ contact_reference: stringFlag(flags, "contact-reference") ?? null,
852
+ from: stringFlag(flags, "from") ?? null,
853
+ };
854
+ const steps = (optionalJsonFlag(flags, "steps") ?? [
855
+ { action: "INIT" },
856
+ ]);
857
+ if (!Array.isArray(steps)) {
858
+ throw new Error("--steps must be a JSON array of { action, screen, data }");
859
+ }
860
+ for (const [index, step] of steps.entries()) {
861
+ const body = JSON.stringify({
862
+ action: step.action ?? "data_exchange",
863
+ screen: step.screen ?? null,
864
+ data: step.data ?? {},
865
+ ...identity,
866
+ });
867
+ const res = await postSignedRaw(url, secret, body);
868
+ await printText(`step ${index + 1}/${steps.length} · ${step.action ?? "data_exchange"}${step.screen ? ` → ${step.screen}` : ""}\n ← ${res.status} ${res.statusText}: ${res.text}\n`);
869
+ if (res.status >= 400) {
870
+ throw new Error(`data-exchange step ${index + 1} failed (${res.status})`);
871
+ }
872
+ }
873
+ if (webhookUrl) {
874
+ const event = JSON.stringify({
875
+ event: "flow.completed",
876
+ api_version: stringFlag(flags, "api-version") ?? "2026-06-18",
877
+ payload: {
878
+ object: "flow_submission",
879
+ flow_token: identity.flow_token,
880
+ flow_id: identity.flow_id,
881
+ agent_id: identity.agent_id,
882
+ organization_id: identity.organization_id,
883
+ contact_id: identity.contact_id,
884
+ contact_reference: identity.contact_reference,
885
+ from: identity.from,
886
+ valid: true,
887
+ submission: optionalJsonFlag(flags, "submission") ?? {},
888
+ submitted_at: new Date().toISOString(),
889
+ },
890
+ sentAt: new Date().toISOString(),
891
+ });
892
+ const res = await postSignedRaw(webhookUrl, secret, event);
893
+ await printText(`completion · flow.completed → ${webhookUrl}\n ← ${res.status} ${res.statusText}: ${res.text}\n`);
894
+ }
489
895
  }
490
896
  function samplePayload(event) {
491
897
  if (event === "flow.completed") {
@@ -595,7 +1001,48 @@ function requiredValue(value, name) {
595
1001
  throw new Error(`Missing required --${name}`);
596
1002
  return value;
597
1003
  }
1004
+ // Global output shaping, set from --id-only / --field at startup.
1005
+ let outputIdOnly = false;
1006
+ let outputField;
1007
+ const ID_FIELDS = [
1008
+ "id",
1009
+ "project_id",
1010
+ "contact_id",
1011
+ "flow_id",
1012
+ "channel_id",
1013
+ "flow_token",
1014
+ "dispatch_id",
1015
+ ];
1016
+ function extractPath(data, path) {
1017
+ return path.split(".").reduce((node, key) => {
1018
+ if (node && typeof node === "object") {
1019
+ return node[key];
1020
+ }
1021
+ return undefined;
1022
+ }, data);
1023
+ }
1024
+ function formatScalar(value) {
1025
+ if (value === undefined || value === null)
1026
+ return "";
1027
+ if (typeof value === "object")
1028
+ return JSON.stringify(value);
1029
+ return String(value);
1030
+ }
598
1031
  async function printJson(data) {
1032
+ if (outputIdOnly) {
1033
+ const record = data && typeof data === "object"
1034
+ ? data
1035
+ : undefined;
1036
+ const id = record
1037
+ ? ID_FIELDS.map((key) => record[key]).find((v) => typeof v === "string")
1038
+ : undefined;
1039
+ process.stdout.write(`${formatScalar(id)}\n`);
1040
+ return;
1041
+ }
1042
+ if (outputField) {
1043
+ process.stdout.write(`${formatScalar(extractPath(data, outputField))}\n`);
1044
+ return;
1045
+ }
599
1046
  process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
600
1047
  }
601
1048
  async function printText(text) {
@@ -618,19 +1065,38 @@ function redactSensitive(value) {
618
1065
  function printHelp() {
619
1066
  process.stdout.write(`Wabery CLI and MCP server
620
1067
 
1068
+ Projects & keys:
1069
+ API keys are scoped to a single project. Log into each project once; the CLI
1070
+ stores one key per project and auto-selects the right one based on the repo.
1071
+ A repo declares its project in a committed, secret-free .wabery.json
1072
+ ({"project_id": "..."}); the CLI walks up from the current directory to find
1073
+ it (falling back to wabery.config.json's project_id).
1074
+
621
1075
  Environment:
622
- WABERY_API_KEY API key override. Defaults to wabery login config.
1076
+ WABERY_API_KEY API key override (used directly, bypasses the store).
1077
+ WABERY_PROJECT_ID Select which stored project's key to use.
623
1078
  WABERY_BASE_URL Defaults to https://api.wabery.com/v1.
624
1079
  WABERY_API_VERSION Optional dated API version.
625
1080
 
626
1081
  Global flags:
627
1082
  --api-key <key> Override WABERY_API_KEY.
1083
+ --project-id <id> Select which stored project's key to use.
628
1084
  --base-url <url> Override WABERY_BASE_URL.
629
1085
  --api-version <ver> Override WABERY_API_VERSION.
1086
+ --id-only Print just the result id (for shell: ID=$(wabery ... --id-only)).
1087
+ --field <dot.path> Print just one field, e.g. --field webhook_secret.
1088
+
1089
+ Getting started:
1090
+ wabery login # log into a project (stores its key)
1091
+ wabery whoami # active key (masked), org, project
1092
+ wabery init [--name "My project"] # scaffold .env.wabery + wabery.config.json + handler
630
1093
 
631
1094
  Authentication:
632
- wabery login
1095
+ wabery login # add/refresh the current project's key
633
1096
  wabery login --no-open
1097
+ wabery use <project-id-or-name> # bind this repo to a logged-in project
1098
+ wabery logout [--project <id|name>] # remove one stored key
1099
+ wabery logout --all # remove every stored key
634
1100
  Config file: ${getConfigPath()}
635
1101
 
636
1102
  MCP:
@@ -642,6 +1108,7 @@ MCP:
642
1108
 
643
1109
  Diagnostics:
644
1110
  wabery doctor
1111
+ wabery env --project-id <project_id> # ready-to-paste env block
645
1112
 
646
1113
  Config:
647
1114
  wabery config schema
@@ -654,7 +1121,10 @@ Config:
654
1121
 
655
1122
  Projects and contacts:
656
1123
  wabery projects list
657
- wabery projects create --name "Demo"
1124
+ wabery projects get <project_id> # reveals webhook_secret
1125
+ wabery projects ensure --name "Demo" # idempotently configure the key's scoped project
1126
+ wabery projects update <project_id> [--name <n>] [--webhook-url <url>] [--webhook-signing-enabled true|false] [--webhook-secret <secret>]
1127
+ wabery projects rotate-secret <project_id> [--webhook-secret <secret>]
658
1128
  wabery contacts enroll --project-id <id> --phone +15555550123 --name "Jane" --opt-in '{"source":"checkout"}'
659
1129
  wabery contacts get <contact_id>
660
1130
  wabery contacts unenroll <contact_id>
@@ -672,12 +1142,13 @@ Templates, messages, and conversations:
672
1142
  Flows:
673
1143
  wabery flows list
674
1144
  wabery flows get <flow_id>
675
- wabery flows publish <flow_id>
1145
+ wabery flows publish <flow_id> [--wait]
676
1146
  wabery flows send <flow_id> --channel-id <id> --contact-id <contact_id>
677
1147
 
678
1148
  Local testing (sign + POST to your own endpoint, no deploy needed):
679
1149
  wabery webhooks send-test --url http://localhost:3000/webhooks --secret <secret> [--event message.received] [--payload '<json>']
680
1150
  wabery flows test --url http://localhost:3000/flow --secret <secret> [--action INIT] [--screen SELECT] [--data '<json>']
1151
+ wabery flows simulate --url http://localhost:3000/flow --secret <secret> [--webhook-url <url>] [--steps '<json array>'] [--submission '<json>']
681
1152
  `);
682
1153
  }
683
1154
  main().catch((error) => {