@wabery/cli 0.3.0 → 0.5.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, readLocalConfig, 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;
@@ -50,11 +62,20 @@ async function main() {
50
62
  apiKey: stringFlag(parsed.flags, "api-key"),
51
63
  baseUrl: stringFlag(parsed.flags, "base-url"),
52
64
  apiVersion: stringFlag(parsed.flags, "api-version"),
65
+ projectId: stringFlag(parsed.flags, "project-id"),
53
66
  });
54
67
  if (command === "mcp") {
55
68
  await runMcpServer(client);
56
69
  return;
57
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
+ }
58
79
  if (command === "doctor" || command === "check") {
59
80
  await printJson(await checkWaberyConnection(client));
60
81
  return;
@@ -68,7 +89,7 @@ async function main() {
68
89
  return;
69
90
  }
70
91
  if (command === "projects") {
71
- await handleProjects(client, subcommand, parsed.flags);
92
+ await handleProjects(client, subcommand, rest, parsed.flags);
72
93
  return;
73
94
  }
74
95
  if (command === "channels" && subcommand === "list") {
@@ -139,12 +160,13 @@ async function handleConfig(client, subcommand, rest, flags) {
139
160
  throw new Error("Usage: wabery config schema|export|example|init|validate|diff|apply [wabery.config.json]");
140
161
  }
141
162
  async function handleLocalConfig(subcommand, rest, flags) {
163
+ const resolvedProjectId = stringFlag(flags, "project-id") ?? resolveProjectId({}).projectId;
142
164
  if (subcommand === "example") {
143
- await printJson(createExampleConfig(stringFlag(flags, "project-id")));
165
+ await printJson(createExampleConfig(resolvedProjectId));
144
166
  return;
145
167
  }
146
168
  const outputPath = rest[0] ?? stringFlag(flags, "file") ?? "wabery.config.json";
147
- const config = createExampleConfig(stringFlag(flags, "project-id"));
169
+ const config = createExampleConfig(resolvedProjectId);
148
170
  await writeFile(outputPath, `${JSON.stringify(config, null, 2)}\n`, {
149
171
  flag: booleanFlag(flags, "force") ? "w" : "wx",
150
172
  });
@@ -245,8 +267,98 @@ async function handleLogin(flags) {
245
267
  if (!apiKey.startsWith("wab_live_")) {
246
268
  throw new Error("That does not look like a Wabery CLI key (it should start with wab_live_). Run `wabery login` again.");
247
269
  }
248
- const configPath = writeLocalConfig({ apiKey, baseUrl });
249
- 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
+ });
250
362
  }
251
363
  /** Read a single line from the terminal, echoing what the user types. */
252
364
  function promptLine(question) {
@@ -281,22 +393,163 @@ function openUrl(url) {
281
393
  function normalizeAppBaseUrl(value) {
282
394
  return value.replace(/\/api\/v1\/?$/, "").replace(/\/+$/, "");
283
395
  }
284
- async function handleProjects(client, subcommand, flags) {
396
+ async function handleProjects(client, subcommand, rest, flags) {
285
397
  if (subcommand === "list") {
286
398
  await printJson(await client.get("/projects"));
287
399
  return;
288
400
  }
289
401
  if (subcommand === "create") {
290
- await printJson(await client.post("/projects", {
291
- name: requiredStringFlag(flags, "name"),
292
- description: stringFlag(flags, "description"),
293
- webhook_url: stringFlag(flags, "webhook-url"),
294
- webhook_signing_enabled: booleanFlag(flags, "webhook-signing-enabled") ?? true,
295
- }));
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));
296
409
  return;
297
410
  }
298
- throw new Error("Usage: wabery projects list|create --name <name>");
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));
439
+ return;
440
+ }
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}`);
299
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");
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
+ `;
300
553
  async function handleContacts(client, subcommand, rest, flags) {
301
554
  if (subcommand === "list") {
302
555
  await printJson(await client.get("/contacts", {
@@ -313,7 +566,8 @@ async function handleContacts(client, subcommand, rest, flags) {
313
566
  if (subcommand === "enroll") {
314
567
  await printJson(await client.post("/contacts", {
315
568
  phone: requiredStringFlag(flags, "phone"),
316
- 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,
317
571
  name: stringFlag(flags, "name"),
318
572
  reference_id: stringFlag(flags, "reference-id"),
319
573
  metadata: optionalJsonFlag(flags, "metadata"),
@@ -321,13 +575,21 @@ async function handleContacts(client, subcommand, rest, flags) {
321
575
  }));
322
576
  return;
323
577
  }
578
+ if (subcommand === "update") {
579
+ const contactId = rest[0] ?? stringFlag(flags, "contact-id");
580
+ await printJson(await client.patch(`/contacts/${requiredValue(contactId, "contact-id")}`, {
581
+ preferred_language: stringFlag(flags, "preferred-language"),
582
+ metadata: optionalJsonFlag(flags, "metadata"),
583
+ }));
584
+ return;
585
+ }
324
586
  if (subcommand === "unenroll" || subcommand === "delete") {
325
587
  const contactId = rest[0] ?? stringFlag(flags, "contact-id");
326
588
  const erase = booleanFlag(flags, "erase");
327
589
  await printJson(await client.delete(`/contacts/${requiredValue(contactId, "contact-id")}${erase ? "?erase=true" : ""}`));
328
590
  return;
329
591
  }
330
- throw new Error("Usage: wabery contacts list|get|enroll|unenroll --phone <phone> --project-id <id>");
592
+ throw new Error("Usage: wabery contacts list|get|enroll|update|unenroll --phone <phone> --preferred-language <lang> --metadata <json> --project-id <id>");
331
593
  }
332
594
  async function handleConversations(client, subcommand, rest, flags) {
333
595
  if (subcommand === "list") {
@@ -420,7 +682,8 @@ async function handleFlows(client, subcommand, rest, flags) {
420
682
  return;
421
683
  }
422
684
  if (subcommand === "send") {
423
- await printJson(await client.post(`/flows/${requiredValue(flowId, "flow-id")}/send`, {
685
+ const configKey = stringFlag(flags, "config-key");
686
+ const common = {
424
687
  channel_id: requiredStringFlag(flags, "channel-id"),
425
688
  contact_id: stringFlag(flags, "contact-id"),
426
689
  conversation_id: stringFlag(flags, "conversation-id"),
@@ -431,11 +694,20 @@ async function handleFlows(client, subcommand, rest, flags) {
431
694
  footer_text: stringFlag(flags, "footer-text"),
432
695
  first_screen: stringFlag(flags, "first-screen"),
433
696
  screen_data: optionalJsonFlag(flags, "screen-data"),
434
- }));
697
+ };
698
+ // `--config-key` resolves the locale variant by the contact's language;
699
+ // otherwise send a specific flow id.
700
+ await printJson(configKey
701
+ ? await client.post("/flows/send", {
702
+ config_key: configKey,
703
+ locale: stringFlag(flags, "locale"),
704
+ ...common,
705
+ })
706
+ : await client.post(`/flows/${requiredValue(flowId, "flow-id")}/send`, common));
435
707
  return;
436
708
  }
437
709
  // `flows test` is handled before client creation (no API key needed).
438
- throw new Error("Usage: wabery flows list|get|publish|send|test");
710
+ throw new Error("Usage: wabery flows list|get|publish|send|test (send: <flow-id> | --config-key <key> [--locale <lang>])");
439
711
  }
440
712
  /**
441
713
  * Poll a flow until Meta reports PUBLISHED (sendable), or throw with the rejection
@@ -471,27 +743,37 @@ async function handleEnv(client, flags) {
471
743
  const projects = (await client.get("/projects"));
472
744
  projectId = projects.data?.[0]?.project_id ?? projects.data?.[0]?.id;
473
745
  }
746
+ // Single GET reveals webhook_url + webhook_secret (the list never does).
747
+ let project;
748
+ if (projectId) {
749
+ project = (await client.get(`/projects/${projectId}`));
750
+ }
751
+ if (!channelId) {
752
+ channelId =
753
+ project?.channel_id ?? project?.enrollment_channel?.id ?? undefined;
754
+ }
474
755
  if (!channelId) {
475
756
  const channels = (await client.get("/channels"));
476
757
  channelId = channels.data?.[0]?.channel_id ?? channels.data?.[0]?.id;
477
758
  }
759
+ const cred = findCredentialByProject(client.projectId ?? projectId ?? "");
478
760
  const apiKey = stringFlag(flags, "api-key") ??
479
761
  process.env.WABERY_API_KEY ??
480
- readLocalConfig().apiKey ??
762
+ cred?.apiKey ??
481
763
  "wab_live_...";
482
764
  const baseUrl = stringFlag(flags, "base-url") ??
483
765
  process.env.WABERY_BASE_URL ??
484
- readLocalConfig().baseUrl ??
766
+ cred?.baseUrl ??
485
767
  "https://api.wabery.com/v1";
486
768
  const lines = [
487
769
  `WABERY_API_KEY=${apiKey}`,
488
770
  `WABERY_BASE_URL=${baseUrl}`,
489
771
  `WABERY_PROJECT_ID=${projectId ?? "<no project found>"}`,
490
772
  `WABERY_CHANNEL_ID=${channelId ?? "<no channel found>"}`,
491
- "# WABERY_WEBHOOK_SECRET is not retrievable via API — copy it from the",
492
- "# dashboard (Project → Webhooks) and paste it here:",
493
- "WABERY_WEBHOOK_SECRET=",
494
773
  ];
774
+ if (project?.webhook_url)
775
+ lines.push(`WABERY_WEBHOOK_URL=${project.webhook_url}`);
776
+ lines.push(`WABERY_WEBHOOK_SECRET=${project?.webhook_secret ?? ""}`);
495
777
  await printText(`${lines.join("\n")}\n`);
496
778
  }
497
779
  /** Sign a body the same way Wabery does: `sha256=<hex>` HMAC-SHA256. */
@@ -737,7 +1019,48 @@ function requiredValue(value, name) {
737
1019
  throw new Error(`Missing required --${name}`);
738
1020
  return value;
739
1021
  }
1022
+ // Global output shaping, set from --id-only / --field at startup.
1023
+ let outputIdOnly = false;
1024
+ let outputField;
1025
+ const ID_FIELDS = [
1026
+ "id",
1027
+ "project_id",
1028
+ "contact_id",
1029
+ "flow_id",
1030
+ "channel_id",
1031
+ "flow_token",
1032
+ "dispatch_id",
1033
+ ];
1034
+ function extractPath(data, path) {
1035
+ return path.split(".").reduce((node, key) => {
1036
+ if (node && typeof node === "object") {
1037
+ return node[key];
1038
+ }
1039
+ return undefined;
1040
+ }, data);
1041
+ }
1042
+ function formatScalar(value) {
1043
+ if (value === undefined || value === null)
1044
+ return "";
1045
+ if (typeof value === "object")
1046
+ return JSON.stringify(value);
1047
+ return String(value);
1048
+ }
740
1049
  async function printJson(data) {
1050
+ if (outputIdOnly) {
1051
+ const record = data && typeof data === "object"
1052
+ ? data
1053
+ : undefined;
1054
+ const id = record
1055
+ ? ID_FIELDS.map((key) => record[key]).find((v) => typeof v === "string")
1056
+ : undefined;
1057
+ process.stdout.write(`${formatScalar(id)}\n`);
1058
+ return;
1059
+ }
1060
+ if (outputField) {
1061
+ process.stdout.write(`${formatScalar(extractPath(data, outputField))}\n`);
1062
+ return;
1063
+ }
741
1064
  process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
742
1065
  }
743
1066
  async function printText(text) {
@@ -760,19 +1083,38 @@ function redactSensitive(value) {
760
1083
  function printHelp() {
761
1084
  process.stdout.write(`Wabery CLI and MCP server
762
1085
 
1086
+ Projects & keys:
1087
+ API keys are scoped to a single project. Log into each project once; the CLI
1088
+ stores one key per project and auto-selects the right one based on the repo.
1089
+ A repo declares its project in a committed, secret-free .wabery.json
1090
+ ({"project_id": "..."}); the CLI walks up from the current directory to find
1091
+ it (falling back to wabery.config.json's project_id).
1092
+
763
1093
  Environment:
764
- WABERY_API_KEY API key override. Defaults to wabery login config.
1094
+ WABERY_API_KEY API key override (used directly, bypasses the store).
1095
+ WABERY_PROJECT_ID Select which stored project's key to use.
765
1096
  WABERY_BASE_URL Defaults to https://api.wabery.com/v1.
766
1097
  WABERY_API_VERSION Optional dated API version.
767
1098
 
768
1099
  Global flags:
769
1100
  --api-key <key> Override WABERY_API_KEY.
1101
+ --project-id <id> Select which stored project's key to use.
770
1102
  --base-url <url> Override WABERY_BASE_URL.
771
1103
  --api-version <ver> Override WABERY_API_VERSION.
1104
+ --id-only Print just the result id (for shell: ID=$(wabery ... --id-only)).
1105
+ --field <dot.path> Print just one field, e.g. --field webhook_secret.
1106
+
1107
+ Getting started:
1108
+ wabery login # log into a project (stores its key)
1109
+ wabery whoami # active key (masked), org, project
1110
+ wabery init [--name "My project"] # scaffold .env.wabery + wabery.config.json + handler
772
1111
 
773
1112
  Authentication:
774
- wabery login
1113
+ wabery login # add/refresh the current project's key
775
1114
  wabery login --no-open
1115
+ wabery use <project-id-or-name> # bind this repo to a logged-in project
1116
+ wabery logout [--project <id|name>] # remove one stored key
1117
+ wabery logout --all # remove every stored key
776
1118
  Config file: ${getConfigPath()}
777
1119
 
778
1120
  MCP:
@@ -797,7 +1139,10 @@ Config:
797
1139
 
798
1140
  Projects and contacts:
799
1141
  wabery projects list
800
- wabery projects create --name "Demo"
1142
+ wabery projects get <project_id> # reveals webhook_secret
1143
+ wabery projects ensure --name "Demo" # idempotently configure the key's scoped project
1144
+ wabery projects update <project_id> [--name <n>] [--webhook-url <url>] [--webhook-signing-enabled true|false] [--webhook-secret <secret>]
1145
+ wabery projects rotate-secret <project_id> [--webhook-secret <secret>]
801
1146
  wabery contacts enroll --project-id <id> --phone +15555550123 --name "Jane" --opt-in '{"source":"checkout"}'
802
1147
  wabery contacts get <contact_id>
803
1148
  wabery contacts unenroll <contact_id>