@primitivedotdev/sdk 0.9.0 → 0.11.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.
@@ -7,80 +7,126 @@ function flagName(parameterName) {
7
7
  function flagDescription(parameter) {
8
8
  return parameter.description ?? parameter.name;
9
9
  }
10
- /**
11
- * Render a one-shot description of a JSON Schema's top-level
12
- * properties so an agent reading `<command> --help` can build a
13
- * valid `--body` payload without probing the server. Pulled from
14
- * the resolved request schema embedded on the manifest.
15
- *
16
- * Format prioritizes scanability over completeness: required
17
- * fields first, each line is `<name> <type> [- description]`
18
- * truncated to a reasonable width. Callers who need the full
19
- * schema can run `primitive list-operations | jq` and read
20
- * `requestSchema` directly.
21
- */
22
- function renderRequestSchemaSummary(schema) {
10
+ function extractBodyFields(schema) {
23
11
  if (!schema || typeof schema !== "object")
24
- return null;
12
+ return [];
25
13
  const properties = schema.properties;
26
14
  if (!properties || typeof properties !== "object")
27
- return null;
15
+ return [];
28
16
  const requiredArr = Array.isArray(schema.required)
29
17
  ? schema.required.filter((k) => typeof k === "string")
30
18
  : [];
31
19
  const required = new Set(requiredArr);
32
- const entries = Object.entries(properties)
33
- .map(([name, raw]) => {
20
+ const fields = [];
21
+ for (const [name, raw] of Object.entries(properties)) {
34
22
  const propSchema = raw && typeof raw === "object" ? raw : {};
35
- let type = "any";
36
23
  const t = propSchema.type;
24
+ let displayType = "any";
25
+ let kind = "complex";
37
26
  if (typeof t === "string") {
38
- type = t;
39
- if (type === "array") {
27
+ displayType = t;
28
+ if (t === "string")
29
+ kind = "string";
30
+ else if (t === "integer" || t === "number")
31
+ kind = "integer";
32
+ else if (t === "boolean")
33
+ kind = "boolean";
34
+ else if (t === "array") {
40
35
  const items = propSchema.items;
41
36
  if (items && typeof items === "object") {
42
37
  const itemType = items.type;
43
38
  if (typeof itemType === "string") {
44
- type = `array<${itemType}>`;
39
+ displayType = `array<${itemType}>`;
45
40
  }
46
41
  }
42
+ kind = "complex";
43
+ }
44
+ else {
45
+ kind = "complex";
47
46
  }
48
47
  }
49
48
  else if (Array.isArray(t)) {
50
- // Nullable shorthand the codegen normalizes to e.g. ["string","null"].
49
+ // Nullable shorthand the codegen normalizes to e.g.
50
+ // ["string","null"]. If exactly one non-null member, surface
51
+ // it as that scalar with a trailing `?`.
51
52
  const nonNull = t.filter((s) => s !== "null");
52
- type = nonNull.length === 1 ? `${nonNull[0]}?` : nonNull.join("|");
53
+ if (nonNull.length === 1) {
54
+ const single = nonNull[0];
55
+ displayType = `${single}?`;
56
+ if (single === "string")
57
+ kind = "string";
58
+ else if (single === "integer" || single === "number")
59
+ kind = "integer";
60
+ else if (single === "boolean")
61
+ kind = "boolean";
62
+ else
63
+ kind = "complex";
64
+ }
65
+ else {
66
+ displayType = nonNull.join("|");
67
+ kind = "complex";
68
+ }
53
69
  }
54
70
  const description = typeof propSchema.description === "string"
55
71
  ? propSchema.description.split("\n")[0].trim()
56
72
  : "";
57
- return {
73
+ const enumRaw = propSchema.enum;
74
+ const enumValues = kind === "string" && Array.isArray(enumRaw)
75
+ ? enumRaw.filter((e) => typeof e === "string")
76
+ : undefined;
77
+ fields.push({
58
78
  name,
59
- type,
60
79
  description,
61
80
  required: required.has(name),
62
- };
63
- })
64
- .sort((a, b) => {
81
+ displayType,
82
+ kind,
83
+ ...(enumValues && enumValues.length > 0 ? { enumValues } : {}),
84
+ });
85
+ }
86
+ return fields.sort((a, b) => {
65
87
  if (a.required !== b.required)
66
88
  return a.required ? -1 : 1;
67
89
  return a.name.localeCompare(b.name);
68
90
  });
69
- if (entries.length === 0)
91
+ }
92
+ /**
93
+ * Render a "Body fields" summary for the per-command help.
94
+ *
95
+ * Most scalar fields are exposed as individual `--flag` flags,
96
+ * which oclif auto-renders in the FLAGS section above. To avoid
97
+ * duplicating that, the summary here only documents fields that
98
+ * MUST go through `--body` (complex types: arrays, objects,
99
+ * mixed-non-nullable). When an operation has only scalars, the
100
+ * summary is omitted entirely and oclif's FLAGS section is the
101
+ * full story.
102
+ *
103
+ * For operations with mixed scalar and complex fields, we also
104
+ * 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."
107
+ */
108
+ function renderRequestSchemaSummary(schema) {
109
+ const fields = extractBodyFields(schema);
110
+ if (fields.length === 0)
70
111
  return null;
71
- const nameWidth = Math.min(24, Math.max(...entries.map((e) => e.name.length)));
72
- const lines = ["Body fields (JSON --body):"];
112
+ const complex = fields.filter((f) => f.kind === "complex");
113
+ if (complex.length === 0)
114
+ return null;
115
+ const nameWidth = Math.min(24, Math.max(...complex.map((f) => f.name.length)));
73
116
  const descMax = 78;
74
- for (const e of entries) {
75
- const flag = e.required ? " *" : " ";
76
- const padName = e.name.padEnd(nameWidth);
77
- const trimmedDesc = e.description.length > descMax
78
- ? `${e.description.slice(0, descMax - 3)}...`
79
- : e.description;
117
+ const lines = [
118
+ "Body fields requiring --body JSON (these are not exposed as flags):",
119
+ ];
120
+ for (const f of complex) {
121
+ const marker = f.required ? " *" : " ";
122
+ const padName = f.name.padEnd(nameWidth);
123
+ const trimmedDesc = f.description.length > descMax
124
+ ? `${f.description.slice(0, descMax - 3)}...`
125
+ : f.description;
80
126
  const desc = trimmedDesc ? ` ${trimmedDesc}` : "";
81
- lines.push(`${flag} ${padName} ${e.type}${desc}`);
127
+ lines.push(`${marker} ${padName} ${f.displayType}${desc}`);
82
128
  }
83
- lines.push("(* = required)");
129
+ lines.push("(* = required. Scalar body fields are exposed as individual --flag-name flags; see FLAGS above.)");
84
130
  return lines.join("\n");
85
131
  }
86
132
  export function flagForParameter(parameter) {
@@ -196,6 +242,44 @@ export function formatErrorPayload(payload) {
196
242
  }
197
243
  return JSON.stringify(payload, null, 2);
198
244
  }
245
+ // Reserved flag names the body-field expander must never overwrite.
246
+ // `--body` and `--body-file` are the JSON escape hatches.
247
+ // `--api-key`, `--base-url`, `--output` are infra. Path and query
248
+ // params get added before body fields and take precedence.
249
+ const RESERVED_FLAG_NAMES = new Set([
250
+ "api-key",
251
+ "base-url",
252
+ "body",
253
+ "body-file",
254
+ "output",
255
+ ]);
256
+ 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;
264
+ // Field-flag UX choice: do NOT mark scalar body fields as
265
+ // required at the oclif level even when the JSON Schema marks
266
+ // them required. Reason: a caller can satisfy the requirement
267
+ // either via the individual flag OR via --body / --body-file.
268
+ // Marking the flag required would force the individual-flag
269
+ // form. The runtime body merger validates the final assembled
270
+ // body against the same server-side schema either way.
271
+ const common = {
272
+ description: trimmedDesc || field.name,
273
+ };
274
+ if (field.kind === "boolean")
275
+ return Flags.boolean(common);
276
+ if (field.kind === "integer")
277
+ return Flags.integer(common);
278
+ if (field.enumValues) {
279
+ return Flags.string({ ...common, options: field.enumValues });
280
+ }
281
+ return Flags.string(common);
282
+ }
199
283
  function buildFlags(operation) {
200
284
  const flags = {
201
285
  "api-key": Flags.string({
@@ -210,18 +294,67 @@ function buildFlags(operation) {
210
294
  for (const parameter of [...operation.pathParams, ...operation.queryParams]) {
211
295
  flags[flagName(parameter.name)] = flagForParameter(parameter);
212
296
  }
297
+ const bodyFieldFlagToProperty = new Map();
213
298
  if (operation.hasJsonBody) {
214
- flags.body = Flags.string({ description: "JSON request body" });
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.",
301
+ });
215
302
  flags["body-file"] = Flags.string({
216
- description: "Path to a JSON file used as the request body",
303
+ description: "Path to a JSON file used as the request body. Same role as --body for callers passing a saved payload.",
217
304
  });
305
+ // Expand top-level scalar body fields into individual flags so
306
+ // `primitive sending:send-email --to alice@x --from support@x
307
+ // --body-text "hi"` works without constructing JSON. Driven by
308
+ // the requestSchema embedded on the manifest. Skip flags that
309
+ // collide with reserved names or with path/query params already
310
+ // added above; those collisions fall back to --body.
311
+ //
312
+ // Collisions are tracked in the returned map so the run()
313
+ // handler doesn't misread a path/query param's value as a
314
+ // body-field override. (A naive "look up parsedFlags[name]"
315
+ // pass would happily pick up the path param's value and
316
+ // silently write it into the body.)
317
+ const bodyFields = extractBodyFields(operation.requestSchema);
318
+ for (const field of bodyFields) {
319
+ if (field.kind === "complex")
320
+ continue;
321
+ const name = flagName(field.name);
322
+ if (RESERVED_FLAG_NAMES.has(name))
323
+ continue;
324
+ if (flags[name] !== undefined)
325
+ continue;
326
+ flags[name] = bodyFieldFlag(field);
327
+ bodyFieldFlagToProperty.set(name, field.name);
328
+ }
218
329
  }
219
330
  if (operation.binaryResponse) {
220
331
  flags.output = Flags.string({
221
332
  description: "Write binary response bytes to a file",
222
333
  });
223
334
  }
224
- return flags;
335
+ return { flags, bodyFieldFlagToProperty };
336
+ }
337
+ // Pull body field values out of the parsed CLI flags. Returns
338
+ // only fields the user actually supplied (omits undefined). Used
339
+ // to override / extend the JSON --body when both forms are
340
+ // present (per-field flags take precedence on key conflicts).
341
+ //
342
+ // The `bodyFieldFlagToProperty` allowlist comes from buildFlags and
343
+ // records ONLY the flags actually registered as body-field flags.
344
+ // Without it, this function would naively read parsedFlags by
345
+ // kebab-cased field name and pick up values from a colliding path
346
+ // or query param flag, silently writing them into the body under
347
+ // the body-field key. The allowlist keeps the merge honest: only
348
+ // flags this CLI generator owns end up in the body.
349
+ function collectBodyFieldFlags(parsedFlags, bodyFieldFlagToProperty) {
350
+ const result = {};
351
+ for (const [flag, property] of bodyFieldFlagToProperty) {
352
+ const value = parsedFlags[flag];
353
+ if (value === undefined)
354
+ continue;
355
+ result[property] = value;
356
+ }
357
+ return result;
225
358
  }
226
359
  function collectValues(parameters, flags) {
227
360
  const values = {};
@@ -234,7 +367,7 @@ function collectValues(parameters, flags) {
234
367
  return values;
235
368
  }
236
369
  export function createOperationCommand(operation) {
237
- const flags = buildFlags(operation);
370
+ const { flags, bodyFieldFlagToProperty } = buildFlags(operation);
238
371
  // Append a "Body fields" summary to the description so agents
239
372
  // running `<command> --help` learn the JSON shape immediately.
240
373
  // Without this, `--help` only said "JSON request body" and agents
@@ -262,11 +395,48 @@ export function createOperationCommand(operation) {
262
395
  ? parsedFlags["base-url"]
263
396
  : undefined,
264
397
  });
265
- const body = operation.hasJsonBody
266
- ? readJsonBody(parsedFlags)
267
- : undefined;
398
+ // Two body sources, merged: explicit JSON via --body /
399
+ // --body-file (the base) plus per-field flags (the
400
+ // overrides). Per-field flag values take precedence on key
401
+ // conflicts so a caller can pass a base payload via --body
402
+ // and override one field on the command line.
403
+ let body;
404
+ if (operation.hasJsonBody) {
405
+ const explicit = readJsonBody(parsedFlags);
406
+ const overrides = collectBodyFieldFlags(parsedFlags, bodyFieldFlagToProperty);
407
+ if (Object.keys(overrides).length > 0) {
408
+ if (explicit === undefined) {
409
+ body = overrides;
410
+ }
411
+ else if (explicit !== null &&
412
+ typeof explicit === "object" &&
413
+ !Array.isArray(explicit)) {
414
+ body = { ...explicit, ...overrides };
415
+ }
416
+ else {
417
+ // Caller passed --body as null, an array, or a
418
+ // primitive AND also passed per-field flags. We can't
419
+ // merge per-field overrides into a non-object body
420
+ // shape, and silently dropping either source would
421
+ // leave the caller's actual intent unclear. Refuse
422
+ // loudly so the next attempt is unambiguous.
423
+ const explicitKind = explicit === null
424
+ ? "null"
425
+ : Array.isArray(explicit)
426
+ ? "array"
427
+ : typeof explicit;
428
+ const overrideFlags = Object.keys(overrides)
429
+ .map((p) => `--${flagName(p)}`)
430
+ .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.`);
432
+ }
433
+ }
434
+ else {
435
+ body = explicit;
436
+ }
437
+ }
268
438
  if (operation.bodyRequired && body === undefined) {
269
- throw new Errors.CLIError(`Operation ${operation.operationId} requires --body or --body-file`);
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.`);
270
440
  }
271
441
  const operationFn = operations[operation.sdkName];
272
442
  const result = await operationFn({
@@ -0,0 +1,159 @@
1
+ import { Command, Errors, Flags } from "@oclif/core";
2
+ import { listDomains, sendEmail } from "../../api/generated/sdk.gen.js";
3
+ import { PrimitiveApiClient } from "../../api/index.js";
4
+ import { extractErrorPayload, formatErrorPayload } from "../api-command.js";
5
+ // `primitive send` is the agent-grade shortcut for the most common
6
+ // case: send a fresh outbound email. It wraps `sending:send-email`
7
+ // with two ergonomic defaults that the underlying operation can't
8
+ // express through manifest-driven flag generation alone:
9
+ //
10
+ // 1. `--from` defaults to `agent@<first-verified-domain>` when
11
+ // omitted. Most agents don't know which domains their org has
12
+ // verified for outbound; making them list-domains first to
13
+ // derive a from-address is exactly the kind of email-ops cruft
14
+ // this command exists to hide. Customers with multiple
15
+ // domains, or who want a different local-part, pass --from
16
+ // explicitly.
17
+ // 2. `--subject` defaults to the first non-empty line of the body
18
+ // (capped). Empty subjects get spam-scored, so we always emit
19
+ // something. Callers who want full control pass --subject.
20
+ //
21
+ // `--body` here is the message body (text). The full `send-email`
22
+ // operation distinguishes `body_text` and `body_html`; this
23
+ // shortcut keeps it simple by exposing `--body` for text and
24
+ // `--html` for the HTML alternative. Users who need both can pass
25
+ // both flags or fall back to `sending:send-email` for the full
26
+ // flag list.
27
+ //
28
+ // Compared to `swaks` (which agents likely have in their training
29
+ // data): this is `swaks`-shaped on purpose so an agent
30
+ // pattern-matching from there lands in the happy path. We just
31
+ // don't need swaks's `--server` / `--auth-*` flags because the
32
+ // HTTPS API key is the auth and the server is implicit.
33
+ const SUBJECT_MAX_LENGTH = 70;
34
+ function deriveSubject(body) {
35
+ for (const line of body.split("\n")) {
36
+ const trimmed = line.trim();
37
+ if (!trimmed)
38
+ continue;
39
+ return trimmed.length > SUBJECT_MAX_LENGTH
40
+ ? `${trimmed.slice(0, SUBJECT_MAX_LENGTH - 3)}...`
41
+ : trimmed;
42
+ }
43
+ return "Message";
44
+ }
45
+ function isVerifiedDomain(domain) {
46
+ return domain.is_active === true;
47
+ }
48
+ async function pickDefaultFromAddress(apiClient) {
49
+ const result = await listDomains({
50
+ client: apiClient.client,
51
+ responseStyle: "fields",
52
+ });
53
+ if (result.error) {
54
+ const errorPayload = extractErrorPayload(result.error);
55
+ throw new Errors.CLIError(`Could not look up your verified domains to default --from. Pass --from explicitly. Underlying error: ${formatErrorPayload(errorPayload)}`);
56
+ }
57
+ const envelope = result.data;
58
+ const first = envelope?.data?.find(isVerifiedDomain);
59
+ if (!first) {
60
+ throw new Errors.CLIError("No active verified outbound domain found on this account; pass --from explicitly. To set up outbound, claim a domain via `primitive domains:add-domain` and verify it.");
61
+ }
62
+ // Local-part: "agent". Any local-part is accepted on managed
63
+ // *.primitive.email subdomains, so this works out of the box for
64
+ // the auto-issued domain pool. For customers with BYO domains
65
+ // and their own MX, "agent@" may or may not be a routable
66
+ // mailbox; if you have a specific address you want to use, pass
67
+ // --from explicitly.
68
+ return `agent@${first.domain}`;
69
+ }
70
+ class SendCommand extends Command {
71
+ static description = `Send an outbound email. Agent-grade shortcut for sending:send-email with sensible defaults.
72
+
73
+ --from defaults to agent@<your-first-verified-outbound-domain> when omitted.
74
+ --subject defaults to the first line of the body when omitted.
75
+
76
+ For the full flag set (custom message-id threading on the wire,
77
+ references arrays, etc.), use \`primitive sending:send-email\`.`;
78
+ static summary = "Send an email (simplified, agent-friendly)";
79
+ static examples = [
80
+ "<%= config.bin %> send --to alice@example.com --body 'Hi Alice!'",
81
+ "<%= config.bin %> send --to alice@example.com --from support@yourcompany.com --subject 'Quick question' --body 'Are you free Thursday?'",
82
+ "<%= config.bin %> send --to alice@example.com --html '<p>Hello!</p>'",
83
+ "<%= config.bin %> send --to alice@example.com --body 'Confirmed' --wait",
84
+ ];
85
+ static flags = {
86
+ "api-key": Flags.string({
87
+ description: "Primitive API key (defaults to PRIMITIVE_API_KEY)",
88
+ env: "PRIMITIVE_API_KEY",
89
+ }),
90
+ "base-url": Flags.string({
91
+ description: "API base URL (defaults to PRIMITIVE_API_URL or production)",
92
+ env: "PRIMITIVE_API_URL",
93
+ }),
94
+ to: Flags.string({
95
+ description: "Recipient address (e.g. alice@example.com).",
96
+ required: true,
97
+ }),
98
+ from: Flags.string({
99
+ description: "Sender address. Defaults to agent@<your-first-verified-outbound-domain>.",
100
+ }),
101
+ subject: Flags.string({
102
+ description: "Subject line. Defaults to the first line of --body / --html when omitted.",
103
+ }),
104
+ body: Flags.string({
105
+ description: "Plain-text message body. Either --body or --html (or both) is required.",
106
+ }),
107
+ html: Flags.string({
108
+ description: "HTML message body. Either --body or --html (or both) is required.",
109
+ }),
110
+ "in-reply-to": Flags.string({
111
+ description: "Message-Id of the parent email when threading a reply on the wire. For replying to an inbound message you received, prefer `primitive sending:reply-to-email --id <inbound-id>`.",
112
+ }),
113
+ wait: Flags.boolean({
114
+ description: "Block until the receiving MTA returns an outcome. Without --wait, the call returns once Primitive has accepted the message for delivery.",
115
+ }),
116
+ "wait-timeout-ms": Flags.integer({
117
+ description: "Maximum time to wait when --wait is set. Defaults to 30000ms.",
118
+ }),
119
+ };
120
+ async run() {
121
+ const { flags } = await this.parse(SendCommand);
122
+ if (!flags.body && !flags.html) {
123
+ throw new Errors.CLIError("Either --body or --html (or both) is required.");
124
+ }
125
+ const apiClient = new PrimitiveApiClient({
126
+ apiKey: flags["api-key"],
127
+ baseUrl: flags["base-url"],
128
+ });
129
+ const from = flags.from ?? (await pickDefaultFromAddress(apiClient));
130
+ const subject = flags.subject ?? (flags.body ? deriveSubject(flags.body) : "Message");
131
+ const result = await sendEmail({
132
+ body: {
133
+ from,
134
+ to: flags.to,
135
+ subject,
136
+ ...(flags.body !== undefined ? { body_text: flags.body } : {}),
137
+ ...(flags.html !== undefined ? { body_html: flags.html } : {}),
138
+ ...(flags["in-reply-to"] !== undefined
139
+ ? { in_reply_to: flags["in-reply-to"] }
140
+ : {}),
141
+ ...(flags.wait !== undefined ? { wait: flags.wait } : {}),
142
+ ...(flags["wait-timeout-ms"] !== undefined
143
+ ? { wait_timeout_ms: flags["wait-timeout-ms"] }
144
+ : {}),
145
+ },
146
+ client: apiClient.client,
147
+ responseStyle: "fields",
148
+ });
149
+ if (result.error) {
150
+ const errorPayload = extractErrorPayload(result.error);
151
+ process.stderr.write(`${formatErrorPayload(errorPayload)}\n`);
152
+ process.exitCode = 1;
153
+ return;
154
+ }
155
+ const envelope = result.data;
156
+ this.log(JSON.stringify(envelope?.data ?? null, null, 2));
157
+ }
158
+ }
159
+ export default SendCommand;
@@ -1,6 +1,7 @@
1
1
  import { Args, Command } from "@oclif/core";
2
2
  import { operationManifest, } from "../openapi/index.js";
3
3
  import { createOperationCommand } from "./api-command.js";
4
+ import SendCommand from "./commands/send.js";
4
5
  import { renderFishCompletion } from "./fish-completion.js";
5
6
  class ListOperationsCommand extends Command {
6
7
  static description = "List all generated API operations";
@@ -38,5 +39,10 @@ const generatedCommands = Object.fromEntries(operationManifest.map((operation) =
38
39
  export const COMMANDS = {
39
40
  completion: CompletionCommand,
40
41
  "list-operations": ListOperationsCommand,
42
+ // `send` is the agent-grade shortcut for sending:send-email with
43
+ // sensible defaults (auto from-address, auto subject). The full
44
+ // operation stays available under sending:send-email for callers
45
+ // who want every flag.
46
+ send: SendCommand,
41
47
  ...generatedCommands,
42
48
  };