@primitivedotdev/sdk 0.11.0 → 0.13.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.
@@ -67,8 +67,22 @@ function extractBodyFields(schema) {
67
67
  kind = "complex";
68
68
  }
69
69
  }
70
+ // Pull the first paragraph of the schema description for use
71
+ // as the CLI flag's --help string. We split on a blank line
72
+ // (paragraph break) and then collapse any soft line wraps
73
+ // inside that paragraph to spaces. This avoids the previous
74
+ // bug where `split("\n")[0]` truncated wrapped prose like
75
+ // "Optional override for ... Defaults to\nthe inbound's..."
76
+ // to "Optional override for ... Defaults to" - a sentence
77
+ // ending with "to" with nothing after it, which read as
78
+ // ellipsis truncation in --help. The remaining paragraphs
79
+ // are intentionally dropped so multi-paragraph schemas don't
80
+ // blow out the per-flag help block.
70
81
  const description = typeof propSchema.description === "string"
71
- ? propSchema.description.split("\n")[0].trim()
82
+ ? propSchema.description
83
+ .split(/\n\s*\n/)[0]
84
+ .replace(/\s*\n\s*/g, " ")
85
+ .trim()
72
86
  : "";
73
87
  const enumRaw = propSchema.enum;
74
88
  const enumValues = kind === "string" && Array.isArray(enumRaw)
@@ -95,15 +109,15 @@ function extractBodyFields(schema) {
95
109
  * Most scalar fields are exposed as individual `--flag` flags,
96
110
  * which oclif auto-renders in the FLAGS section above. To avoid
97
111
  * duplicating that, the summary here only documents fields that
98
- * MUST go through `--body` (complex types: arrays, objects,
112
+ * MUST go through `--raw-body` (complex types: arrays, objects,
99
113
  * mixed-non-nullable). When an operation has only scalars, the
100
114
  * summary is omitted entirely and oclif's FLAGS section is the
101
115
  * full story.
102
116
  *
103
117
  * For operations with mixed scalar and complex fields, we also
104
118
  * include a short header pointing the agent at the flag form so
105
- * the natural reading is "use the flags above; --body for the
106
- * leftovers below."
119
+ * the natural reading is "use the flags above; --raw-body for
120
+ * the leftovers below."
107
121
  */
108
122
  function renderRequestSchemaSummary(schema) {
109
123
  const fields = extractBodyFields(schema);
@@ -115,7 +129,7 @@ function renderRequestSchemaSummary(schema) {
115
129
  const nameWidth = Math.min(24, Math.max(...complex.map((f) => f.name.length)));
116
130
  const descMax = 78;
117
131
  const lines = [
118
- "Body fields requiring --body JSON (these are not exposed as flags):",
132
+ "Body fields requiring --raw-body JSON (these are not exposed as flags):",
119
133
  ];
120
134
  for (const f of complex) {
121
135
  const marker = f.required ? " *" : " ";
@@ -180,9 +194,9 @@ function parseJson(source, flagLabel) {
180
194
  }
181
195
  export function readJsonBody(flags) {
182
196
  const bodyFile = flags["body-file"];
183
- const body = flags.body;
184
- if (bodyFile && body) {
185
- throw cliError("Use either --body or --body-file, not both");
197
+ const rawBody = flags["raw-body"];
198
+ if (bodyFile && rawBody) {
199
+ throw cliError("Use either --raw-body or --body-file, not both");
186
200
  }
187
201
  if (typeof bodyFile === "string") {
188
202
  let contents;
@@ -195,8 +209,8 @@ export function readJsonBody(flags) {
195
209
  }
196
210
  return parseJson(contents, `--body-file ${bodyFile}`);
197
211
  }
198
- if (typeof body === "string") {
199
- return parseJson(body, "--body");
212
+ if (typeof rawBody === "string") {
213
+ return parseJson(rawBody, "--raw-body");
200
214
  }
201
215
  return undefined;
202
216
  }
@@ -242,34 +256,93 @@ export function formatErrorPayload(payload) {
242
256
  }
243
257
  return JSON.stringify(payload, null, 2);
244
258
  }
259
+ // Pull the top-level error code out of either a server response
260
+ // payload (`{ error: { code: '...' } }` or `{ code: '...' }`) or a
261
+ // thrown Error whose `cause.code` carries the value. Used to drive
262
+ // `--api-key` and similar hints in writeErrorWithHints below.
263
+ // Also exported so individual commands (send, whoami) can branch
264
+ // on auth failures and avoid surfacing misleading "fix this flag"
265
+ // guidance when the real problem is the API key.
266
+ export function extractErrorCode(payload) {
267
+ if (payload instanceof Error) {
268
+ const { code } = extractCauseDetails(payload.cause);
269
+ return code;
270
+ }
271
+ if (payload && typeof payload === "object") {
272
+ const inner = payload.error;
273
+ if (inner && typeof inner === "object" && typeof inner.code === "string") {
274
+ return inner.code;
275
+ }
276
+ const direct = payload.code;
277
+ if (typeof direct === "string")
278
+ return direct;
279
+ }
280
+ return undefined;
281
+ }
282
+ // Common-case actionable hints keyed by error code. The full
283
+ // JSON envelope still goes to stderr unchanged for any caller
284
+ // that wants to parse it; the hint is an extra trailing line so
285
+ // a human reading the output sees "what to actually do next."
286
+ // The AGX walkthrough flagged that an `unauthorized` envelope
287
+ // alone left the agent without context for the env var or the
288
+ // `--api-key` flag; this closes that gap without having to
289
+ // special-case every command.
290
+ const ERROR_CODE_HINTS = {
291
+ unauthorized: "Hint: pass --api-key explicitly, or set PRIMITIVE_API_KEY in your environment. `primitive whoami` is the fastest way to verify a key is live.",
292
+ };
293
+ // Write a server / SDK error to stderr in the canonical envelope
294
+ // shape, plus an actionable hint when the code is one we know how
295
+ // to advise on. Replaces the bare
296
+ // `process.stderr.write(${formatErrorPayload(p)}\n)` dance every
297
+ // command was doing.
298
+ export function writeErrorWithHints(payload) {
299
+ process.stderr.write(`${formatErrorPayload(payload)}\n`);
300
+ const code = extractErrorCode(payload);
301
+ if (code && ERROR_CODE_HINTS[code]) {
302
+ process.stderr.write(`${ERROR_CODE_HINTS[code]}\n`);
303
+ }
304
+ }
245
305
  // Reserved flag names the body-field expander must never overwrite.
246
- // `--body` and `--body-file` are the JSON escape hatches.
306
+ // `--raw-body` and `--body-file` are the JSON escape hatches.
247
307
  // `--api-key`, `--base-url`, `--output` are infra. Path and query
248
308
  // params get added before body fields and take precedence.
309
+ //
310
+ // Note: `--body` is intentionally NOT reserved here. The naive
311
+ // agent expectation (per AGX walkthrough) is that --body means
312
+ // "the message body content," which collides with the JSON
313
+ // escape-hatch meaning we used pre-0.12. The escape hatch is now
314
+ // `--raw-body`; --body is free to be claimed by per-field flag
315
+ // expansion as the kebab-cased version of a `body` schema field
316
+ // (e.g. on a future `body: { ... }` schema). For send-mail today,
317
+ // the body-text field is `body_text` -> `--body-text`, and there
318
+ // is no top-level `body` field, so --body remains unclaimed at
319
+ // the generated-command level. The agent shortcut `primitive
320
+ // send` defines its own --body for the message text.
249
321
  const RESERVED_FLAG_NAMES = new Set([
250
322
  "api-key",
251
323
  "base-url",
252
- "body",
324
+ "raw-body",
253
325
  "body-file",
254
326
  "output",
255
327
  ]);
256
328
  function bodyFieldFlag(field) {
257
- // Flag descriptions cap at 80 chars so oclif's --help output
258
- // stays readable; the schema's full description is also visible
259
- // via `primitive list-operations | jq`.
260
- const descMax = 80;
261
- const trimmedDesc = field.description.length > descMax
262
- ? `${field.description.slice(0, descMax - 3)}...`
263
- : field.description;
329
+ // Pass the full first-line description through. oclif's --help
330
+ // renderer wraps long values across multiple lines on its own,
331
+ // so a fixed character cap here just produces ellipsis-truncated
332
+ // sentences ("body_html is required. Th...") that mislead the
333
+ // reader. extractBodyFields already normalizes by taking only
334
+ // the first paragraph of the schema description, so multi-
335
+ // paragraph fields don't blow out the help.
336
+ //
264
337
  // Field-flag UX choice: do NOT mark scalar body fields as
265
338
  // required at the oclif level even when the JSON Schema marks
266
339
  // them required. Reason: a caller can satisfy the requirement
267
- // either via the individual flag OR via --body / --body-file.
340
+ // either via the individual flag OR via --raw-body / --body-file.
268
341
  // Marking the flag required would force the individual-flag
269
342
  // form. The runtime body merger validates the final assembled
270
343
  // body against the same server-side schema either way.
271
344
  const common = {
272
- description: trimmedDesc || field.name,
345
+ description: field.description || field.name,
273
346
  };
274
347
  if (field.kind === "boolean")
275
348
  return Flags.boolean(common);
@@ -296,11 +369,11 @@ function buildFlags(operation) {
296
369
  }
297
370
  const bodyFieldFlagToProperty = new Map();
298
371
  if (operation.hasJsonBody) {
299
- flags.body = Flags.string({
300
- description: "Full request body as JSON. Prefer per-field flags (e.g. --to, --from, --body-text) when available; --body is the escape hatch for nested or complex fields.",
372
+ flags["raw-body"] = Flags.string({
373
+ description: "Full request body as raw JSON. Escape hatch for nested or complex fields (e.g. arrays); prefer per-field flags (e.g. --to, --from, --body-text) when available.",
301
374
  });
302
375
  flags["body-file"] = Flags.string({
303
- description: "Path to a JSON file used as the request body. Same role as --body for callers passing a saved payload.",
376
+ description: "Path to a JSON file used as the request body. Same role as --raw-body for callers passing a saved payload.",
304
377
  });
305
378
  // Expand top-level scalar body fields into individual flags so
306
379
  // `primitive sending:send-email --to alice@x --from support@x
@@ -414,7 +487,7 @@ export function createOperationCommand(operation) {
414
487
  body = { ...explicit, ...overrides };
415
488
  }
416
489
  else {
417
- // Caller passed --body as null, an array, or a
490
+ // Caller passed --raw-body as null, an array, or a
418
491
  // primitive AND also passed per-field flags. We can't
419
492
  // merge per-field overrides into a non-object body
420
493
  // shape, and silently dropping either source would
@@ -428,7 +501,7 @@ export function createOperationCommand(operation) {
428
501
  const overrideFlags = Object.keys(overrides)
429
502
  .map((p) => `--${flagName(p)}`)
430
503
  .join(", ");
431
- throw new Errors.CLIError(`--body must be a JSON object when also passing per-field flags (got ${explicitKind}); supplied per-field flags: ${overrideFlags}. Either drop --body and rely on the per-field flags, or move every field into the JSON --body and drop the flags.`);
504
+ throw new Errors.CLIError(`--raw-body must be a JSON object when also passing per-field flags (got ${explicitKind}); supplied per-field flags: ${overrideFlags}. Either drop --raw-body and rely on the per-field flags, or move every field into the JSON --raw-body and drop the flags.`);
432
505
  }
433
506
  }
434
507
  else {
@@ -436,7 +509,7 @@ export function createOperationCommand(operation) {
436
509
  }
437
510
  }
438
511
  if (operation.bodyRequired && body === undefined) {
439
- throw new Errors.CLIError(`Operation ${operation.operationId} requires a body. Pass each field as a --flag (see --help) or supply JSON via --body / --body-file.`);
512
+ throw new Errors.CLIError(`Operation ${operation.operationId} requires a body. Pass each field as a --flag (see --help) or supply JSON via --raw-body / --body-file.`);
440
513
  }
441
514
  const operationFn = operations[operation.sdkName];
442
515
  const result = await operationFn({
@@ -448,8 +521,7 @@ export function createOperationCommand(operation) {
448
521
  responseStyle: "fields",
449
522
  });
450
523
  if (result.error) {
451
- const errorPayload = extractErrorPayload(result.error);
452
- process.stderr.write(`${formatErrorPayload(errorPayload)}\n`);
524
+ writeErrorWithHints(extractErrorPayload(result.error));
453
525
  process.exitCode = 1;
454
526
  return;
455
527
  }
@@ -469,8 +541,34 @@ export function createOperationCommand(operation) {
469
541
  if (cursor) {
470
542
  process.stderr.write(`next cursor: ${cursor}\n`);
471
543
  }
544
+ // Empty-result hint. When a list-style operation returns
545
+ // an empty array, emit an operation-specific note to
546
+ // stderr so a naive caller can distinguish "nothing here"
547
+ // from "something isn't set up." Stdout still gets the
548
+ // raw `[]` so machine-readable output is unchanged. The
549
+ // AGX walkthrough flagged this: `list-deliveries` returning
550
+ // `[]` left the agent unsure whether they had an empty
551
+ // delivery log or no endpoints configured at all.
552
+ if (Array.isArray(envelope?.data) && envelope.data.length === 0) {
553
+ const hint = EMPTY_RESULT_HINTS[operation.sdkName];
554
+ if (hint)
555
+ process.stderr.write(`${hint}\n`);
556
+ }
472
557
  this.log(JSON.stringify(envelope?.data ?? null, null, 2));
473
558
  }
474
559
  }
475
560
  return OperationCommand;
476
561
  }
562
+ // Empty-state hints for list-style operations whose empty result
563
+ // would otherwise leave the caller wondering "is this empty
564
+ // because there's nothing to list, or because something earlier
565
+ // in the setup chain isn't done?" Keys are the manifest's
566
+ // `sdkName` for the operation. Operations without an entry fall
567
+ // back to no hint (silent empty array, same as before).
568
+ const EMPTY_RESULT_HINTS = {
569
+ listDeliveries: "(no results) Often means no webhook endpoints are configured to receive deliveries. Run `primitive endpoints:list-endpoints` to check.",
570
+ listEndpoints: "(no results) No webhook endpoints configured. Add one with `primitive endpoints:create-endpoint --url <your-url>`.",
571
+ listEmails: "(no results) No inbound emails received yet on this account.",
572
+ listDomains: "(no results) No domains on this account. Add one with `primitive domains:add-domain --domain <yourdomain.example>`.",
573
+ listFilters: "(no results) No filter rules configured.",
574
+ };
@@ -1,7 +1,7 @@
1
1
  import { Command, Errors, Flags } from "@oclif/core";
2
2
  import { listDomains, sendEmail } from "../../api/generated/sdk.gen.js";
3
3
  import { PrimitiveApiClient } from "../../api/index.js";
4
- import { extractErrorPayload, formatErrorPayload } from "../api-command.js";
4
+ import { extractErrorCode, extractErrorPayload, formatErrorPayload, writeErrorWithHints, } from "../api-command.js";
5
5
  // `primitive send` is the agent-grade shortcut for the most common
6
6
  // case: send a fresh outbound email. It wraps `sending:send-email`
7
7
  // with two ergonomic defaults that the underlying operation can't
@@ -52,6 +52,20 @@ async function pickDefaultFromAddress(apiClient) {
52
52
  });
53
53
  if (result.error) {
54
54
  const errorPayload = extractErrorPayload(result.error);
55
+ // If the underlying failure is an auth problem, don't pretend
56
+ // --from will fix it: the actual sendEmail call would 401 too.
57
+ // Surface the auth hint via writeErrorWithHints and bail with
58
+ // a focused message instead of the verbose "underlying error"
59
+ // wrapping.
60
+ if (extractErrorCode(errorPayload) === "unauthorized") {
61
+ writeErrorWithHints(errorPayload);
62
+ // exit: 1 to match the run() unauthorized path (which uses
63
+ // `process.exitCode = 1`). oclif's CLIError defaults to 2,
64
+ // so without this override the same "unauthorized" condition
65
+ // exits 2 when surfaced from listDomains and 1 when surfaced
66
+ // from sendEmail, breaking callers that branch on exit code.
67
+ throw new Errors.CLIError("Cannot send: API key is missing or invalid (see hint above).", { exit: 1 });
68
+ }
55
69
  throw new Errors.CLIError(`Could not look up your verified domains to default --from. Pass --from explicitly. Underlying error: ${formatErrorPayload(errorPayload)}`);
56
70
  }
57
71
  const envelope = result.data;
@@ -147,8 +161,7 @@ class SendCommand extends Command {
147
161
  responseStyle: "fields",
148
162
  });
149
163
  if (result.error) {
150
- const errorPayload = extractErrorPayload(result.error);
151
- process.stderr.write(`${formatErrorPayload(errorPayload)}\n`);
164
+ writeErrorWithHints(extractErrorPayload(result.error));
152
165
  process.exitCode = 1;
153
166
  return;
154
167
  }
@@ -0,0 +1,67 @@
1
+ import { Command, Errors, Flags } from "@oclif/core";
2
+ import { getAccount } from "../../api/generated/sdk.gen.js";
3
+ import { PrimitiveApiClient } from "../../api/index.js";
4
+ import { extractErrorPayload, writeErrorWithHints } from "../api-command.js";
5
+ // `primitive whoami` is the credentials smoke-test the AGX
6
+ // walkthrough kept asking for. Before this command, a user with a
7
+ // suspect API key had no fast way to verify "is my key live and
8
+ // pointed at the org I expect" short of trying any other call and
9
+ // reading a 401. That ambiguity bit two consecutive walkthroughs.
10
+ //
11
+ // Implementation: thin wrapper over /api/v1/account that prints
12
+ // the account email, plan, id, and onboarding status. Any auth
13
+ // problem surfaces as the standard error envelope, same as the
14
+ // generated commands.
15
+ class WhoamiCommand extends Command {
16
+ static description = `Print the account currently authenticated by the API key. Useful as a credentials smoke test: confirms the key is live and shows which account it belongs to.`;
17
+ static summary = "Print the authenticated account (credentials smoke test)";
18
+ static examples = [
19
+ "<%= config.bin %> whoami",
20
+ "<%= config.bin %> whoami --api-key prim_...",
21
+ ];
22
+ static flags = {
23
+ "api-key": Flags.string({
24
+ description: "Primitive API key (defaults to PRIMITIVE_API_KEY)",
25
+ env: "PRIMITIVE_API_KEY",
26
+ }),
27
+ "base-url": Flags.string({
28
+ description: "API base URL (defaults to PRIMITIVE_API_URL or production)",
29
+ env: "PRIMITIVE_API_URL",
30
+ }),
31
+ };
32
+ async run() {
33
+ const { flags } = await this.parse(WhoamiCommand);
34
+ const apiClient = new PrimitiveApiClient({
35
+ apiKey: flags["api-key"],
36
+ baseUrl: flags["base-url"],
37
+ });
38
+ const result = await getAccount({
39
+ client: apiClient.client,
40
+ responseStyle: "fields",
41
+ });
42
+ if (result.error) {
43
+ writeErrorWithHints(extractErrorPayload(result.error));
44
+ process.exitCode = 1;
45
+ return;
46
+ }
47
+ const envelope = result.data;
48
+ const account = envelope?.data;
49
+ if (!account) {
50
+ process.stderr.write("Server returned an empty account body; this should not happen for a valid key.\n");
51
+ throw new Errors.CLIError("unexpected empty response");
52
+ }
53
+ // Concise human-readable summary on stderr; the full account
54
+ // JSON goes to stdout so a script can pipe it.
55
+ const onboarding = account.onboarding_completed === true
56
+ ? "complete"
57
+ : account.onboarding_step
58
+ ? `in progress (step: ${account.onboarding_step})`
59
+ : "incomplete";
60
+ process.stderr.write(`Authenticated as ${account.email}\n`);
61
+ process.stderr.write(` Account id: ${account.id}\n`);
62
+ process.stderr.write(` Plan: ${account.plan}\n`);
63
+ process.stderr.write(` Onboarding: ${onboarding}\n`);
64
+ this.log(JSON.stringify(account, null, 2));
65
+ }
66
+ }
67
+ export default WhoamiCommand;
@@ -2,6 +2,7 @@ import { Args, Command } from "@oclif/core";
2
2
  import { operationManifest, } from "../openapi/index.js";
3
3
  import { createOperationCommand } from "./api-command.js";
4
4
  import SendCommand from "./commands/send.js";
5
+ import WhoamiCommand from "./commands/whoami.js";
5
6
  import { renderFishCompletion } from "./fish-completion.js";
6
7
  class ListOperationsCommand extends Command {
7
8
  static description = "List all generated API operations";
@@ -44,5 +45,10 @@ export const COMMANDS = {
44
45
  // operation stays available under sending:send-email for callers
45
46
  // who want every flag.
46
47
  send: SendCommand,
48
+ // `whoami` is the credentials smoke test. Prints the account the
49
+ // current API key authenticates as. AGX walkthroughs kept
50
+ // wanting this before risking a real call against a possibly-
51
+ // bad key.
52
+ whoami: WhoamiCommand,
47
53
  ...generatedCommands,
48
54
  };