@bli-cockpit/cli 0.2.61 → 0.2.63

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.
@@ -207,7 +207,15 @@ export async function sendOneTurn(context, prompt, io) {
207
207
  trace_failed: trace?.filter((step) => step.status === "failed").length ?? 0,
208
208
  model_requested: context.command.model ?? null,
209
209
  elapsed_ms: Date.now() - startedAt,
210
- thread: context.command.thread === "main" ? "default" : "named",
210
+ // BLI-3786: `minted` is a conversation the DOOR opened for this call
211
+ // because nothing named one — read off the answer rather than guessed
212
+ // from the name, so this side keeps no copy of the server's rule. Never
213
+ // the thread name itself.
214
+ thread: body.thread && body.thread !== context.command.thread
215
+ ? "minted"
216
+ : context.command.thread === "main"
217
+ ? "default"
218
+ : "named",
211
219
  subject: context.command.subject ? "selected" : "caller",
212
220
  image_attached: attachment !== null,
213
221
  image_byte_size: attachment?.bytes.byteLength ?? null,
@@ -289,21 +297,28 @@ export async function readHistory(context, io) {
289
297
  writeThreadHistory(io, body);
290
298
  }
291
299
  else {
292
- writeThreadList(io, body.threads ?? []);
300
+ writeThreadList(io, body.threads ?? [], body.agentThreads ?? 0);
293
301
  }
294
302
  writeLine(io.stderr, `[jarvis cli] history read ${JSON.stringify({
295
303
  mode: command.history ? "thread" : "threads",
296
304
  thread_count: body.threads?.length ?? null,
305
+ // BLI-3786: the per-call conversations a `--json` turn minted, folded
306
+ // out of the list by the door and counted here.
307
+ agent_thread_count: body.agentThreads ?? null,
297
308
  message_count: body.messages?.length ?? null,
298
309
  truncated: body.truncated ?? null,
299
310
  })}`);
300
311
  return 0;
301
312
  }
302
- function writeThreadList(io, threads) {
313
+ function writeThreadList(io, threads, agentThreads) {
303
314
  if (threads.length === 0) {
304
315
  // A real answer, not a blank. Nothing here means nothing was ever asked
305
- // from this terminal, which is worth saying rather than implying.
306
- writeLine(io.stdout, "No terminal conversations yet. Ask something with `cockpit jarvis`.");
316
+ // from this terminal, which is worth saying rather than implying — and if
317
+ // the only conversations on this account were a program's, say THAT
318
+ // instead of "nothing was ever asked" (BLI-3786).
319
+ writeLine(io.stdout, agentThreads > 0
320
+ ? `No conversations you named yet — ${agentThreads} one-off ${agentThreads === 1 ? "conversation" : "conversations"} from \`--json\` turns are not listed. Ask something with \`cockpit jarvis\`.`
321
+ : "No terminal conversations yet. Ask something with `cockpit jarvis`.");
307
322
  return;
308
323
  }
309
324
  const styled = colorEnabled(io);
@@ -314,6 +329,10 @@ function writeThreadList(io, threads) {
314
329
  writeLine(io.stdout, dim(` ${thread.preview}`, styled));
315
330
  }
316
331
  writeLine(io.stdout, "");
332
+ if (agentThreads > 0) {
333
+ // BLI-3786: never hidden without a number. Each one still replays by name.
334
+ writeLine(io.stdout, dim(`${agentThreads} one-off ${agentThreads === 1 ? "conversation" : "conversations"} from \`--json\` turns are not listed.`, styled));
335
+ }
317
336
  writeLine(io.stdout, dim("Replay one with `cockpit jarvis --thread <name> --history`.", styled));
318
337
  }
319
338
  function writeThreadHistory(io, body) {
@@ -0,0 +1,151 @@
1
+ /**
2
+ * `cockpit mail` argument parsing (BLI-3708) — the mail surface's half of
3
+ * `local-args-tower.ts`, a sibling of `local-args-tower-work.ts` for the same
4
+ * reason that file is one: mail is its own noun with its own vocabulary (a
5
+ * mailbox, a thread, an attachment, a recipient), and folding it into another
6
+ * family's parser would stretch that family's doc comment past the truth.
7
+ *
8
+ * TWO SECRETS NEVER TRAVEL ON ARGV, and both are refused here rather than at
9
+ * the door:
10
+ *
11
+ * - **The app password.** `cockpit mail add-imap --address <a>` reads it from
12
+ * STDIN, always. There is no `--password` flag to forget about: argv is
13
+ * world-readable on a shared machine (`ps`), lands in shell history, and is
14
+ * captured by this very product's own session harvester. That is the
15
+ * sourcing-secrets verdict applied to the one credential this product
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.
20
+ */
21
+ import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
22
+ const MAIL_ACTIONS = new Set([
23
+ "accounts",
24
+ "add-imap",
25
+ "detach",
26
+ "inbox",
27
+ "read",
28
+ "search",
29
+ "send",
30
+ "attachment",
31
+ "sync",
32
+ ]);
33
+ /** Every verb that names ONE thing first: a thread, an attachment, a mailbox. */
34
+ const MAIL_ACTIONS_NEEDING_A_SUBJECT = new Set(["read", "attachment", "detach", "sync"]);
35
+ export function parseMailArgs(args) {
36
+ const values = parseNamedArgs(args, {
37
+ allowedFlags: [
38
+ "--home",
39
+ "--dashboard-url",
40
+ "--account",
41
+ "--address",
42
+ "--name",
43
+ "--limit",
44
+ "--before",
45
+ "--unread",
46
+ "--label",
47
+ "--to",
48
+ "--cc",
49
+ "--subject",
50
+ "--reply-to",
51
+ "--file",
52
+ "--body-stdin",
53
+ "--out",
54
+ "--json",
55
+ ],
56
+ valueFlags: [
57
+ "--home",
58
+ "--dashboard-url",
59
+ "--account",
60
+ "--address",
61
+ "--name",
62
+ "--limit",
63
+ "--before",
64
+ "--label",
65
+ "--to",
66
+ "--cc",
67
+ "--subject",
68
+ "--reply-to",
69
+ "--file",
70
+ "--out",
71
+ ],
72
+ });
73
+ const first = values.positionals[0];
74
+ const action = (first === undefined ? "inbox" : first);
75
+ if (!MAIL_ACTIONS.has(action)) {
76
+ throw new Error(`Unknown mail command: ${first}. Try accounts, add-imap, detach, inbox, read, search, send, attachment, or sync.`);
77
+ }
78
+ const rest = values.positionals.slice(first === undefined ? 0 : 1);
79
+ let subject;
80
+ let query;
81
+ if (MAIL_ACTIONS_NEEDING_A_SUBJECT.has(action)) {
82
+ subject = optionalNonEmpty(rest[0]);
83
+ if (!subject) {
84
+ throw new Error(action === "read"
85
+ ? "mail read needs a thread id — take one from `cockpit mail inbox --json`."
86
+ : `mail ${action} needs an id.`);
87
+ }
88
+ if (rest.length > 1)
89
+ throw new Error(`mail ${action} takes one id, not ${rest.length}.`);
90
+ }
91
+ else if (action === "search") {
92
+ query = optionalNonEmpty(rest.join(" "));
93
+ if (!query)
94
+ throw new Error('mail search needs words: cockpit mail search "invoice from stripe".');
95
+ }
96
+ else if (rest.length > 0) {
97
+ throw new Error(`mail ${action} does not take "${rest[0]}".`);
98
+ }
99
+ const address = optionalNonEmpty(values.flags.get("--address"));
100
+ if (action === "add-imap" && !address) {
101
+ throw new Error("mail add-imap needs --address, and reads the app password from stdin.");
102
+ }
103
+ const to = splitAddresses(values.flags.get("--to"));
104
+ const cc = splitAddresses(values.flags.get("--cc"));
105
+ if (action === "send" && to.length === 0) {
106
+ throw new Error('mail send needs --to "someone@example.com" (comma-separated for several).');
107
+ }
108
+ if (action === "send" && !optionalNonEmpty(values.flags.get("--account"))) {
109
+ 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.");
110
+ }
111
+ const outPath = optionalNonEmpty(values.flags.get("--out"));
112
+ if (action === "attachment" && !outPath) {
113
+ throw new Error("mail attachment needs --out <path>: the file is written to disk, never to stdout.");
114
+ }
115
+ const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
116
+ if (limit !== undefined && action !== "inbox" && action !== "search") {
117
+ throw new Error("--limit belongs to `cockpit mail inbox` or `cockpit mail search`.");
118
+ }
119
+ return {
120
+ kind: "mail",
121
+ action,
122
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
123
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
124
+ ...(subject ? { subjectId: subject } : {}),
125
+ ...(query ? { query } : {}),
126
+ accountId: optionalNonEmpty(values.flags.get("--account")),
127
+ address,
128
+ displayName: optionalNonEmpty(values.flags.get("--name")),
129
+ ...(limit === undefined ? {} : { limit }),
130
+ before: optionalNonEmpty(values.flags.get("--before")),
131
+ unreadOnly: values.booleans.has("--unread"),
132
+ label: optionalNonEmpty(values.flags.get("--label")),
133
+ to,
134
+ cc,
135
+ subjectLine: optionalNonEmpty(values.flags.get("--subject")),
136
+ replyTo: optionalNonEmpty(values.flags.get("--reply-to")),
137
+ filePath: optionalNonEmpty(values.flags.get("--file")),
138
+ bodyStdin: values.booleans.has("--body-stdin"),
139
+ outPath,
140
+ json: values.booleans.has("--json"),
141
+ };
142
+ }
143
+ /** `a@x.com, b@y.com` → two recipients. Exact, never fuzzy; empties dropped. */
144
+ function splitAddresses(raw) {
145
+ if (!raw)
146
+ return [];
147
+ return raw
148
+ .split(",")
149
+ .map((value) => value.trim())
150
+ .filter((value) => value !== "");
151
+ }
@@ -20,6 +20,7 @@
20
20
  * channels/messages (BLI-3706)
21
21
  * local-args-tower-work.ts issue, project — the issue tracker
22
22
  * (BLI-3716)
23
+ * local-args-tower-mail.ts mail — the mailboxes (BLI-3708)
23
24
  * local-args-tower-search.ts search — one bar over all five
24
25
  * corpora (BLI-3728)
25
26
  *
@@ -30,4 +31,5 @@ export { parseBriefArgs, WORKBOOK_MIN_WIDTH, parseWorkbookArgs, parseNotesArgs,
30
31
  export { SCOUT_MIN_PREFIX_LENGTH, parseScoutArgs, parseOpsArgs, SLACK_WORKSPACE_KEYS, parseSlackArgs, parseSettingsArgs, parseTeamArgs, parseModelArgs, } from "./local-args-tower-admin.js";
31
32
  export { parseDocsArgs, parseMsgArgs, } from "./local-args-tower-docs-msg.js";
32
33
  export { ISSUE_STATES, parseIssueArgs, parseProjectArgs, } from "./local-args-tower-work.js";
33
- export { SEARCH_KINDS, parseSearchArgs } from "./local-args-tower-search.js";
34
+ export { SEARCH_KINDS, parseSearchArgs } from "./local-args-tower-search.js";
35
+ export { parseMailArgs } from "./local-args-tower-mail.js";
@@ -16,7 +16,7 @@
16
16
  * verbatim, no logic change.
17
17
  */
18
18
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
19
- import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
19
+ import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
20
20
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
21
21
  // `local-auth.ts` import it from here — so it stays exported from this address
22
22
  // even though it now lives next door. The same goes for the four names the
@@ -103,6 +103,8 @@ export function parseLocalArgs(argv) {
103
103
  return parseMsgArgs(argv.slice(1));
104
104
  case "issue":
105
105
  return parseIssueArgs(argv.slice(1));
106
+ case "mail":
107
+ return parseMailArgs(argv.slice(1));
106
108
  case "project":
107
109
  return parseProjectArgs(argv.slice(1));
108
110
  case "search":
@@ -253,7 +253,8 @@ export function localSubcommandHelp(command) {
253
253
  "That object carries answer, sources (the Source: lines inside it), turn_id (pass it to --trace), thread_id (pass it to --thread), proposal_id (the coding-arm proposal this turn made, or null), degraded and degraded_reasons — the SAME shape the bli-tower MCP server's jarvis_ask tool returns, so a script and an agent read one contract. reply/thread/traceId/model/trace/latency are still there beside it and still mean what they always meant.",
254
254
  "A turn that outlives the dashboard's own ceiling is reported as turn_timed_out — an incomplete answer is never printed as a finished one.",
255
255
  "The command uses the existing paired device identity. It cannot override the caller, team, role, or person scope.",
256
- "--threads lists the terminal conversations on this account; --thread <name> --history replays one (the newest --limit <n> messages). Neither takes a question, and neither can reach anybody else's conversations.",
256
+ "A thread is SINGLE-WRITER: two turns in the same conversation at the same time can read each other, and one can answer the other's question. Your own terminal is one conversation, named main, and stays that way. A --json turn is a program's, so it gets a FRESH conversation per call unless --thread names one, and the answer's thread_id is the name it used — pass that back with --thread to continue it deliberately. On a --json turn, --thread main asks for the shared default nobody chose, so it is treated as naming nothing and a fresh conversation is minted anyway.",
257
+ "--threads lists the terminal conversations on this account; --thread <name> --history replays one (the newest --limit <n> messages). The per-call conversations a --json turn minted are counted rather than listed, and any one of them still replays by name. Neither takes a question, and neither can reach anybody else's conversations.",
257
258
  "--trace <id|last> prints the step tree of one turn — every model step, tool call and server-side memory step, with how long each took, which model ran it, the tokens it spent and what failed. `last` opens the turn this machine asked most recently. Like --threads it takes no question and reaches no model.",
258
259
  "Run `cockpit login` first if this machine is not paired.",
259
260
  ],
@@ -595,6 +596,29 @@ export function localSubcommandHelp(command) {
595
596
  "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
596
597
  ],
597
598
  ],
599
+ [
600
+ "mail",
601
+ [
602
+ "Usage: cockpit mail [accounts|add-imap|inbox|read <thread>|search \"<words>\"|send|attachment <id>|sync <account>|detach <account>] [flags]",
603
+ "",
604
+ "Every mailbox you attached, in one place. One person, several addresses — nothing here has a \"current\" mailbox.",
605
+ "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.",
606
+ "add-imap --address <you@gmail.com> [--name \"<display name>\"] — attaches a personal Google account over IMAP + SMTP. The APP PASSWORD is read from stdin and there is no flag for it:",
607
+ " macOS: printf \"%s\" \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
608
+ " PowerShell: \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
609
+ " Make one at myaccount.google.com/apppasswords (2-Step Verification has to be on). A work @buildlaunchiterate.ca address connects in the browser instead.",
610
+ "inbox [--account <id>] [--unread] [--label <l>] [--limit <n>] [--before <iso>] — everything across every mailbox, newest first. * is unread, @ has an attachment.",
611
+ "read <thread> — one conversation with its bodies. Thread ids come from `cockpit mail inbox --json`.",
612
+ "search \"<words>\" [--account <id>] [--limit <n>] — full text over subject and body. Quotes and OR work the way they do in a search box.",
613
+ "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.",
614
+ "attachment <id> --out <path> — downloads one attachment to a file. Attachments are pointers until you ask; nothing is stored in Tower.",
615
+ "sync <account> — reads that mailbox now instead of waiting for the cron. Prints what landed, or the reason it did not.",
616
+ "detach <account> — removes the mailbox, its stored mail and its credential.",
617
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
618
+ "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.",
619
+ "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
620
+ ],
621
+ ],
598
622
  [
599
623
  "project",
600
624
  [
@@ -617,7 +641,7 @@ export function localSubcommandHelp(command) {
617
641
  "",
618
642
  "The words are positional and do not need quoting unless they contain shell metacharacters:",
619
643
  '`cockpit search storage ceiling` and `cockpit search "storage ceiling"` are the same search.',
620
- "Quoted phrases and -word work inside the query itself \u2014 the query goes to Postgres\u2019s",
644
+ "Quoted phrases and -word work inside the query itself the query goes to Postgres's",
621
645
  "websearch parser, which shrugs at anything a person can type instead of raising.",
622
646
  "",
623
647
  "--kind narrows to one or more corpora, comma separated. Omit it and all five are searched.",
@@ -627,11 +651,11 @@ export function localSubcommandHelp(command) {
627
651
  "Every result is scoped by what YOU may read: four of the five corpora are searched on your",
628
652
  "own database session, so a document or a channel you cannot open is not in the list.",
629
653
  "",
630
- "A corpus that could not ANSWER gets its own line, separately from \"nothing matched\" \u2014",
654
+ "A corpus that could not ANSWER gets its own line, separately from \"nothing matched\" ",
631
655
  "those are different facts and folding them together would let a broken search read as silence.",
632
656
  "A memory result has no page to open; the text printed under it is the whole record.",
633
657
  "",
634
- "A refusal keeps Tower's own reason label \u2014 needs_rls_client, query_too_short, query_too_long,",
658
+ "A refusal keeps Tower's own reason label needs_rls_client, query_too_short, query_too_long,",
635
659
  "unknown_kind, read_failed.",
636
660
  "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
637
661
  ],
@@ -45,6 +45,7 @@ export const rootCommandNames = new Set([
45
45
  "docs",
46
46
  "msg",
47
47
  "issue",
48
+ "mail",
48
49
  "project",
49
50
  "search",
50
51
  "release",
@@ -89,6 +90,7 @@ export function localCommandHelp(command) {
89
90
  " cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>] [--parent <id>|root|--clear-parent] [--query <text>] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
90
91
  " 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]",
91
92
  " 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] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
93
+ " 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]",
92
94
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
93
95
  " cockpit search \"<words>\" [--kind doc,msg,issue,note,memory] [--limit <n>] [--dashboard-url <url>] [--json]",
94
96
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
@@ -35,6 +35,7 @@ import { runClean } from "./clean.js";
35
35
  import { runDocs } from "./docs.js";
36
36
  import { runMsg } from "./msg.js";
37
37
  import { runIssue } from "./issue.js";
38
+ import { runMail } from "./mail.js";
38
39
  import { runProject } from "./project.js";
39
40
  import { runSearch } from "./search.js";
40
41
  import { parseLocalArgs } from "./local-args.js";
@@ -142,6 +143,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
142
143
  return await runMsg(command, io);
143
144
  case "issue":
144
145
  return await runIssue(command, io);
146
+ case "mail":
147
+ return await runMail(command, io);
145
148
  case "project":
146
149
  return await runProject(command, io);
147
150
  case "search":
@@ -0,0 +1,337 @@
1
+ /**
2
+ * `cockpit mail` — the mailboxes a person attached, from a terminal
3
+ * (BLI-3708).
4
+ *
5
+ * Nine verbs over `/api/mail/**`, the same doors the browser will use when the
6
+ * UI lands. Every one of those routes already accepts the collector device
7
+ * token (`resolveCaller({ allowDeviceToken: true })`), so this command is a
8
+ * terminal in front of an existing door and adds no authority of its own.
9
+ *
10
+ * The CLI is the FIRST surface for this wave, deliberately (AGENTS.md, "CLI is
11
+ * king"): a mailbox is easier to reason about as `--json` than as a rendered
12
+ * list, and every bug fixed here is fixed for the browser and MCP halves that
13
+ * share the same doors.
14
+ *
15
+ * TWO SECRETS NEVER TOUCH ARGV. `add-imap` reads the Google app password from
16
+ * STDIN and there is no flag that would take it; `send` reads the body from
17
+ * stdin or `--file`. `local-args-tower-mail.ts` says why in full.
18
+ *
19
+ * ONE MORE RULE, for the attachment verb: bytes go to a FILE, never to stdout.
20
+ * `--out` is required. A terminal that prints a PDF is a terminal somebody has
21
+ * to close, and `--json` output on stdout is a contract other tools parse.
22
+ */
23
+ import { writeFile } from "node:fs/promises";
24
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
25
+ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
26
+ import { readNoteFile } from "./notes-file.js";
27
+ import { towerRequest } from "../tower-client.js";
28
+ const TAG = "[mail cli]";
29
+ const READ_DEADLINE_MS = 30_000;
30
+ const WRITE_DEADLINE_MS = 60_000;
31
+ /** A sync pass reads a bounded page, but a cold mailbox can still take a while. */
32
+ const SYNC_DEADLINE_MS = 120_000;
33
+ const BODY_MAX_CHARS = 200_000;
34
+ const PASSWORD_MAX_CHARS = 200;
35
+ export async function runMail(command, io) {
36
+ const door = await openAgentDoor("mail", command, io);
37
+ switch (command.action) {
38
+ case "accounts":
39
+ return listAccounts(door);
40
+ case "add-imap":
41
+ return addImap(command, door);
42
+ case "detach":
43
+ return detach(command, door);
44
+ case "inbox":
45
+ return inbox(command, door);
46
+ case "read":
47
+ return readThread(command, door);
48
+ case "search":
49
+ return search(command, door);
50
+ case "send":
51
+ return send(command, door);
52
+ case "attachment":
53
+ return attachment(command, door);
54
+ case "sync":
55
+ return sync(command, door);
56
+ }
57
+ }
58
+ async function listAccounts(door) {
59
+ const answer = await askAgentDoor(door, {
60
+ path: "/api/mail/accounts",
61
+ method: "GET",
62
+ label: "mail accounts",
63
+ timeoutMs: READ_DEADLINE_MS,
64
+ });
65
+ if (!answer.ok)
66
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
67
+ const accounts = answer.body.accounts ?? [];
68
+ if (door.json)
69
+ return emitAgentDoor(door, { ok: true, accounts });
70
+ if (accounts.length === 0) {
71
+ writeLine(door.io.stdout, "No mailboxes attached yet.");
72
+ writeLine(door.io.stdout, "");
73
+ writeLine(door.io.stdout, "A personal address: cockpit mail add-imap --address you@gmail.com (app password on stdin)");
74
+ writeLine(door.io.stdout, "A work address: open Tower in a browser and connect Google.");
75
+ return 0;
76
+ }
77
+ for (const account of accounts) {
78
+ const health = account.status === "active" ? account.backfill_state : `${account.status}: ${account.status_reason ?? "no reason recorded"}`;
79
+ writeLine(door.io.stdout, `${account.address.padEnd(34)} ${account.provider.padEnd(12)} ${health.padEnd(18)} ${account.last_sync_at ?? "never synced"}`);
80
+ }
81
+ writeLine(door.io.stdout, "");
82
+ writeLine(door.io.stdout, `${accounts.length} mailbox(es). ids: ${accounts.map((account) => account.id).join(", ")}`);
83
+ return 0;
84
+ }
85
+ async function addImap(command, door) {
86
+ // The password comes from stdin and nowhere else. An interactive terminal
87
+ // with nothing piped in is told how, rather than left waiting on a prompt
88
+ // that would echo the secret into the scrollback.
89
+ if (isInteractiveStdin(door.io)) {
90
+ return failAgentDoor(door, TAG, "password_required_on_stdin", 'The app password is read from stdin, never from a flag. Try: printf "%s" "abcd efgh ijkl mnop" | cockpit mail add-imap --address you@gmail.com');
91
+ }
92
+ let appPassword;
93
+ try {
94
+ appPassword = (await readPipedText(door.io.stdin, {
95
+ maxChars: PASSWORD_MAX_CHARS,
96
+ overflowMessage: "That does not look like a Google app password (they are 16 characters).",
97
+ })).trim();
98
+ }
99
+ catch (error) {
100
+ return failAgentDoor(door, TAG, "password_unreadable", error instanceof Error ? error.message : String(error));
101
+ }
102
+ if (appPassword === "") {
103
+ return failAgentDoor(door, TAG, "password_required_on_stdin", "Nothing arrived on stdin, so nothing was attached.");
104
+ }
105
+ const answer = await askAgentDoor(door, {
106
+ path: "/api/mail/accounts",
107
+ method: "POST",
108
+ label: "mail add-imap",
109
+ timeoutMs: WRITE_DEADLINE_MS,
110
+ body: {
111
+ address: command.address,
112
+ display_name: command.displayName ?? null,
113
+ app_password: appPassword,
114
+ },
115
+ });
116
+ if (!answer.ok)
117
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
118
+ const account = answer.body.account;
119
+ if (door.json)
120
+ return emitAgentDoor(door, { ok: true, account });
121
+ writeLine(door.io.stdout, `Attached ${account?.address ?? command.address} (${account?.id ?? "no id returned"}).`);
122
+ writeLine(door.io.stdout, "Nothing is read until a sync runs: `cockpit mail sync <id>`, or wait for the cron.");
123
+ return 0;
124
+ }
125
+ async function detach(command, door) {
126
+ const answer = await askAgentDoor(door, {
127
+ path: `/api/mail/accounts?account_id=${encodeURIComponent(command.subjectId ?? "")}`,
128
+ method: "DELETE",
129
+ label: "mail detach",
130
+ timeoutMs: WRITE_DEADLINE_MS,
131
+ });
132
+ if (!answer.ok)
133
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
134
+ if (door.json)
135
+ return emitAgentDoor(door, { ok: true, detached: command.subjectId });
136
+ writeLine(door.io.stdout, `Detached ${command.subjectId}. Its stored mail and its credential went with it.`);
137
+ return 0;
138
+ }
139
+ async function inbox(command, door) {
140
+ const query = new URLSearchParams();
141
+ if (command.accountId)
142
+ query.set("account_id", command.accountId);
143
+ if (command.limit !== undefined)
144
+ query.set("limit", String(command.limit));
145
+ if (command.before)
146
+ query.set("before", command.before);
147
+ if (command.unreadOnly)
148
+ query.set("unread", "1");
149
+ if (command.label)
150
+ query.set("label", command.label);
151
+ const suffix = query.toString();
152
+ const answer = await askAgentDoor(door, {
153
+ path: `/api/mail/inbox${suffix ? `?${suffix}` : ""}`,
154
+ method: "GET",
155
+ label: "mail inbox",
156
+ timeoutMs: READ_DEADLINE_MS,
157
+ });
158
+ if (!answer.ok)
159
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
160
+ const body = answer.body;
161
+ if (door.json)
162
+ return emitAgentDoor(door, { ok: true, ...body });
163
+ const messages = body.messages ?? [];
164
+ if (messages.length === 0) {
165
+ writeLine(door.io.stdout, (body.accounts ?? []).length === 0 ? "No mailboxes attached yet." : "No mail matches that.");
166
+ return 0;
167
+ }
168
+ for (const message of messages) {
169
+ writeLine(door.io.stdout, messageLine(message));
170
+ }
171
+ writeLine(door.io.stdout, "");
172
+ writeLine(door.io.stdout, `${messages.length} message(s) across ${(body.accounts ?? []).length} mailbox(es).`);
173
+ return 0;
174
+ }
175
+ function messageLine(message) {
176
+ const flag = message.is_unread ? "*" : " ";
177
+ const paperclip = message.has_attachments ? "@" : " ";
178
+ const from = message.from_address?.address ?? "(no sender)";
179
+ const day = message.internal_date.slice(0, 16).replace("T", " ");
180
+ return `${flag}${paperclip} ${day} ${from.padEnd(30).slice(0, 30)} ${(message.subject ?? "(no subject)").slice(0, 60)}`;
181
+ }
182
+ async function readThread(command, door) {
183
+ const answer = await askAgentDoor(door, {
184
+ path: `/api/mail/threads/${encodeURIComponent(command.subjectId ?? "")}`,
185
+ method: "GET",
186
+ label: "mail read",
187
+ timeoutMs: READ_DEADLINE_MS,
188
+ });
189
+ if (!answer.ok)
190
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
191
+ const body = answer.body;
192
+ if (door.json)
193
+ return emitAgentDoor(door, { ok: true, ...body });
194
+ writeLine(door.io.stdout, body.thread?.subject ?? "(no subject)");
195
+ writeLine(door.io.stdout, `${body.thread?.account_address ?? "?"} — ${body.messages?.length ?? 0} message(s)`);
196
+ for (const message of body.messages ?? []) {
197
+ writeLine(door.io.stdout, "");
198
+ writeLine(door.io.stdout, `--- ${message.internal_date} ${message.from_address?.address ?? "(no sender)"} [${message.id}]`);
199
+ writeLine(door.io.stdout, (message.body_text ?? message.snippet ?? "(no text body — this message is HTML only)").trim());
200
+ }
201
+ return 0;
202
+ }
203
+ async function search(command, door) {
204
+ const query = new URLSearchParams({ q: command.query ?? "" });
205
+ if (command.accountId)
206
+ query.set("account_id", command.accountId);
207
+ if (command.limit !== undefined)
208
+ query.set("limit", String(command.limit));
209
+ const answer = await askAgentDoor(door, {
210
+ path: `/api/mail/search?${query.toString()}`,
211
+ method: "GET",
212
+ label: "mail search",
213
+ timeoutMs: READ_DEADLINE_MS,
214
+ });
215
+ if (!answer.ok)
216
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
217
+ const matches = answer.body.matches ?? [];
218
+ if (door.json)
219
+ return emitAgentDoor(door, { ok: true, query: command.query, matches });
220
+ if (matches.length === 0) {
221
+ writeLine(door.io.stdout, `Nothing in your mail matched "${command.query}".`);
222
+ return 0;
223
+ }
224
+ for (const message of matches)
225
+ writeLine(door.io.stdout, messageLine(message));
226
+ writeLine(door.io.stdout, "");
227
+ writeLine(door.io.stdout, `${matches.length} match(es).`);
228
+ return 0;
229
+ }
230
+ async function send(command, door) {
231
+ const body = await readBody(command, door);
232
+ if (!body.ok)
233
+ return failAgentDoor(door, TAG, body.reason, body.detail);
234
+ if (body.text.trim() === "") {
235
+ return failAgentDoor(door, TAG, "body_required", 'A message needs a body: `echo "on my way" | cockpit mail send --account <id> --to someone@example.com --subject "Re: lunch"`.');
236
+ }
237
+ const answer = await askAgentDoor(door, {
238
+ path: "/api/mail/send",
239
+ method: "POST",
240
+ label: "mail send",
241
+ timeoutMs: WRITE_DEADLINE_MS,
242
+ body: {
243
+ account_id: command.accountId,
244
+ to: command.to.map((address) => ({ address })),
245
+ ...(command.cc.length > 0 ? { cc: command.cc.map((address) => ({ address })) } : {}),
246
+ subject: command.subjectLine ?? "",
247
+ text: body.text,
248
+ ...(command.replyTo ? { in_reply_to_message_id: command.replyTo } : {}),
249
+ },
250
+ });
251
+ if (!answer.ok)
252
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
253
+ const sent = answer.body.sent;
254
+ if (door.json)
255
+ return emitAgentDoor(door, { ok: true, sent });
256
+ writeLine(door.io.stdout, `Sent from ${sent?.address ?? "that mailbox"} to ${command.to.join(", ")}${sent?.threaded ? " (as a reply)" : ""}.`);
257
+ return 0;
258
+ }
259
+ async function attachment(command, door) {
260
+ const outPath = command.outPath;
261
+ // The bytes do not go through `askAgentDoor` — it parses JSON, and this
262
+ // response is a file. Same door, same token, raw response.
263
+ const result = await towerRequest({
264
+ dashboardUrl: door.dashboardUrl,
265
+ path: `/api/mail/attachments/${encodeURIComponent(command.subjectId ?? "")}`,
266
+ deviceToken: door.deviceToken,
267
+ fetch: door.io.fetch,
268
+ method: "GET",
269
+ label: "mail attachment",
270
+ timeoutMs: WRITE_DEADLINE_MS,
271
+ log: (line) => writeLine(door.io.stderr, line),
272
+ });
273
+ if (!result.ok) {
274
+ return failAgentDoor(door, TAG, result.reason, result.detail);
275
+ }
276
+ if (!result.response.ok) {
277
+ return failAgentDoor(door, TAG, `http_${result.response.status}`, `Tower answered ${result.response.status} for that attachment. Nothing was written to ${outPath}.`);
278
+ }
279
+ const bytes = Buffer.from(await result.response.arrayBuffer());
280
+ try {
281
+ await writeFile(outPath, bytes);
282
+ }
283
+ catch (error) {
284
+ return failAgentDoor(door, TAG, "write_failed", `Downloaded ${bytes.length} bytes and could not write them to ${outPath}: ${error instanceof Error ? error.message : String(error)}`);
285
+ }
286
+ if (door.json)
287
+ return emitAgentDoor(door, { ok: true, path: outPath, bytes: bytes.length });
288
+ writeLine(door.io.stdout, `Wrote ${bytes.length} bytes to ${outPath}.`);
289
+ return 0;
290
+ }
291
+ async function sync(command, door) {
292
+ const answer = await askAgentDoor(door, {
293
+ path: `/api/mail/accounts/${encodeURIComponent(command.subjectId ?? "")}/sync`,
294
+ method: "POST",
295
+ label: "mail sync",
296
+ timeoutMs: SYNC_DEADLINE_MS,
297
+ });
298
+ if (!answer.ok)
299
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
300
+ const run = answer.body.run;
301
+ if (door.json)
302
+ return emitAgentDoor(door, { ok: true, run });
303
+ if (!run) {
304
+ writeLine(door.io.stdout, "Tower answered without a run. Nothing to report.");
305
+ return 0;
306
+ }
307
+ if (run.reason === "ok") {
308
+ writeLine(door.io.stdout, `Synced: ${run.messagesAdded} new, ${run.messagesUpdated} updated, ${run.messagesDeleted} gone.`);
309
+ return 0;
310
+ }
311
+ // A failure names itself; the exit code says it failed.
312
+ writeLine(door.io.stderr, `${TAG} sync ${JSON.stringify({ reason: run.reason, detail: run.detail })}`);
313
+ writeLine(door.io.stdout, `That mailbox did not sync: ${run.reason}${run.detail ? ` (${run.detail})` : ""}.`);
314
+ return 1;
315
+ }
316
+ /** Body on stdin, never argv — `--file` is the safest way on Windows. */
317
+ async function readBody(command, door) {
318
+ if (command.filePath) {
319
+ const read = await readNoteFile(command.filePath);
320
+ if (!read.ok) {
321
+ return { ok: false, reason: read.refusal, detail: `Could not read ${command.filePath}: ${read.detail}` };
322
+ }
323
+ return { ok: true, text: read.bytes.toString("utf8") };
324
+ }
325
+ if (isInteractiveStdin(door.io))
326
+ return { ok: true, text: "" };
327
+ try {
328
+ const text = await readPipedText(door.io.stdin, {
329
+ maxChars: BODY_MAX_CHARS,
330
+ overflowMessage: `A message body is limited to ${BODY_MAX_CHARS} characters here.`,
331
+ });
332
+ return { ok: true, text };
333
+ }
334
+ catch (error) {
335
+ return { ok: false, reason: "body_too_long", detail: error instanceof Error ? error.message : String(error) };
336
+ }
337
+ }
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.61");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.63");
19
19
  return 0;
20
20
  }
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.61",
3
+ "version": "0.2.63",
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.8",
31
- "@bli-cockpit/mcp": "0.1.4",
31
+ "@bli-cockpit/mcp": "0.1.6",
32
32
  "@bli-cockpit/telemetry-core": "0.1.29"
33
33
  }
34
34
  }