@bli-cockpit/cli 0.2.165 → 0.2.166

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.
@@ -14,9 +14,14 @@
14
14
  * captured by this very product's own session harvester. That is the
15
15
  * sourcing-secrets verdict applied to the one credential this product
16
16
  * stores.
17
- * - **The message body.** `cockpit mail send` takes it from stdin or
18
- * `--file`, the same discipline `cockpit issue comment` and `cockpit docs
19
- * update` keep.
17
+ * - **The message body.** `cockpit mail send` and `cockpit mail draft` take it
18
+ * from stdin or `--file`, the same discipline `cockpit issue comment` and
19
+ * `cockpit docs update` keep.
20
+ *
21
+ * `draft` (BLI-12530) takes `send`'s flags exactly — `--account`, `--to`,
22
+ * `--cc`, `--subject`, `--reply-to`, `--file` — and is refused the same two
23
+ * ways when `--account` or `--to` is missing: which mailbox a message is saved
24
+ * in is as much the caller's to say as which one it goes out from.
20
25
  */
21
26
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
22
27
  const MAIL_ACTIONS = new Set([
@@ -27,10 +32,13 @@ const MAIL_ACTIONS = new Set([
27
32
  "read",
28
33
  "search",
29
34
  "send",
35
+ "draft",
30
36
  "attachment",
31
37
  "sync",
32
38
  "sync-status",
33
39
  ]);
40
+ /** The two verbs that put a message in a mailbox: one sends it, one saves it unsent. */
41
+ const MAIL_ACTIONS_COMPOSING = new Set(["send", "draft"]);
34
42
  /** Every verb that names ONE thing first: a thread, an attachment, a mailbox. */
35
43
  const MAIL_ACTIONS_NEEDING_A_SUBJECT = new Set(["read", "attachment", "detach", "sync"]);
36
44
  /** `sync-status [<account id>]` — every mailbox, or one's. Ticket 10730. */
@@ -85,7 +93,7 @@ function readMailAction(values) {
85
93
  const first = values.positionals[0];
86
94
  const action = (first === undefined ? "inbox" : first);
87
95
  if (!MAIL_ACTIONS.has(action)) {
88
- throw new Error(`Unknown mail command: ${first}. Try accounts, add-imap, detach, inbox, read, search, send, attachment, sync, or sync-status.`);
96
+ throw new Error(`Unknown mail command: ${first}. Try accounts, add-imap, detach, inbox, read, search, send, draft, attachment, sync, or sync-status.`);
89
97
  }
90
98
  return { action, rest: values.positionals.slice(first === undefined ? 0 : 1) };
91
99
  }
@@ -127,11 +135,13 @@ function readImapAddress(action, values) {
127
135
  function readMailRecipients(action, values) {
128
136
  const to = splitAddresses(values.flags.get("--to"));
129
137
  const cc = splitAddresses(values.flags.get("--cc"));
130
- if (action === "send" && to.length === 0) {
131
- throw new Error('mail send needs --to "someone@example.com" (comma-separated for several).');
138
+ if (MAIL_ACTIONS_COMPOSING.has(action) && to.length === 0) {
139
+ throw new Error(`mail ${action} needs --to "someone@example.com" (comma-separated for several).`);
132
140
  }
133
- if (action === "send" && !optionalNonEmpty(values.flags.get("--account"))) {
134
- throw new Error("mail send needs --account <id>: with several mailboxes attached, which address this goes out from is yours to say. `cockpit mail accounts` lists them.");
141
+ if (MAIL_ACTIONS_COMPOSING.has(action) && !optionalNonEmpty(values.flags.get("--account"))) {
142
+ throw new Error(action === "draft"
143
+ ? "mail draft needs --account <id>: with several mailboxes attached, which one the draft is saved in is yours to say. `cockpit mail accounts` lists them."
144
+ : "mail send needs --account <id>: with several mailboxes attached, which address this goes out from is yours to say. `cockpit mail accounts` lists them.");
135
145
  }
136
146
  return { to, cc };
137
147
  }
@@ -122,7 +122,7 @@ export const TOWER_COMMAND_HELP = [
122
122
  [
123
123
  "mail",
124
124
  [
125
- "Usage: cockpit mail [accounts|add-imap|inbox|read <thread>|search \"<words>\"|send|attachment <id>|sync <account>|sync-status [<account>]|detach <account>] [flags]",
125
+ "Usage: cockpit mail [accounts|add-imap|inbox|read <thread>|search \"<words>\"|send|draft|attachment <id>|sync <account>|sync-status [<account>]|detach <account>] [flags]",
126
126
  "",
127
127
  "Every mailbox you attached, in one place. One person, several addresses — nothing here has a \"current\" mailbox.",
128
128
  "accounts — every mailbox, its provider (gmail_oauth or imap), its health and when it last synced. The ids other verbs take are on the last line.",
@@ -134,12 +134,13 @@ export const TOWER_COMMAND_HELP = [
134
134
  "read <thread> — one conversation with its bodies. Thread ids come from `cockpit mail inbox` or `cockpit mail search`, in either --json or plain text.",
135
135
  "search \"<words>\" [--account <id>] [--limit <n>] — full text over subject and body. Quotes and OR work the way they do in a search box. Every row (text or --json) carries the thread id.",
136
136
  "send --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] — the BODY comes from --file or stdin. --account is required: which address this goes out from is yours to say.",
137
+ "draft --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] — saves the message as a DRAFT in that mailbox for a person to read, edit and send themselves. NOTHING IS SENT and nobody is notified, so there is no confirmation step. Same flags and same body rule as send (--file or stdin). Only the mailbox's owner can draft into it. --reply-to threads the draft into that conversation the way send does; Gmail files it there only when the subject matches the original's, and the answer says whether it did. Gmail mailboxes only: an IMAP mailbox is refused as imap_drafts_unsupported. --json answers { ok, draft: { account_id, address, provider_draft_id, provider_thread_id, threaded } } (BLI-12530).",
137
138
  "attachment <id> --out <path> — downloads one attachment to a file. Attachments are pointers until you ask; nothing is stored in Tower.",
138
139
  "sync <account> — reads that mailbox now instead of waiting for the cron. Prints what landed, or the reason it did not.",
139
140
  "sync-status [<account>] — every mailbox's health, or one's: last successful sync, the last pass's own reason, how many messages it carried, failures in the last 24h, and the lag between a message's own clock and when Tower stored it (ticket 10730).",
140
141
  "detach <account> — removes the mailbox, its stored mail and its credential.",
141
142
  "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr. Shapes differ by verb: inbox/read answer { messages: [...] }, search answers { matches: [...] } — same MessageRow shape either name, always carrying thread_id.",
142
- "A refusal keeps Tower's own reason label — needs_rls_client, account_not_found_or_unreadable, account_not_active, google_oauth_not_configured, credential_unavailable, and so on.",
143
+ "A refusal keeps Tower's own reason label — needs_rls_client, account_not_found_or_unreadable, account_not_active, account_not_yours, google_oauth_not_configured, credential_unavailable, and so on.",
143
144
  "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
144
145
  ],
145
146
  ],
@@ -124,7 +124,7 @@ export function localCommandHelp(command) {
124
124
  " cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>|delete <id|slug>] [--parent <id>|root|--clear-parent] [--query <text> [--word]] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
125
125
  " cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--dashboard-url <url>] [--json]",
126
126
  " cockpit issue [list|show <BLI-id>|create --title <t>|update <BLI-id>|move <BLI-id> <state>|comment <BLI-id>|history <BLI-id>] [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] [--priority 0-4] [--size S|M|L] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
127
- " cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--dashboard-url <url>] [--json]",
127
+ " cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|draft --account <id> --to <a> --subject <s> [--reply-to <message id>]|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--dashboard-url <url>] [--json]",
128
128
  " cockpit cal [today|week|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
129
129
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
130
130
  " cockpit github <pr|merge|retitle|rerun> <owner/repo> <n> [--claim <state>] [--method squash|merge|rebase] [--title \"<text>\"] [--run <id>] [--confirm <code>] [--dashboard-url <url>] [--json]",
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The compose half of `cockpit mail` (BLI-12530): `draft`, and the two things
3
+ * it shares with `send` — where a message body comes from, and the JSON both
4
+ * verbs post.
5
+ *
6
+ * `cockpit mail draft` saves a message as a DRAFT in one of your own mailboxes
7
+ * (`POST /api/mail/drafts`) so a person can read it, edit it and press send
8
+ * themselves. It is the verb for an agent that prepares mail on somebody's
9
+ * behalf: nothing is sent, nobody is notified, and so — unlike `send` — there
10
+ * is nothing to confirm.
11
+ *
12
+ * Kept beside `mail.ts` rather than in it because `mail.ts` sits at the edge of
13
+ * the readability ceiling, and because the two verbs genuinely share one shape:
14
+ * the request body is built once here, so a field added to `send` cannot be
15
+ * forgotten by `draft` (both doors accept exactly the same fields).
16
+ *
17
+ * THE BODY NEVER TOUCHES ARGV. It comes from `--file` or stdin, the discipline
18
+ * `send`, `issue comment` and `docs update` already keep — argv is world-
19
+ * readable on a shared machine and is captured by this product's own session
20
+ * harvester.
21
+ */
22
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, } from "./agent-door.js";
23
+ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
24
+ import { readNoteFile } from "./notes-file.js";
25
+ // One spelling per surface: the same tag `mail.ts` uses.
26
+ const TAG = "[mail cli]";
27
+ const WRITE_DEADLINE_MS = 60_000;
28
+ const BODY_MAX_CHARS = 200_000;
29
+ /** Body on stdin, never argv — `--file` is the safest way on Windows. */
30
+ export async function readMailBody(command, door) {
31
+ if (command.filePath) {
32
+ const read = await readNoteFile(command.filePath);
33
+ if (!read.ok) {
34
+ return { ok: false, reason: read.refusal, detail: `Could not read ${command.filePath}: ${read.detail}` };
35
+ }
36
+ return { ok: true, text: read.bytes.toString("utf8") };
37
+ }
38
+ if (isInteractiveStdin(door.io))
39
+ return { ok: true, text: "" };
40
+ try {
41
+ const text = await readPipedText(door.io.stdin, {
42
+ maxChars: BODY_MAX_CHARS,
43
+ overflowMessage: `A message body is limited to ${BODY_MAX_CHARS} characters here.`,
44
+ });
45
+ return { ok: true, text };
46
+ }
47
+ catch (error) {
48
+ return { ok: false, reason: "body_too_long", detail: error instanceof Error ? error.message : String(error) };
49
+ }
50
+ }
51
+ /**
52
+ * The JSON that `send` and `draft` post. One shape on purpose: the two doors
53
+ * take the same fields, and a message that can be sent can be saved unsent.
54
+ */
55
+ export function composedMessageBody(command, text) {
56
+ return {
57
+ account_id: command.accountId,
58
+ to: command.to.map((address) => ({ address })),
59
+ ...(command.cc.length > 0 ? { cc: command.cc.map((address) => ({ address })) } : {}),
60
+ subject: command.subjectLine ?? "",
61
+ text,
62
+ ...(command.replyTo ? { in_reply_to_message_id: command.replyTo } : {}),
63
+ };
64
+ }
65
+ /** `draft --account <id> --to <a[,b]> [--cc <c>] --subject "<s>" [--reply-to <message id>] [--file <path>]` */
66
+ export async function runMailDraft(command, door) {
67
+ const body = await readMailBody(command, door);
68
+ if (!body.ok)
69
+ return failAgentDoor(door, TAG, body.reason, body.detail);
70
+ if (body.text.trim() === "") {
71
+ return failAgentDoor(door, TAG, "body_required", 'A draft needs a body: `echo "Tuesday works for me." | cockpit mail draft --account <id> --to someone@example.com --subject "Re: lunch"`.');
72
+ }
73
+ const answer = await askAgentDoor(door, {
74
+ path: "/api/mail/drafts",
75
+ method: "POST",
76
+ label: "mail draft",
77
+ timeoutMs: WRITE_DEADLINE_MS,
78
+ body: composedMessageBody(command, body.text),
79
+ });
80
+ if (!answer.ok)
81
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
82
+ const draft = answer.body.draft;
83
+ if (door.json)
84
+ return emitAgentDoor(door, { ok: true, draft });
85
+ writeLine(door.io.stdout, `Saved a draft in ${draft?.address ?? "that mailbox"} for ${command.to.join(", ")}. `
86
+ + "Nothing was sent: it is in Drafts for a person to read, edit and send.");
87
+ if (command.replyTo) {
88
+ // `threaded` is what Gmail answered, not what was asked: a reply is only
89
+ // filed in its conversation when the subject still matches the original's.
90
+ writeLine(door.io.stdout, draft?.threaded
91
+ ? "It sits in the reply's conversation."
92
+ : "Gmail did not file it in that conversation, so it is a new one. A reply joins its thread only when the subject matches the original's.");
93
+ }
94
+ writeLine(door.io.stdout, `draft ${draft?.provider_draft_id ?? "(no id returned)"} thread ${draft?.provider_thread_id ?? "(none)"} account ${draft?.account_id ?? command.accountId}`);
95
+ return 0;
96
+ }
@@ -2,10 +2,12 @@
2
2
  * `cockpit mail` — the mailboxes a person attached, from a terminal
3
3
  * (BLI-3708).
4
4
  *
5
- * Nine verbs over `/api/mail/**`, the same doors the browser will use when the
5
+ * Ten verbs over `/api/mail/**`, the same doors the browser will use when the
6
6
  * UI lands. Every one of those routes already accepts the collector device
7
7
  * token (`resolveCaller({ allowDeviceToken: true })`), so this command is a
8
8
  * terminal in front of an existing door and adds no authority of its own.
9
+ * `draft` (BLI-12530) lives in `mail-compose.ts` beside the body reader it
10
+ * shares with `send`.
9
11
  *
10
12
  * The CLI is the FIRST surface for this wave, deliberately (AGENTS.md, "CLI is
11
13
  * king"): a mailbox is easier to reason about as `--json` than as a rendered
@@ -23,7 +25,7 @@
23
25
  import { writeFile } from "node:fs/promises";
24
26
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
25
27
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
26
- import { readNoteFile } from "./notes-file.js";
28
+ import { composedMessageBody, readMailBody, runMailDraft } from "./mail-compose.js";
27
29
  import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
28
30
  import { towerRequest } from "../tower-client.js";
29
31
  const TAG = "[mail cli]";
@@ -31,7 +33,6 @@ const READ_DEADLINE_MS = 30_000;
31
33
  const WRITE_DEADLINE_MS = 60_000;
32
34
  /** A sync pass reads a bounded page, but a cold mailbox can still take a while. */
33
35
  const SYNC_DEADLINE_MS = 120_000;
34
- const BODY_MAX_CHARS = 200_000;
35
36
  const PASSWORD_MAX_CHARS = 200;
36
37
  export async function runMail(command, io) {
37
38
  const door = await openAgentDoor("mail", command, io);
@@ -50,6 +51,8 @@ export async function runMail(command, io) {
50
51
  return search(command, door);
51
52
  case "send":
52
53
  return send(command, door);
54
+ case "draft":
55
+ return runMailDraft(command, door);
53
56
  case "attachment":
54
57
  return attachment(command, door);
55
58
  case "sync":
@@ -250,7 +253,7 @@ async function search(command, door) {
250
253
  return 0;
251
254
  }
252
255
  async function send(command, door) {
253
- const body = await readBody(command, door);
256
+ const body = await readMailBody(command, door);
254
257
  if (!body.ok)
255
258
  return failAgentDoor(door, TAG, body.reason, body.detail);
256
259
  if (body.text.trim() === "") {
@@ -261,14 +264,7 @@ async function send(command, door) {
261
264
  method: "POST",
262
265
  label: "mail send",
263
266
  timeoutMs: WRITE_DEADLINE_MS,
264
- body: {
265
- account_id: command.accountId,
266
- to: command.to.map((address) => ({ address })),
267
- ...(command.cc.length > 0 ? { cc: command.cc.map((address) => ({ address })) } : {}),
268
- subject: command.subjectLine ?? "",
269
- text: body.text,
270
- ...(command.replyTo ? { in_reply_to_message_id: command.replyTo } : {}),
271
- },
267
+ body: composedMessageBody(command, body.text),
272
268
  });
273
269
  if (!answer.ok)
274
270
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
@@ -361,26 +357,4 @@ async function syncStatus(command, door) {
361
357
  writeLine(door.io.stdout, `${account.address} last_success=${account.last_success_at ?? "never"} last_run=${lastRun} failures_24h=${account.failures_24h} lag=${lag}${account.data_incomplete ? " (partial read)" : ""}`);
362
358
  }
363
359
  return 0;
364
- }
365
- /** Body on stdin, never argv — `--file` is the safest way on Windows. */
366
- async function readBody(command, door) {
367
- if (command.filePath) {
368
- const read = await readNoteFile(command.filePath);
369
- if (!read.ok) {
370
- return { ok: false, reason: read.refusal, detail: `Could not read ${command.filePath}: ${read.detail}` };
371
- }
372
- return { ok: true, text: read.bytes.toString("utf8") };
373
- }
374
- if (isInteractiveStdin(door.io))
375
- return { ok: true, text: "" };
376
- try {
377
- const text = await readPipedText(door.io.stdin, {
378
- maxChars: BODY_MAX_CHARS,
379
- overflowMessage: `A message body is limited to ${BODY_MAX_CHARS} characters here.`,
380
- });
381
- return { ok: true, text };
382
- }
383
- catch (error) {
384
- return { ok: false, reason: "body_too_long", detail: error instanceof Error ? error.message : String(error) };
385
- }
386
360
  }
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.165");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.166");
25
25
  return 0;
26
26
  }
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.165",
3
+ "version": "0.2.166",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.45",
31
- "@bli-cockpit/mcp": "0.1.83",
31
+ "@bli-cockpit/mcp": "0.1.84",
32
32
  "@bli-cockpit/telemetry-core": "0.1.57"
33
33
  },
34
34
  "repository": {