@primitivedotdev/cli 0.31.5 → 0.31.7

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.
Files changed (2) hide show
  1. package/dist/oclif/index.js +557 -45
  2. package/package.json +1 -1
@@ -13379,7 +13379,7 @@ function surfaceUnauthorizedHint(params) {
13379
13379
  }
13380
13380
  process.stderr.write("Your saved Primitive CLI OAuth session was rejected. If the command was working a moment ago, please retry; brief retries often clear transient rejections. If it keeps failing, run `primitive logout && primitive signin` to mint a fresh session.\n");
13381
13381
  }
13382
- function formatElapsed(ms) {
13382
+ function formatElapsed$1(ms) {
13383
13383
  const seconds = ms / 1e3;
13384
13384
  if (seconds < 60) return `${seconds.toFixed(2)}s`;
13385
13385
  const minutes = Math.floor(seconds / 60);
@@ -13391,7 +13391,7 @@ async function runWithTiming(enabled, fn) {
13391
13391
  try {
13392
13392
  return await fn();
13393
13393
  } finally {
13394
- process.stderr.write(`[time: ${formatElapsed(Date.now() - start)}]\n`);
13394
+ process.stderr.write(`[time: ${formatElapsed$1(Date.now() - start)}]\n`);
13395
13395
  }
13396
13396
  }
13397
13397
  const TIME_FLAG_DESCRIPTION = "Print the wall-clock duration of this command to stderr after it completes (e.g. `[time: 1.34s]`). Useful for measuring `--wait` send latency, comparing CLI overhead, or capturing timing in scripts.";
@@ -13755,6 +13755,364 @@ async function readStdinToString() {
13755
13755
  for await (const chunk of process.stdin) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
13756
13756
  return Buffer.concat(chunks).toString("utf8");
13757
13757
  }
13758
+ var ChatProgressIndicator = class {
13759
+ currentMessage = null;
13760
+ frameIndex = 0;
13761
+ lastLineLength = 0;
13762
+ startedAt;
13763
+ timer = null;
13764
+ constructor(stream = process.stderr, now = Date.now) {
13765
+ this.stream = stream;
13766
+ this.now = now;
13767
+ this.startedAt = this.now();
13768
+ }
13769
+ start(message) {
13770
+ this.stopTimer();
13771
+ this.currentMessage = message;
13772
+ if (this.stream.isTTY) {
13773
+ this.render(message);
13774
+ this.timer = setInterval(() => this.render(message), 120);
13775
+ this.timer.unref?.();
13776
+ return;
13777
+ }
13778
+ this.stream.write(`${message}\n`);
13779
+ }
13780
+ update(message, options = {}) {
13781
+ this.currentMessage = message;
13782
+ if (this.stream.isTTY) {
13783
+ this.stopTimer();
13784
+ this.clearLine();
13785
+ this.render(message);
13786
+ this.timer = setInterval(() => this.render(message), 120);
13787
+ this.timer.unref?.();
13788
+ return;
13789
+ }
13790
+ this.stopTimer();
13791
+ this.stream.write(`${message}\n`);
13792
+ if (options.heartbeatMs !== void 0) {
13793
+ this.timer = setInterval(() => {
13794
+ this.stream.write(`${formatWaitingHeartbeat(message, this.now() - this.startedAt, options.timeoutSeconds)}\n`);
13795
+ }, options.heartbeatMs);
13796
+ this.timer.unref?.();
13797
+ }
13798
+ }
13799
+ notice(message) {
13800
+ if (this.stream.isTTY) {
13801
+ const currentMessage = this.currentMessage;
13802
+ this.clearLine();
13803
+ this.stream.write(`${message}\n`);
13804
+ if (currentMessage !== null && this.timer !== null) this.render(currentMessage);
13805
+ return;
13806
+ }
13807
+ this.stream.write(`${message}\n`);
13808
+ }
13809
+ succeed(message) {
13810
+ this.finish(`${message} after ${formatElapsed(this.now() - this.startedAt)}.`);
13811
+ }
13812
+ fail(message) {
13813
+ this.finish(message);
13814
+ }
13815
+ finish(message) {
13816
+ this.stopTimer();
13817
+ this.currentMessage = null;
13818
+ if (this.stream.isTTY) this.clearLine();
13819
+ this.stream.write(`${message}\n`);
13820
+ }
13821
+ render(message) {
13822
+ const frames = [
13823
+ "-",
13824
+ "\\",
13825
+ "|",
13826
+ "/"
13827
+ ];
13828
+ const frame = frames[this.frameIndex % frames.length];
13829
+ this.frameIndex += 1;
13830
+ const line = `${frame} ${message} (${formatElapsed(this.now() - this.startedAt)})`;
13831
+ this.lastLineLength = Math.max(this.lastLineLength, line.length);
13832
+ this.stream.write(`\r${line}`);
13833
+ }
13834
+ clearLine() {
13835
+ if (this.lastLineLength > 0) {
13836
+ this.stream.write(`\r${" ".repeat(this.lastLineLength)}\r`);
13837
+ this.lastLineLength = 0;
13838
+ }
13839
+ }
13840
+ stopTimer() {
13841
+ if (this.timer !== null) {
13842
+ clearInterval(this.timer);
13843
+ this.timer = null;
13844
+ }
13845
+ }
13846
+ };
13847
+ function formatElapsed(ms) {
13848
+ const seconds = Math.max(0, Math.round(ms / 1e3));
13849
+ if (seconds < 60) return `${seconds}s`;
13850
+ const minutes = Math.floor(seconds / 60);
13851
+ const remainder = seconds % 60;
13852
+ return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
13853
+ }
13854
+ function formatWaitingHeartbeat(message, elapsedMs, timeoutSeconds) {
13855
+ const timeout = timeoutSeconds === void 0 ? "" : timeoutSeconds === 0 ? ", no timeout" : `, timeout ${formatElapsed(timeoutSeconds * 1e3)}`;
13856
+ return `${message} (${formatElapsed(elapsedMs)} elapsed${timeout})`;
13857
+ }
13858
+ function shellQuote(value) {
13859
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value;
13860
+ return `'${value.replaceAll("'", "'\\''")}'`;
13861
+ }
13862
+ function commandFromArgv(argv) {
13863
+ return argv.map(shellQuote).join(" ");
13864
+ }
13865
+ function resolveChatResponseBody(reply) {
13866
+ if (reply.body_text && reply.body_text.length > 0) return {
13867
+ body: reply.body_text,
13868
+ format: "text"
13869
+ };
13870
+ if (reply.body_html && reply.body_html.length > 0) return {
13871
+ body: reply.body_html,
13872
+ format: "html"
13873
+ };
13874
+ if (reply.body_text !== null && reply.body_text !== void 0) return {
13875
+ body: reply.body_text,
13876
+ format: "text"
13877
+ };
13878
+ if (reply.body_html !== null && reply.body_html !== void 0) return {
13879
+ body: reply.body_html,
13880
+ format: "html"
13881
+ };
13882
+ return {
13883
+ body: "",
13884
+ format: "empty"
13885
+ };
13886
+ }
13887
+ function matchDescription(strategy) {
13888
+ return strategy === "strict" ? "strict, matched by reply_to_sent_email_id" : "fallback, matched by sender/time window";
13889
+ }
13890
+ function normalizeEmailAddress(value) {
13891
+ return value.trim().toLowerCase();
13892
+ }
13893
+ function derivedReplySubject(parent) {
13894
+ const subject = parent.subject?.trim();
13895
+ if (!subject) return "Re: (no subject)";
13896
+ return /^re:/i.test(subject) ? subject : `Re: ${subject}`;
13897
+ }
13898
+ function assertParentMatchesRecipient(parent, recipient) {
13899
+ if (normalizeEmailAddress(parent.from_email) === normalizeEmailAddress(recipient)) return;
13900
+ throw cliError$6(`Inbound email ${parent.id} is from ${parent.from_email}, not ${recipient}. Use \`primitive chat ${parent.from_email} --reply <message> --reply-to-email-id ${parent.id}\` or omit --reply-to-email-id to continue the latest inbound from ${recipient}.`);
13901
+ }
13902
+ function emailDetailFromEnvelope(envelope) {
13903
+ return envelope?.data ?? envelope ?? null;
13904
+ }
13905
+ function buildCommand(kind, description, argv, options = {}) {
13906
+ const requiresMessage = options.requiresMessage ?? false;
13907
+ return {
13908
+ argv,
13909
+ description,
13910
+ command: commandFromArgv(argv),
13911
+ kind,
13912
+ placeholders: requiresMessage ? [{
13913
+ description: "Replace with the message body before running.",
13914
+ token: "<message>"
13915
+ }] : [],
13916
+ requires_message: requiresMessage
13917
+ };
13918
+ }
13919
+ function buildChatFollowUpCommands(context) {
13920
+ const commands = [];
13921
+ const continueParts = [
13922
+ "primitive",
13923
+ "chat",
13924
+ context.recipient,
13925
+ "--reply",
13926
+ "<message>",
13927
+ "--from",
13928
+ context.from,
13929
+ "--reply-to-email-id",
13930
+ context.reply.id,
13931
+ "--timeout",
13932
+ String(context.timeoutSeconds)
13933
+ ];
13934
+ if (context.json) continueParts.push("--json");
13935
+ if (context.quiet) continueParts.push("--quiet");
13936
+ if (context.strictOnly) continueParts.push("--strict-only");
13937
+ else if (context.strictPhaseSeconds !== DEFAULT_STRICT_PHASE_SECONDS) continueParts.push("--strict-phase-seconds", String(context.strictPhaseSeconds));
13938
+ commands.push(buildCommand("continue_chat", "Continue this chat", continueParts, { requiresMessage: true }));
13939
+ commands.push(buildCommand("reply_direct", "Reply directly to the inbound email", [
13940
+ "primitive",
13941
+ "reply",
13942
+ "--id",
13943
+ context.reply.id,
13944
+ "--from",
13945
+ context.from,
13946
+ "--body",
13947
+ "<message>"
13948
+ ], { requiresMessage: true }));
13949
+ commands.push(buildCommand("inspect_reply", "Inspect the full inbound email", [
13950
+ "primitive",
13951
+ "emails",
13952
+ "get",
13953
+ "--id",
13954
+ context.reply.id
13955
+ ]));
13956
+ commands.push(buildCommand("wait_for_more", "Wait for future replies to this send", [
13957
+ "primitive",
13958
+ "emails",
13959
+ "wait",
13960
+ "--reply-to-sent-email-id",
13961
+ context.sent.id,
13962
+ "--to",
13963
+ context.from,
13964
+ "--since",
13965
+ context.reply.received_at,
13966
+ "--timeout",
13967
+ String(context.timeoutSeconds)
13968
+ ]));
13969
+ return commands;
13970
+ }
13971
+ function buildChatRecoveryCommands(context) {
13972
+ return [
13973
+ buildCommand("wait_threaded_reply", "Wait for the threaded reply again", [
13974
+ "primitive",
13975
+ "emails",
13976
+ "wait",
13977
+ "--reply-to-sent-email-id",
13978
+ context.sent.id,
13979
+ "--to",
13980
+ context.from,
13981
+ "--since",
13982
+ context.sentAtIso,
13983
+ "--timeout",
13984
+ String(context.timeoutSeconds)
13985
+ ]),
13986
+ buildCommand("wait_fallback_reply", "Fallback wait by sender/time window", [
13987
+ "primitive",
13988
+ "emails",
13989
+ "wait",
13990
+ "--from",
13991
+ context.recipient,
13992
+ "--to",
13993
+ context.from,
13994
+ "--since",
13995
+ context.sentAtIso,
13996
+ "--timeout",
13997
+ String(context.timeoutSeconds)
13998
+ ]),
13999
+ buildCommand("inspect_sent_email", "Inspect the outbound send", [
14000
+ "primitive",
14001
+ "sent",
14002
+ "get",
14003
+ "--id",
14004
+ context.sent.id
14005
+ ])
14006
+ ];
14007
+ }
14008
+ function buildChatJsonEnvelope(context) {
14009
+ const responseBody = resolveChatResponseBody(context.reply);
14010
+ return {
14011
+ sent: context.sent,
14012
+ reply: context.reply,
14013
+ response_body: responseBody.body,
14014
+ response_body_format: responseBody.format,
14015
+ match: {
14016
+ description: matchDescription(context.matchStrategy),
14017
+ reply_to_sent_email_id: context.reply.reply_to_sent_email_id ?? null,
14018
+ strategy: context.matchStrategy
14019
+ },
14020
+ follow_up_commands: buildChatFollowUpCommands(context)
14021
+ };
14022
+ }
14023
+ function formatChatResponse(context) {
14024
+ const accepted = context.sent.accepted.join(", ") || context.recipient;
14025
+ const responseBody = resolveChatResponseBody(context.reply);
14026
+ const lines = [
14027
+ "Reply received",
14028
+ "",
14029
+ "Sent",
14030
+ ` To: ${accepted}`,
14031
+ ` From: ${context.sent.from || context.from}`,
14032
+ ` Subject: ${context.subject}`,
14033
+ ` Sent email id: ${context.sent.id}`,
14034
+ ` Delivery status: ${context.sent.delivery_status ?? context.sent.status}`,
14035
+ "",
14036
+ "Reply",
14037
+ ` Email id: ${context.reply.id}`,
14038
+ ` From: ${context.reply.from_email}`,
14039
+ ` To: ${context.reply.to_email}`,
14040
+ ` Subject: ${context.reply.subject ?? "(no subject)"}`,
14041
+ ` Received: ${context.reply.received_at}`,
14042
+ ` Match: ${matchDescription(context.matchStrategy)}`
14043
+ ];
14044
+ if (context.reply.reply_to_sent_email_id) lines.push(` Reply to sent email id: ${context.reply.reply_to_sent_email_id}`);
14045
+ if (context.reply.message_id) lines.push(` Message-Id: ${context.reply.message_id}`);
14046
+ lines.push("", "Helpful follow-up commands", " Replace <message> before running commands that include it.", " Commands are templates; use --json for parse-safe output.");
14047
+ for (const { description, command } of buildChatFollowUpCommands(context)) lines.push(` ${description}:`, ` ${command}`);
14048
+ lines.push("", `Response body (${responseBody.format}; use --json for parsing)`, "----- BEGIN RESPONSE -----", responseBody.body || "(empty response)", "----- END RESPONSE -----");
14049
+ return lines.join("\n");
14050
+ }
14051
+ function formatChatRecoveryContext(context) {
14052
+ const lines = [
14053
+ "",
14054
+ "Sent message context",
14055
+ ` To: ${context.sent.accepted.join(", ") || context.recipient}`,
14056
+ ` From: ${context.sent.from || context.from}`,
14057
+ ` Subject: ${context.subject}`,
14058
+ ` Sent email id: ${context.sent.id}`,
14059
+ ` Delivery status: ${context.sent.delivery_status ?? context.sent.status}`,
14060
+ ` Poll since: ${context.sentAtIso}`,
14061
+ "",
14062
+ "Helpful recovery commands"
14063
+ ];
14064
+ for (const { description, command } of buildChatRecoveryCommands(context)) lines.push(` ${description}:`, ` ${command}`);
14065
+ return lines.join("\n");
14066
+ }
14067
+ async function loadInboundEmailDetail(params) {
14068
+ const result = await getEmail({
14069
+ client: params.apiClient.client,
14070
+ path: { id: params.id },
14071
+ responseStyle: "fields"
14072
+ });
14073
+ if (result.error) {
14074
+ const payload = extractErrorPayload(result.error);
14075
+ writeErrorWithHints(payload);
14076
+ surfaceUnauthorizedHint({
14077
+ ...params.authFailureContext,
14078
+ payload
14079
+ });
14080
+ throw new Errors.CLIError(`Could not load inbound email ${params.id}.`, { exit: 1 });
14081
+ }
14082
+ const detail = emailDetailFromEnvelope(result.data);
14083
+ if (!detail) throw new Errors.CLIError(`Could not load inbound email ${params.id}: the API returned no email body.`, { exit: 1 });
14084
+ return detail;
14085
+ }
14086
+ async function findLatestInboundFromRecipient(params) {
14087
+ const result = await searchEmails({
14088
+ client: params.apiClient.client,
14089
+ query: {
14090
+ from: params.recipient,
14091
+ to: params.from,
14092
+ include_facets: "false",
14093
+ limit: params.pageSize,
14094
+ snippet: "false",
14095
+ sort: "received_at_desc"
14096
+ },
14097
+ responseStyle: "fields"
14098
+ });
14099
+ if (result.error) {
14100
+ const payload = extractErrorPayload(result.error);
14101
+ writeErrorWithHints(payload);
14102
+ surfaceUnauthorizedHint({
14103
+ ...params.authFailureContext,
14104
+ payload
14105
+ });
14106
+ throw new Errors.CLIError("Could not find a prior chat reply.", { exit: 1 });
14107
+ }
14108
+ const row = (result.data?.data ?? []).find((email) => email.status === "accepted" || email.status === "completed");
14109
+ if (!row) return null;
14110
+ return loadInboundEmailDetail({
14111
+ apiClient: params.apiClient,
14112
+ authFailureContext: params.authFailureContext,
14113
+ id: row.id
14114
+ });
14115
+ }
13758
14116
  var ChatCommand = class ChatCommand extends Command {
13759
14117
  static description = `Send a message to an address and wait for the reply.
13760
14118
 
@@ -13763,17 +14121,37 @@ var ChatCommand = class ChatCommand extends Command {
13763
14121
  \`primitive chat\` is semantic (send + wait for the threaded reply).
13764
14122
 
13765
14123
  The message body can be given as the second positional argument or
13766
- piped via stdin. The reply body is written to stdout; --json emits a
13767
- structured envelope with both sides of the exchange.
14124
+ piped via stdin. The default output confirms the reply was received,
14125
+ prints exchange metadata, shows the response body, and lists helpful
14126
+ follow-up commands as templates. The default transcript is for humans;
14127
+ agents and scripts should pass --json for parse-safe output.
14128
+
14129
+ To continue an existing chat, pass --reply '<message>'. By default,
14130
+ the CLI replies to the latest inbound email from the recipient to
14131
+ your sender address. For exact continuation, pass
14132
+ --reply-to-email-id <inbound-email-id>. Reply mode uses Primitive's
14133
+ reply endpoint, so the reply subject and threading headers are
14134
+ derived from the inbound email instead of copied into CLI flags.
14135
+
14136
+ --json emits a structured envelope with both sides of the exchange,
14137
+ a direct response_body field, match details, and follow-up command
14138
+ metadata such as kind, argv, placeholders, and requires_message.
13768
14139
 
13769
- Matching the reply: the wait phase polls inbound mail filtered by
13770
- the recipient as sender and the send time as a lower bound. The
13771
- first match is taken; the full inbound row is then fetched for the
13772
- body. Exits non-zero on timeout.`;
14140
+ Matching the reply: chat first waits in strict threading mode by
14141
+ filtering inbound mail with reply_to_sent_email_id=<sent id>. If
14142
+ no strict match arrives before the strict phase ends, and
14143
+ --strict-only is not set, it falls back to a weaker sender/time
14144
+ window match: from=<recipient>, to=<sender>, and since=<send time>.
14145
+ The fallback can catch clients that strip threading headers, but it
14146
+ is less exact than strict matching. Progress is written to stderr
14147
+ while the CLI waits. Exits non-zero on timeout and prints recovery
14148
+ commands when the send succeeded but no reply was returned.`;
13773
14149
  static summary = "Chat with an agent over email (send and wait for the reply)";
13774
14150
  static examples = [
13775
14151
  "<%= config.bin %> chat help@agent.acme.dev 'how do I rotate my API key?'",
13776
14152
  "cat error.log | <%= config.bin %> chat help@agent.acme.dev --subject 'webhook 401s'",
14153
+ "<%= config.bin %> chat help@agent.acme.dev --reply 'one more thing'",
14154
+ "<%= config.bin %> chat help@agent.acme.dev --reply 'one more thing' --reply-to-email-id <inbound-email-id>",
13777
14155
  "<%= config.bin %> chat help@agent.acme.dev 'follow up question' --json",
13778
14156
  "<%= config.bin %> chat help@agent.acme.dev 'one more thing' --timeout 300"
13779
14157
  ];
@@ -13801,8 +14179,11 @@ var ChatCommand = class ChatCommand extends Command {
13801
14179
  }),
13802
14180
  from: Flags.string({ description: "Sender address. Defaults to agent@<your-first-verified-outbound-domain>." }),
13803
14181
  subject: Flags.string({ description: "Subject line. Defaults to the first line of the message when omitted." }),
13804
- "in-reply-to": Flags.string({ description: "Message-Id of the parent email to thread this against. Use when continuing a prior conversation from outside the CLI; for an inbound you received via Primitive, prefer `primitive reply --id <inbound-id>`." }),
13805
- json: Flags.boolean({ description: "Emit a structured JSON envelope { sent, reply } on stdout instead of just the reply body." }),
14182
+ reply: Flags.string({ description: "Reply body. Continues the latest inbound email from the recipient to your sender address; pass --reply-to-email-id for an exact thread." }),
14183
+ "reply-to-email-id": Flags.string({ description: "Inbound email id to continue exactly. Uses Primitive's reply endpoint, so recipient, subject, and threading headers are derived from the inbound email." }),
14184
+ "in-reply-to": Flags.string({ description: "Raw Message-Id of the parent email to thread a new send against. Prefer --reply-to-email-id with --reply when continuing an inbound email stored by Primitive." }),
14185
+ json: Flags.boolean({ description: "Emit a structured JSON envelope { sent, reply, response_body, response_body_format, match, follow_up_commands } on stdout instead of the human-readable transcript." }),
14186
+ quiet: Flags.boolean({ description: "Suppress stderr progress updates while sending and waiting. Errors and recovery commands are still written to stderr." }),
13806
14187
  timeout: Flags.integer({
13807
14188
  default: DEFAULT_CHAT_TIMEOUT_SECONDS,
13808
14189
  description: "Seconds to wait for a reply before exiting non-zero; 0 waits forever.",
@@ -13830,8 +14211,12 @@ var ChatCommand = class ChatCommand extends Command {
13830
14211
  };
13831
14212
  async run() {
13832
14213
  const { args, flags } = await this.parse(ChatCommand);
13833
- const message = args.message !== void 0 && args.message !== "" ? args.message : await readStdinToString();
13834
- if (!message.trim()) throw cliError$6("Message body is empty.");
14214
+ const replyMode = flags.reply !== void 0 || flags["reply-to-email-id"] !== void 0;
14215
+ if (flags.reply !== void 0 && args.message !== void 0 && args.message !== "") throw cliError$6("Pass the reply body either as --reply or as the positional message, not both.");
14216
+ if (replyMode && flags.subject !== void 0) throw cliError$6("--subject is not used with --reply. Primitive derives the reply subject from the inbound email.");
14217
+ if (replyMode && flags["in-reply-to"] !== void 0) throw cliError$6("Use --reply-to-email-id with --reply instead of raw --in-reply-to.");
14218
+ const message = flags.reply !== void 0 ? flags.reply : args.message !== void 0 && args.message !== "" ? args.message : await readStdinToString();
14219
+ if (!message.trim()) throw cliError$6(replyMode ? "Reply body is empty." : "Message body is empty.");
13835
14220
  await runWithTiming(flags.time, async () => {
13836
14221
  const { apiClient, auth, baseUrlOverridden } = await createAuthenticatedCliApiClient({
13837
14222
  apiKey: flags["api-key"],
@@ -13844,10 +14229,71 @@ var ChatCommand = class ChatCommand extends Command {
13844
14229
  baseUrlOverridden,
13845
14230
  configDir: this.config.configDir
13846
14231
  };
13847
- const from = flags.from ?? await pickDefaultFromAddress(apiClient, authFailureContext);
13848
- const subject = flags.subject ?? deriveSubject(message);
14232
+ const progress = flags.quiet ? null : new ChatProgressIndicator(process.stderr);
14233
+ let from;
14234
+ let parentReply;
14235
+ let subject;
14236
+ if (replyMode) {
14237
+ const replyContext = await (async () => {
14238
+ let replyContextFailureMessage = "Could not load reply context.";
14239
+ try {
14240
+ if (flags["reply-to-email-id"] !== void 0) {
14241
+ progress?.start(`Loading reply context for ${flags["reply-to-email-id"]}`);
14242
+ const exactParentReply = await loadInboundEmailDetail({
14243
+ apiClient,
14244
+ authFailureContext,
14245
+ id: flags["reply-to-email-id"]
14246
+ });
14247
+ replyContextFailureMessage = `Inbound email ${flags["reply-to-email-id"]} does not match recipient ${args.recipient}.`;
14248
+ assertParentMatchesRecipient(exactParentReply, args.recipient);
14249
+ return {
14250
+ from: flags.from ?? exactParentReply.to_email,
14251
+ parentReply: exactParentReply
14252
+ };
14253
+ }
14254
+ const replyFrom = flags.from ?? await pickDefaultFromAddress(apiClient, authFailureContext);
14255
+ progress?.start(`Finding latest inbound email from ${args.recipient}`);
14256
+ const latestParentReply = await findLatestInboundFromRecipient({
14257
+ apiClient,
14258
+ authFailureContext,
14259
+ from: replyFrom,
14260
+ pageSize: flags["page-size"],
14261
+ recipient: args.recipient
14262
+ });
14263
+ if (!latestParentReply) {
14264
+ replyContextFailureMessage = "No prior inbound email found.";
14265
+ throw cliError$6(`No prior inbound email from ${args.recipient} to ${replyFrom}. Start a new chat with \`primitive chat ${args.recipient} <message>\`, pass --from, or pass --reply-to-email-id <inbound-email-id>.`);
14266
+ }
14267
+ replyContextFailureMessage = `Inbound email ${latestParentReply.id} does not match recipient ${args.recipient}.`;
14268
+ assertParentMatchesRecipient(latestParentReply, args.recipient);
14269
+ return {
14270
+ from: replyFrom,
14271
+ parentReply: latestParentReply
14272
+ };
14273
+ } catch (error) {
14274
+ progress?.fail(replyContextFailureMessage);
14275
+ throw error;
14276
+ }
14277
+ })();
14278
+ from = replyContext.from;
14279
+ parentReply = replyContext.parentReply;
14280
+ subject = derivedReplySubject(replyContext.parentReply);
14281
+ } else {
14282
+ from = flags.from ?? await pickDefaultFromAddress(apiClient, authFailureContext);
14283
+ subject = flags.subject ?? deriveSubject(message);
14284
+ }
13849
14285
  const sentAtIso = (/* @__PURE__ */ new Date()).toISOString();
13850
- const sendResult = await sendEmail({
14286
+ if (replyMode) progress?.update(`Sending reply to ${args.recipient}`);
14287
+ else progress?.start(`Sending message to ${args.recipient}`);
14288
+ const sendResult = parentReply !== void 0 ? await replyToEmail({
14289
+ body: {
14290
+ body_text: message,
14291
+ from
14292
+ },
14293
+ client: apiClient.client,
14294
+ path: { id: parentReply.id },
14295
+ responseStyle: "fields"
14296
+ }) : await sendEmail({
13851
14297
  body: {
13852
14298
  from,
13853
14299
  to: args.recipient,
@@ -13859,6 +14305,7 @@ var ChatCommand = class ChatCommand extends Command {
13859
14305
  responseStyle: "fields"
13860
14306
  });
13861
14307
  if (sendResult.error) {
14308
+ progress?.fail(replyMode ? "Reply send failed." : "Message send failed.");
13862
14309
  const errorPayload = extractErrorPayload(sendResult.error);
13863
14310
  writeErrorWithHints(errorPayload);
13864
14311
  surfaceUnauthorizedHint({
@@ -13869,39 +14316,78 @@ var ChatCommand = class ChatCommand extends Command {
13869
14316
  return;
13870
14317
  }
13871
14318
  const sent = sendResult.data?.data;
13872
- if (!sent) throw cliError$6("Send succeeded but the API returned no data.");
13873
- const reply = await waitForReply({
13874
- apiClient,
13875
- authFailureContext,
13876
- from,
13877
- interval: flags.interval,
13878
- pageSize: flags["page-size"],
14319
+ if (!sent) {
14320
+ progress?.fail("Send succeeded but the API returned no data.");
14321
+ throw cliError$6("Send succeeded but the API returned no data.");
14322
+ }
14323
+ const replyAddress = sent.from || from;
14324
+ progress?.update(`${replyMode ? "Reply" : "Message"} sent; waiting for reply from ${args.recipient}`, {
14325
+ heartbeatMs: 15e3,
14326
+ timeoutSeconds: flags.timeout
14327
+ });
14328
+ const baseContext = {
14329
+ from: replyAddress,
14330
+ json: flags.json,
14331
+ parentReply,
14332
+ quiet: flags.quiet,
13879
14333
  recipient: args.recipient,
14334
+ sent,
13880
14335
  sentAtIso,
13881
- sentId: sent.id,
13882
14336
  strictOnly: flags["strict-only"],
13883
14337
  strictPhaseSeconds: flags["strict-phase-seconds"],
14338
+ subject,
13884
14339
  timeoutSeconds: flags.timeout
13885
- });
13886
- if (reply === null) {
13887
- process.stderr.write(`Timed out after ${flags.timeout}s waiting for a reply from ${args.recipient}.\n`);
14340
+ };
14341
+ let replyResult;
14342
+ try {
14343
+ replyResult = await waitForReply({
14344
+ apiClient,
14345
+ authFailureContext,
14346
+ from: replyAddress,
14347
+ interval: flags.interval,
14348
+ notice: (message) => {
14349
+ if (progress) {
14350
+ progress.notice(message);
14351
+ return;
14352
+ }
14353
+ process.stderr.write(`${message}\n`);
14354
+ },
14355
+ pageSize: flags["page-size"],
14356
+ recipient: args.recipient,
14357
+ sentAtIso,
14358
+ sentId: sent.id,
14359
+ strictOnly: flags["strict-only"],
14360
+ strictPhaseSeconds: flags["strict-phase-seconds"],
14361
+ timeoutSeconds: flags.timeout
14362
+ });
14363
+ } catch (error) {
14364
+ progress?.fail("Reply polling failed.");
14365
+ process.stderr.write(`${formatChatRecoveryContext(baseContext)}\n`);
14366
+ throw error;
14367
+ }
14368
+ if (replyResult === null) {
14369
+ const timeoutMessage = `Timed out after ${flags.timeout}s waiting for a reply from ${args.recipient}.`;
14370
+ progress?.fail(timeoutMessage);
14371
+ if (progress === null) process.stderr.write(`${timeoutMessage}\n`);
14372
+ process.stderr.write(`${formatChatRecoveryContext(baseContext)}\n`);
13888
14373
  process.exitCode = 1;
13889
14374
  return;
13890
14375
  }
13891
- if (flags.json) {
13892
- const envelope = {
13893
- sent,
13894
- reply
13895
- };
13896
- this.log(JSON.stringify(envelope, null, 2));
13897
- } else {
13898
- const body = reply.body_text ?? reply.body_html ?? "";
13899
- this.log(body);
13900
- }
14376
+ progress?.succeed(`Reply received from ${replyResult.reply.from_email}`);
14377
+ const outputContext = {
14378
+ ...baseContext,
14379
+ matchStrategy: replyResult.matchStrategy,
14380
+ reply: replyResult.reply
14381
+ };
14382
+ if (flags.json) this.log(JSON.stringify(buildChatJsonEnvelope(outputContext), null, 2));
14383
+ else this.log(formatChatResponse(outputContext));
13901
14384
  });
13902
14385
  }
13903
14386
  };
13904
14387
  async function waitForReply(params) {
14388
+ const notice = params.notice ?? ((message) => {
14389
+ process.stderr.write(`${message}\n`);
14390
+ });
13905
14391
  const totalDeadline = params.timeoutSeconds === 0 ? null : Date.now() + params.timeoutSeconds * 1e3;
13906
14392
  const strictDeadlineFromBudget = Date.now() + params.strictPhaseSeconds * 1e3;
13907
14393
  const strictDeadline = params.strictOnly ? totalDeadline : totalDeadline === null ? strictDeadlineFromBudget : Math.min(strictDeadlineFromBudget, totalDeadline);
@@ -13970,11 +14456,14 @@ async function waitForReply(params) {
13970
14456
  const detail = envelope?.data ?? envelope ?? null;
13971
14457
  if (!detail) throw new Errors.CLIError(`Reply landed but the email body could not be loaded (id=${match.id}).`, { exit: 1 });
13972
14458
  if (phase.label === "strict" && detail.reply_to_sent_email_id !== params.sentId) {
13973
- if (!strictFilterUnsupported) process.stderr.write(params.strictOnly ? "Strict-phase reply matching is not supported by this Primitive API host; --strict-only requires server support so the command will exit without a match.\n" : "Strict-phase reply matching is not supported by this Primitive API host; falling back to time-window matching.\n");
14459
+ if (!strictFilterUnsupported) notice(params.strictOnly ? "Strict-phase reply matching is not supported by this Primitive API host; --strict-only requires server support so the command will exit without a match." : "Strict-phase reply matching is not supported by this Primitive API host; falling back to time-window matching.");
13974
14460
  strictFilterUnsupported = true;
13975
14461
  continue;
13976
14462
  }
13977
- return detail;
14463
+ return {
14464
+ reply: detail,
14465
+ matchStrategy: phase.label
14466
+ };
13978
14467
  }
13979
14468
  if (strictFilterUnsupported && phase.label === "strict") break;
13980
14469
  if (lastAccepted !== void 0) continue;
@@ -13996,6 +14485,33 @@ function redactConfig(config) {
13996
14485
  environments: Object.fromEntries(Object.entries(config.environments).map(([name, environment]) => [name, redactCliEnvironment(environment)]))
13997
14486
  };
13998
14487
  }
14488
+ function switchCliEnvironment(configDir, environmentName) {
14489
+ const environment = normalizeCliEnvironmentName(environmentName);
14490
+ const config = loadOrCreateConfig(configDir);
14491
+ if (!config.environments[environment]) throw new Errors.CLIError(`Primitive CLI environment ${environment} is not configured.`, { exit: 1 });
14492
+ const previousEnvironment = resolveConfigEnvironment(config)?.name ?? null;
14493
+ const nextConfig = {
14494
+ ...config,
14495
+ current_environment: environment
14496
+ };
14497
+ const shouldClearCredentials = previousEnvironment !== environment;
14498
+ let removedCredentials = false;
14499
+ if (shouldClearCredentials) {
14500
+ const releaseLock = acquireCliCredentialsLock(configDir);
14501
+ try {
14502
+ saveCliConfig(configDir, nextConfig);
14503
+ removedCredentials = existsSync(credentialsPath(configDir));
14504
+ deleteCliCredentials(configDir);
14505
+ } finally {
14506
+ releaseLock();
14507
+ }
14508
+ } else saveCliConfig(configDir, nextConfig);
14509
+ return {
14510
+ environment,
14511
+ previousEnvironment,
14512
+ removedCredentials
14513
+ };
14514
+ }
13999
14515
  var ConfigSetCommand = class ConfigSetCommand extends Command {
14000
14516
  static summary = "Set a Primitive CLI request environment";
14001
14517
  static flags = {
@@ -14032,20 +14548,16 @@ var ConfigSetCommand = class ConfigSetCommand extends Command {
14032
14548
  };
14033
14549
  var ConfigUseCommand = class ConfigUseCommand extends Command {
14034
14550
  static summary = "Switch the active Primitive CLI request environment";
14551
+ static description = "Switch the active Primitive CLI request environment. When this switches to a different environment, the CLI removes saved OAuth credentials so the next authenticated command signs in against the newly active API host.";
14035
14552
  static args = { environment: Args.string({
14036
14553
  description: "Environment name to use",
14037
14554
  required: true
14038
14555
  }) };
14039
14556
  async run() {
14040
14557
  const { args } = await this.parse(ConfigUseCommand);
14041
- const environment = normalizeCliEnvironmentName(args.environment);
14042
- const config = loadOrCreateConfig(this.config.configDir);
14043
- if (!config.environments[environment]) throw new Errors.CLIError(`Primitive CLI environment ${environment} is not configured.`, { exit: 1 });
14044
- saveCliConfig(this.config.configDir, {
14045
- ...config,
14046
- current_environment: environment
14047
- });
14558
+ const { environment, removedCredentials } = switchCliEnvironment(this.config.configDir, args.environment);
14048
14559
  process.stderr.write(`Primitive CLI environment ${environment} is active.\n`);
14560
+ if (removedCredentials) process.stderr.write("Removed saved Primitive CLI credentials. Run `primitive signin` to authenticate in the active environment.\n");
14049
14561
  }
14050
14562
  };
14051
14563
  var ConfigListCommand = class ConfigListCommand extends Command {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitivedotdev/cli",
3
- "version": "0.31.5",
3
+ "version": "0.31.7",
4
4
  "description": "Official Primitive CLI: deploy Primitive Functions, send and inspect mail, manage endpoints, all from the terminal. Wraps the @primitivedotdev/sdk runtime client with one-shot commands.",
5
5
  "type": "module",
6
6
  "sideEffects": false,