@primitivedotdev/sdk 0.13.0 → 0.15.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.
@@ -0,0 +1,131 @@
1
+ import { Command, Flags } from "@oclif/core";
2
+ import { listEmails } from "../../api/generated/sdk.gen.js";
3
+ import { PrimitiveApiClient } from "../../api/index.js";
4
+ import { extractErrorPayload, writeErrorWithHints } from "../api-command.js";
5
+ // `primitive emails:latest` is the agent-grade shortcut for "show me
6
+ // the most recent inbound emails as something I can read at a glance."
7
+ // `emails:list-emails` returns the full JSON envelope which is great
8
+ // for piping but blows out the screen for a quick triage. The AGX
9
+ // walkthrough flagged that an agent doing inbox triage had no compact
10
+ // view to reach for; `latest` is that view.
11
+ //
12
+ // Output is a fixed-width text table: short-id, received timestamp,
13
+ // from address, to address, and subject. Subject is truncated for
14
+ // display only; the underlying JSON is unchanged. For machine-readable
15
+ // output, callers should use `emails:list-emails` and pipe to jq.
16
+ const DEFAULT_LIMIT = 10;
17
+ const MAX_LIMIT = 100;
18
+ // Truncation widths chosen so a row fits in ~140 columns total. Long
19
+ // values wrap to "..." rather than blowing out terminal layout.
20
+ const SUBJECT_DISPLAY_WIDTH = 50;
21
+ const ADDRESS_DISPLAY_WIDTH = 32;
22
+ const ID_DISPLAY_WIDTH = 8;
23
+ const RECEIVED_DISPLAY_WIDTH = 19;
24
+ // Truncate to width with right-padding; values longer than width are
25
+ // cut to width-3 with a "..." suffix so the output is exactly `width`
26
+ // chars (3 of which are the ellipsis). Display-only; never mutates
27
+ // the underlying value the caller passed in.
28
+ //
29
+ // Width-exact output matters here: formatRow relies on each column
30
+ // being exactly its declared width so columns line up across rows.
31
+ // An overflowing truncate would shift every later column to the
32
+ // right whenever truncation fired (e.g. a row with both addresses
33
+ // truncated would push SUBJECT 4 chars off).
34
+ export function truncate(value, width) {
35
+ if (value.length <= width)
36
+ return value.padEnd(width);
37
+ return `${value.slice(0, width - 3)}...`;
38
+ }
39
+ // Compact ISO timestamp for display: `YYYY-MM-DD HH:MM:SS` in UTC.
40
+ // The full ISO string with milliseconds and `T`/`Z` markers is too
41
+ // dense to scan at a glance; this is the same shape git log uses.
42
+ export function formatReceivedAt(value) {
43
+ if (!value)
44
+ return "-".padEnd(RECEIVED_DISPLAY_WIDTH);
45
+ const d = new Date(value);
46
+ if (Number.isNaN(d.getTime()))
47
+ return value.padEnd(RECEIVED_DISPLAY_WIDTH);
48
+ const pad = (n) => String(n).padStart(2, "0");
49
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
50
+ }
51
+ export function formatRow(email) {
52
+ const id = truncate(email.id.slice(0, ID_DISPLAY_WIDTH), ID_DISPLAY_WIDTH);
53
+ const received = formatReceivedAt(email.received_at);
54
+ const from = truncate(email.sender ?? "", ADDRESS_DISPLAY_WIDTH);
55
+ const to = truncate(email.recipient ?? "", ADDRESS_DISPLAY_WIDTH);
56
+ const subject = (email.subject ?? "").replace(/\s+/g, " ");
57
+ const subjectCol = truncate(subject, SUBJECT_DISPLAY_WIDTH);
58
+ return `${id} ${received} ${from} ${to} ${subjectCol}`;
59
+ }
60
+ class EmailsLatestCommand extends Command {
61
+ static description = `Print the N most recent inbound emails as a one-line-per-row text table. Designed for quick triage and visual scanning. For programmatic access, use \`primitive emails:list-emails\` (full JSON envelope, cursor pagination, filters) or pass \`--json\` here for the same raw shape without pagination/filters.
62
+
63
+ The default text table truncates each row's id to the first ${ID_DISPLAY_WIDTH} characters for readability. Operations that take an id (\`emails:get-email\`, \`emails:delete-email\`, etc.) require the full UUID, so pass \`--json\` or use \`emails:list-emails\` when you need to feed an id back into another command.
64
+
65
+ Output streams: the column header line is written to STDERR so the row data on STDOUT stays grep/awk-friendly. \`--json\` writes everything (including the envelope) to STDOUT.`;
66
+ static summary = "Show the most recent inbound emails as a compact table";
67
+ static examples = [
68
+ "<%= config.bin %> emails:latest",
69
+ "<%= config.bin %> emails:latest --limit 25",
70
+ "<%= config.bin %> emails:latest --json | jq '.data[0].id'",
71
+ ];
72
+ static flags = {
73
+ "api-key": Flags.string({
74
+ description: "Primitive API key (defaults to PRIMITIVE_API_KEY)",
75
+ env: "PRIMITIVE_API_KEY",
76
+ }),
77
+ "base-url": Flags.string({
78
+ description: "API base URL (defaults to PRIMITIVE_API_URL or production)",
79
+ env: "PRIMITIVE_API_URL",
80
+ }),
81
+ limit: Flags.integer({
82
+ description: `Number of rows to print (1-${MAX_LIMIT}, default ${DEFAULT_LIMIT}).`,
83
+ default: DEFAULT_LIMIT,
84
+ // oclif validates min/max at parse time and emits a consistent
85
+ // out-of-range error before run() is reached, so no manual
86
+ // bounds check is needed here.
87
+ min: 1,
88
+ max: MAX_LIMIT,
89
+ }),
90
+ json: Flags.boolean({
91
+ description: "Print the raw response envelope (with full UUIDs and meta) as JSON on STDOUT instead of the text table. Useful for piping into `jq`, capturing ids for follow-up commands, or scripting.",
92
+ }),
93
+ };
94
+ async run() {
95
+ const { flags } = await this.parse(EmailsLatestCommand);
96
+ const apiClient = new PrimitiveApiClient({
97
+ apiKey: flags["api-key"],
98
+ baseUrl: flags["base-url"],
99
+ });
100
+ const result = await listEmails({
101
+ client: apiClient.client,
102
+ query: { limit: flags.limit },
103
+ responseStyle: "fields",
104
+ });
105
+ if (result.error) {
106
+ writeErrorWithHints(extractErrorPayload(result.error));
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+ const envelope = result.data;
111
+ if (flags.json) {
112
+ // Raw envelope on stdout. Mirrors the shape `emails:list-emails`
113
+ // emits so callers can swap one for the other when they want
114
+ // table vs json without remembering different command names.
115
+ this.log(JSON.stringify(envelope ?? null, null, 2));
116
+ return;
117
+ }
118
+ const rows = envelope?.data ?? [];
119
+ if (rows.length === 0) {
120
+ process.stderr.write("No inbound emails yet. Send an email to one of your verified domains to populate this list.\n");
121
+ return;
122
+ }
123
+ // Header on stderr so the table itself stays grep-friendly.
124
+ const header = `${"ID".padEnd(ID_DISPLAY_WIDTH)} ${"RECEIVED (UTC)".padEnd(RECEIVED_DISPLAY_WIDTH)} ${"FROM".padEnd(ADDRESS_DISPLAY_WIDTH)} ${"TO".padEnd(ADDRESS_DISPLAY_WIDTH)} SUBJECT`;
125
+ process.stderr.write(`${header}\n`);
126
+ for (const row of rows) {
127
+ this.log(formatRow(row));
128
+ }
129
+ }
130
+ }
131
+ export default EmailsLatestCommand;
@@ -30,7 +30,15 @@ import { extractErrorCode, extractErrorPayload, formatErrorPayload, writeErrorWi
30
30
  // pattern-matching from there lands in the happy path. We just
31
31
  // don't need swaks's `--server` / `--auth-*` flags because the
32
32
  // HTTPS API key is the auth and the server is implicit.
33
- const SUBJECT_MAX_LENGTH = 70;
33
+ // 200 chars is a generous cap that almost never trips on natural
34
+ // first-line subjects (a sentence is typically <120 chars). The
35
+ // previous 70-char limit was tight enough that legitimate one-line
36
+ // bodies routinely produced ellipsis-truncated subjects in inbox
37
+ // listings, e.g. `"this is the simplest possible send: agent typed
38
+ // two flags and hit\\n e..."` from the AGX walkthrough. Real spam
39
+ // scoring engines don't penalize subjects under ~200 chars, so 200
40
+ // is both more useful and still well under the practical wire limit.
41
+ const SUBJECT_MAX_LENGTH = 200;
34
42
  function deriveSubject(body) {
35
43
  for (const line of body.split("\n")) {
36
44
  const trimmed = line.trim();
@@ -95,6 +103,7 @@ class SendCommand extends Command {
95
103
  "<%= config.bin %> send --to alice@example.com --from support@yourcompany.com --subject 'Quick question' --body 'Are you free Thursday?'",
96
104
  "<%= config.bin %> send --to alice@example.com --html '<p>Hello!</p>'",
97
105
  "<%= config.bin %> send --to alice@example.com --body 'Confirmed' --wait",
106
+ "<%= config.bin %> send --to inbox@your-managed-domain.primitive.email --body 'self-loop smoke test' --wait # any *.primitive.email address routes back to the sending account; useful for proving outbound + inbound work end-to-end",
98
107
  ];
99
108
  static flags = {
100
109
  "api-key": Flags.string({
@@ -1,16 +1,82 @@
1
- import { Args, Command } from "@oclif/core";
1
+ import { Args, Command, Errors } from "@oclif/core";
2
2
  import { operationManifest, } from "../openapi/index.js";
3
3
  import { createOperationCommand } from "./api-command.js";
4
+ import EmailsLatestCommand from "./commands/emails-latest.js";
4
5
  import SendCommand from "./commands/send.js";
5
6
  import WhoamiCommand from "./commands/whoami.js";
6
7
  import { renderFishCompletion } from "./fish-completion.js";
7
8
  class ListOperationsCommand extends Command {
8
- static description = "List all generated API operations";
9
- static summary = "List all generated API operations";
9
+ static description = "List all generated API operations as JSON. Useful for piping to `jq` to discover available commands, their request/response schemas, and per-field descriptions. For inspecting a single operation in detail, prefer `primitive describe <command>`.";
10
+ static summary = "List all generated API operations (JSON)";
10
11
  async run() {
11
12
  this.log(JSON.stringify(operationManifest, null, 2));
12
13
  }
13
14
  }
15
+ // Looks up an operation manifest entry by its `<topic>:<command>` id
16
+ // (e.g. `emails:get-email`). On miss, returns up to 5 closest
17
+ // candidates by substring match so the caller can render a
18
+ // "did you mean" hint. Pure function: no oclif config dependency,
19
+ // so it's also unit-testable in isolation.
20
+ export function lookupOperation(id) {
21
+ const trimmed = id.trim();
22
+ const sep = trimmed.indexOf(":");
23
+ const tag = sep === -1 ? "" : trimmed.slice(0, sep);
24
+ const cmd = sep === -1 ? trimmed : trimmed.slice(sep + 1);
25
+ const match = operationManifest.find((op) => op.command === cmd && op.tagCommand === tag) ?? null;
26
+ if (match)
27
+ return { match, candidates: [] };
28
+ const candidates = operationManifest
29
+ .filter((op) => op.command.includes(cmd) || op.tagCommand.includes(tag))
30
+ .slice(0, 5)
31
+ .map((op) => op.tagCommand ? `${op.tagCommand}:${op.command}` : op.command);
32
+ return { match: null, candidates };
33
+ }
34
+ // `primitive describe <command>` is the operation-detail inspector
35
+ // the AGX walkthrough kept wanting. The information is already in
36
+ // the operation manifest emitted by `list-operations`, but agents
37
+ // don't intuitively reach for `list-operations | jq '.[] | select(...)'`
38
+ // when they want to know "what does the from_email field on this
39
+ // response actually mean." A direct command is more discoverable.
40
+ //
41
+ // Lookup is by the colon-joined command id (e.g. `emails:get-email`,
42
+ // `sending:send-email`, `account:get-account`). For top-level
43
+ // generated commands (without a topic), pass the bare command id.
44
+ class DescribeCommand extends Command {
45
+ static args = {
46
+ command: Args.string({
47
+ description: "Command id to describe, in `<topic>:<command>` form (e.g. `emails:get-email`). Run `primitive list-operations | jq -r '.[] | \"\\(.tagCommand):\\(.command)\"'` to enumerate.",
48
+ required: true,
49
+ }),
50
+ };
51
+ static description = `Print the full operation manifest entry for a single API command, including the path, request schema, response schema, and per-field descriptions sourced from the OpenAPI spec.
52
+
53
+ The manifest entry's \`responseSchema\` carries the inlined JSON Schema for the operation's 200/201 \`data\` envelope contents (\`$ref\`s resolved). Use it to look up what specific response fields mean. Examples:
54
+
55
+ # Which of EmailDetail's sender-shaped fields is canonical?
56
+ primitive describe emails:get-email | jq '.responseSchema.properties | keys'
57
+ primitive describe emails:get-email | jq -r '.responseSchema.properties.from_email.description'
58
+
59
+ # What does each value of SentEmailStatus mean?
60
+ primitive describe sending:get-sent-email | jq -r '.responseSchema.properties.status.description'
61
+
62
+ \`requestSchema\` is the same shape for the request body when one exists. For a single field across many operations at once, use \`primitive list-operations | jq\` instead.`;
63
+ static summary = "Describe a single API operation in detail";
64
+ static examples = [
65
+ "<%= config.bin %> describe emails:get-email",
66
+ "<%= config.bin %> describe sending:send-email",
67
+ ];
68
+ async run() {
69
+ const { args } = await this.parse(DescribeCommand);
70
+ const { match, candidates } = lookupOperation(args.command);
71
+ if (!match) {
72
+ const hint = candidates.length > 0
73
+ ? `Did you mean: ${candidates.join(", ")}?`
74
+ : "Run `primitive list-operations` to enumerate.";
75
+ throw new Errors.CLIError(`Unknown operation \`${args.command.trim()}\`. ${hint}`, { exit: 1 });
76
+ }
77
+ this.log(JSON.stringify(match, null, 2));
78
+ }
79
+ }
14
80
  class CompletionCommand extends Command {
15
81
  static args = {
16
82
  shell: Args.string({
@@ -40,6 +106,11 @@ const generatedCommands = Object.fromEntries(operationManifest.map((operation) =
40
106
  export const COMMANDS = {
41
107
  completion: CompletionCommand,
42
108
  "list-operations": ListOperationsCommand,
109
+ // `describe` prints a single operation's full manifest entry
110
+ // (path, request schema, response schema, per-field descriptions).
111
+ // The same data is in `list-operations` but agents don't reach for
112
+ // `list-operations | jq` when they want to clarify a field meaning.
113
+ describe: DescribeCommand,
43
114
  // `send` is the agent-grade shortcut for sending:send-email with
44
115
  // sensible defaults (auto from-address, auto subject). The full
45
116
  // operation stays available under sending:send-email for callers
@@ -50,5 +121,9 @@ export const COMMANDS = {
50
121
  // wanting this before risking a real call against a possibly-
51
122
  // bad key.
52
123
  whoami: WhoamiCommand,
124
+ // `emails:latest` is the inbox-triage shortcut: the most recent N
125
+ // inbound emails as a compact text table. emails:list-emails stays
126
+ // available for the full JSON envelope + cursor pagination.
127
+ "emails:latest": EmailsLatestCommand,
53
128
  ...generatedCommands,
54
129
  };
@@ -37,6 +37,12 @@ type PrimitiveOperationManifest = {
37
37
  * true. `$ref`s into the OpenAPI components are inlined.
38
38
  */
39
39
  requestSchema: Record<string, unknown> | null;
40
+ /**
41
+ * Resolved JSON Schema for the 200/201 response body's `data`
42
+ * envelope contents. Same shape as `requestSchema`: `$ref`s
43
+ * inlined. Null on operations without a 200/201 JSON response.
44
+ */
45
+ responseSchema: Record<string, unknown> | null;
40
46
  sdkName: string;
41
47
  summary: string | null;
42
48
  tag: string;