@plotio/cli 0.3.0 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +29 -17
  2. package/dist/plot.js +682 -89
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @plotio/cli
2
2
 
3
- `plot`: read Plot pages, follow a meeting while it happens, and post back, from
3
+ `plot`: read Plot pages, follow a meeting while it happens, and write back, from
4
4
  any agent with a shell (Claude Code, Codex CLI, Amp, OpenCode) or a script.
5
5
 
6
6
  Plot records conversations — meetings, calls, voice notes — as shared pages: a
@@ -33,6 +33,14 @@ a hash. Each is kept once redeemed, so later shells need nothing.
33
33
  browser, against the authorization server the API itself names — so `--api`
34
34
  at a local or preview deployment signs in on that deployment's own web app,
35
35
  never production's.
36
+ - **A workspace, with no browser anywhere.** Someone signed in to Plot creates
37
+ a token in Settings > Agents and hands it over. Set `PLOT_TOKEN` (and
38
+ `PLOT_API_URL` unless it is production) and every command works with no login
39
+ and nothing stored, which is what a sandbox or a CI job wants.
40
+
41
+ Whoever grants the access also chooses whether the agent may post. Allowed
42
+ drafts only, `plot message` still works and `--send` is refused
43
+ (`draft_only`); `plot whoami` says so under **Writes**.
36
44
 
37
45
  `plot login` ends three ways, and what it can see decides which:
38
46
 
@@ -48,21 +56,24 @@ a hash. Each is kept once redeemed, so later shells need nothing.
48
56
 
49
57
  ## Commands
50
58
 
51
- | Command | Does |
52
- | ----------------------------------------------------------- | ----------------------------------------------------------------------------- |
53
- | `plot open <temp_code_…> [--agent <name>]` | Redeem a handoff code and store the page token |
54
- | `plot login [--agent] [--no-browser]` | Connect a workspace: sign in and approve in the browser |
55
- | `plot login --code <code>` | Finish a login started without a terminal, with the code the browser showed |
56
- | `plot logout [<page>] [--all]` | Revoke a credential on the server, then forget it |
57
- | `plot whoami [<page>] [--json]` | What the credential acts for, its scope and expiry |
58
- | `plot pages [--limit <n>] [--next <c>] [--json]` | Pages the workspace grant can read, newest first |
59
- | `plot read <page> [--json]` | The page as Markdown (topics included), or as JSON; stores the updates cursor |
60
- | `plot watch <page> [--level …] [options]` | Wait for the next update, print it, store the cursor |
61
- | `plot topics <page>` | Topics as JSON, with the quotes behind each point |
62
- | `plot search <query> [--limit] [--next] [--json]` | Search every page the workspace grant can read |
63
- | `plot post <page> [--branch <id>] [--body <t>]` | Post a message (body from `--body`, else stdin), idempotent per run |
64
- | `plot mcp-config [--host <h>] [--token] [--write] [<page>]` | The MCP server block for a host: the `<api>/mcp` URL, signed in with OAuth |
65
- | `plot help agents` | The guide for an AI agent |
59
+ | Command | Does |
60
+ | ------------------------------------------------------------- | ----------------------------------------------------------------------------- |
61
+ | `plot open <temp_code_…> [--agent <name>]` | Redeem a handoff code and store the page token |
62
+ | `plot login [--agent] [--no-browser]` | Connect a workspace: sign in and approve in the browser |
63
+ | `plot login --code <code>` | Finish a login started without a terminal, with the code the browser showed |
64
+ | `plot logout [<page>] [--all]` | Revoke a credential on the server, then forget it |
65
+ | `plot whoami [<page>] [--json]` | What the credential acts for, its scope and expiry |
66
+ | `plot pages [--limit <n>] [--next <c>] [--json]` | Pages the workspace grant can read, newest first |
67
+ | `plot read <page> [--json]` | The page as Markdown (topics included), or as JSON; stores the updates cursor |
68
+ | `plot watch <page> [--level …] [options]` | Wait for the next update, print it, store the cursor |
69
+ | `plot topics <page>` | Topics as JSON, with the quotes behind each point |
70
+ | `plot task <page> [--status <s>] [--assign <id>]…` | A task page's Status and assignees; with an option, set them |
71
+ | `plot search <query> [--limit] [--next] [--json]` | Search every page the workspace grant can read |
72
+ | `plot message <page> [--body <t>] [--branch] [--note] [--id]` | Draft a message into the person's composer (body from `--body`, else stdin) |
73
+ | `plot message <page> --send …` | Post it now instead, shown as your agent, for the person |
74
+ | `plot message <page> --show\|--discard [<id>]` | Read back or withdraw a draft (the last one made here by default) |
75
+ | `plot mcp-config [--host <h>] [--token] [--write] [<page>]` | The MCP server block for a host: the `<api>/mcp` URL, signed in with OAuth |
76
+ | `plot help agents` | The guide for an AI agent |
66
77
 
67
78
  `<page>` is a page id or a page URL (`https://www.plot.io/p/<id>`).
68
79
 
@@ -119,7 +130,8 @@ calls expired or revoked is removed and the next one tried.
119
130
  `~/.config/plot/credentials.json`, mode `0600` in a `0700` directory. It is
120
131
  keyed by API base, so production's tokens and a preview's never collide — and
121
132
  so changing `--api` or `PLOT_API_URL` changes which credential is in play. It
122
- also holds each page's last `watch` cursor, which is how a new shell resumes.
133
+ also holds each page's last `watch` cursor, which is how a new shell resumes,
134
+ and the id of the last message `plot message` made there.
123
135
  `plot logout --all` revokes and removes everything for the current API base.
124
136
 
125
137
  A `plot login` with no TTY leaves a `pending-login.json` beside it, same mode,
package/dist/plot.js CHANGED
@@ -6,22 +6,22 @@ import { parseArgs } from "node:util";
6
6
  // src/errors.ts
7
7
  var CliError = class extends Error {
8
8
  exitCode;
9
- constructor(message, exitCode = 1) {
10
- super(message);
9
+ constructor(message2, exitCode = 1) {
10
+ super(message2);
11
11
  this.name = "CliError";
12
12
  this.exitCode = exitCode;
13
13
  }
14
14
  };
15
15
  var UsageError = class extends CliError {
16
- constructor(message) {
17
- super(`${message}
16
+ constructor(message2) {
17
+ super(`${message2}
18
18
  Run "plot --help" for usage.`, 2);
19
19
  this.name = "UsageError";
20
20
  }
21
21
  };
22
22
 
23
23
  // src/version.ts
24
- var VERSION = "0.3.0";
24
+ var VERSION = "0.5.0";
25
25
 
26
26
  // src/api.ts
27
27
  var DEFAULT_API_URL = "https://agents.plot.io";
@@ -29,8 +29,8 @@ var ApiError = class extends Error {
29
29
  status;
30
30
  /** The server's machine reason (`expired`, `no_access`, …), or `network`/`http`. */
31
31
  code;
32
- constructor(status, code, message) {
33
- super(message);
32
+ constructor(status, code, message2) {
33
+ super(message2);
34
34
  this.name = "ApiError";
35
35
  this.status = status;
36
36
  this.code = code;
@@ -61,6 +61,7 @@ var UPDATE_LEVELS = [
61
61
  ];
62
62
  var MAX_WAIT_SECONDS = 240;
63
63
  var CURSOR_HEADER = "X-Plot-Updates-Cursor";
64
+ var TASK_STATUSES = ["Todo", "Doing", "Done"];
64
65
  function createApi(opts) {
65
66
  const fetchFn = opts.fetch ?? fetch;
66
67
  const base = opts.base;
@@ -214,6 +215,50 @@ function createApi(opts) {
214
215
  });
215
216
  return { body: await json(response), cursor: cursorFrom(response) };
216
217
  },
218
+ /** GET /v1/pages/:id/attachments/:id: where one file's bytes are. */
219
+ async attachment(token, pageId, attachmentId, variant) {
220
+ const response = await request(
221
+ "GET",
222
+ `/v1/pages/${enc(pageId)}/attachments/${enc(attachmentId)}`,
223
+ { token, query: { variant } }
224
+ );
225
+ return await json(response);
226
+ },
227
+ /**
228
+ * The bytes, from the presigned URL the call above handed back.
229
+ *
230
+ * No Authorization header, and not through `request`, which would add
231
+ * one. R2 signs a presigned URL in the query string and refuses a
232
+ * request that also carries an Authorization header, with
233
+ * `400 InvalidRequest: Missing x-amz-content-sha256` — an error naming a
234
+ * header nobody was trying to send, which is why it is written down.
235
+ *
236
+ * Note what does *not* need defending against: Node's fetch and `curl`
237
+ * both drop the header themselves when following a cross-origin
238
+ * redirect, so `?redirect=1` is safe with either. The way to meet this
239
+ * is to send the header on purpose, which is what any authenticated
240
+ * fetch wrapper does — including this file's own `request`.
241
+ */
242
+ async download(url) {
243
+ let response;
244
+ try {
245
+ response = await fetchFn(new URL(url), { method: "GET" });
246
+ } catch (err) {
247
+ throw new ApiError(
248
+ 0,
249
+ "network",
250
+ `Could not fetch the file: ${err.message}`
251
+ );
252
+ }
253
+ if (!response.ok) {
254
+ throw new ApiError(
255
+ response.status,
256
+ "download_failed",
257
+ `The storage service answered ${response.status} for that file. A signed URL lives only a few minutes; ask for it again.`
258
+ );
259
+ }
260
+ return new Uint8Array(await response.arrayBuffer());
261
+ },
217
262
  /**
218
263
  * GET /v1/pages/:id/updates. `wait` is honoured by the production proxy
219
264
  * (a long poll, capped at MAX_WAIT_SECONDS) and ignored by Convex directly, so
@@ -242,6 +287,24 @@ function createApi(opts) {
242
287
  await request("GET", `/v1/pages/${enc(pageId)}/topics`, { token })
243
288
  );
244
289
  },
290
+ /**
291
+ * PATCH /v1/pages/:id/task: set a task page's Status, its assignees, or
292
+ * both. Only the fields given are sent. 400 `not_a_task` on any other
293
+ * page; a grant that cannot post is refused as a send is.
294
+ */
295
+ async updateTask(token, pageId, update) {
296
+ const body = {};
297
+ if (update.status !== void 0) body.status = update.status;
298
+ if (update.assigneeIds !== void 0) {
299
+ body.assigneeIds = update.assigneeIds;
300
+ }
301
+ return json(
302
+ await request("PATCH", `/v1/pages/${enc(pageId)}/task`, {
303
+ token,
304
+ body
305
+ })
306
+ );
307
+ },
245
308
  /** GET /v1/search?q=. */
246
309
  async search(token, q, page2 = {}) {
247
310
  return json(
@@ -252,32 +315,65 @@ function createApi(opts) {
252
315
  );
253
316
  },
254
317
  /**
255
- * POST /v1/pages/:id/messages. `id` makes the post idempotent, which is
256
- * what lets a transport failure (no answer, or a gateway 5xx) be retried
257
- * with the same id: the server posts it once however many times it
258
- * arrives. A refusal with a reason is never retried.
318
+ * POST /v1/pages/:id/messages: a draft in the person's composer, or with
319
+ * `send` a message on the page. `id` makes it idempotent, which is what
320
+ * lets a transport failure (no answer, or a gateway 5xx) be retried with
321
+ * the same id: the server acts once however many times it arrives. A
322
+ * refusal with a reason is never retried. Returns the HTTP status too,
323
+ * since 201 (new) and 200 (an existing draft updated) differ only there.
259
324
  */
260
- async post(token, pageId, message, retry = {}) {
261
- const body = {
262
- body: message.body,
263
- id: message.id
264
- };
265
- if (message.branchId) body.branchId = message.branchId;
325
+ async createMessage(token, pageId, message2, retry = {}) {
326
+ const body = {};
327
+ if (message2.body !== void 0) body.body = message2.body;
328
+ body.id = message2.id;
329
+ if (message2.branchId) body.branchId = message2.branchId;
330
+ if (message2.send) body.send = true;
331
+ if (message2.note) body.note = message2.note;
266
332
  const attempts = retry.attempts ?? 3;
267
333
  for (let attempt = 1; ; attempt++) {
268
334
  try {
269
- return await json(
270
- await request("POST", `/v1/pages/${enc(pageId)}/messages`, {
271
- token,
272
- body
273
- })
335
+ const response = await request(
336
+ "POST",
337
+ `/v1/pages/${enc(pageId)}/messages`,
338
+ { token, body }
274
339
  );
340
+ return {
341
+ status: response.status,
342
+ body: await json(response)
343
+ };
275
344
  } catch (err) {
276
345
  const transport = err instanceof ApiError && (err.code === "network" || [502, 503, 504].includes(err.status));
277
346
  if (!transport || attempt >= attempts) throw err;
278
347
  await retry.sleep?.(1e3 * attempt);
279
348
  }
280
349
  }
350
+ },
351
+ /**
352
+ * GET /v1/pages/:id/messages/:messageId: a draft's words and note, or
353
+ * just `status: 'sent'` once it is on the page. 404 `no_draft` when the
354
+ * id names nothing of the person's.
355
+ */
356
+ async getMessage(token, pageId, messageId) {
357
+ const body = await json(
358
+ await request(
359
+ "GET",
360
+ `/v1/pages/${enc(pageId)}/messages/${enc(messageId)}`,
361
+ { token }
362
+ )
363
+ );
364
+ return "message" in body && body.message && typeof body.message === "object" ? body.message : body;
365
+ },
366
+ /**
367
+ * DELETE /v1/pages/:id/messages/:messageId: withdraw a draft. 204; 404
368
+ * `no_draft`; 403 `not_a_draft` for a message already sent, which the
369
+ * agent surface never deletes.
370
+ */
371
+ async discardMessage(token, pageId, messageId) {
372
+ await request(
373
+ "DELETE",
374
+ `/v1/pages/${enc(pageId)}/messages/${enc(messageId)}`,
375
+ { token }
376
+ );
281
377
  }
282
378
  };
283
379
  }
@@ -296,8 +392,8 @@ async function errorFrom(response) {
296
392
  const body = JSON.parse(text);
297
393
  if (body && typeof body === "object") {
298
394
  const code = typeof body.error === "string" ? body.error : "http";
299
- const message = typeof body.message === "string" && body.message ? body.message : `Request failed with HTTP ${response.status} (${code}).`;
300
- return new ApiError(response.status, code, message);
395
+ const message2 = typeof body.message === "string" && body.message ? body.message : `Request failed with HTTP ${response.status} (${code}).`;
396
+ return new ApiError(response.status, code, message2);
301
397
  }
302
398
  } catch {
303
399
  }
@@ -349,6 +445,7 @@ var KNOWN_NODES = /* @__PURE__ */ new Set([
349
445
  ...INLINE_NODES
350
446
  ]);
351
447
  var KNOWN_MARKS = new Set(MARKS);
448
+ var MAX_DOC_DEPTH = 20;
352
449
  var MAX_DOC_BYTES = 256 * 1024;
353
450
  var MARKDOWN_SYNTAX = new RegExp(
354
451
  [
@@ -385,6 +482,9 @@ var MARKDOWN_SYNTAX = new RegExp(
385
482
  ].join("|"),
386
483
  "m"
387
484
  );
485
+ var MAX_PARAGRAPH_DEPTH = MAX_DOC_DEPTH - 1;
486
+ var MAX_QUOTE_DEPTH = MAX_DOC_DEPTH - 2;
487
+ var MAX_LIST_DEPTH = MAX_DOC_DEPTH - 3;
388
488
 
389
489
  // ../shared/src/transcriptDelta.ts
390
490
  var INT64_MAX = BigInt("9223372036854775807");
@@ -405,9 +505,13 @@ Usage: plot <command> [options]
405
505
  read <page> [--json] The page as Markdown, topics included
406
506
  watch <page> [--level <level>] Wait for what is new since the last watch
407
507
  topics <page> The page's topics as JSON
508
+ files <page> [--get <id>] [--out <p>] Files on the page; --get writes one to disk
509
+ task <page> [--status <s>] A task's Status and assignees, or set them
408
510
  search <query> [--limit] [--next] Search your pages (workspace login)
409
- post <page> [--branch <id>] [--body <text> | stdin]
410
- Post a message onto the page
511
+ message <page> [--body <text> | stdin] Draft a message into the person's composer
512
+ message <page> --send \u2026 Or post it as them, only when they asked
513
+ message <page> --show|--discard [<id>]
514
+ Read back or withdraw a draft
411
515
  mcp-config [--host <host>] [--write] Config block for an MCP host
412
516
  help agents How an AI agent should use this
413
517
 
@@ -430,11 +534,21 @@ Get access (once; the token is stored, so later shells need nothing):
430
534
  the code the page shows. Then:
431
535
  plot login --code <code> Finishes it (within ten minutes)
432
536
 
537
+ If nobody can open a browser for you at all, ask your person for a token from
538
+ Plot's Settings > Agents, and export PLOT_TOKEN=plot_agent_sk_\u2026 in your shell
539
+ (plus PLOT_API_URL if they say so). Then no login is needed.
540
+
433
541
  Read:
434
542
  plot read <page> The page as Markdown: transcript, then Topics.
435
543
  A later watch starts from the end of this read.
436
544
  plot read <page> --json The same, structured
437
545
  plot topics <page> Topics as JSON, with the quotes behind each point
546
+ plot files <page> Files on the page, with the ids read prints
547
+ plot files <page> --get <id>
548
+ Writes that file where you can open it and
549
+ prints the path. This is how you look at a
550
+ screenshot or a PDF someone attached: fetch
551
+ it, then read the path with your own tools
438
552
  plot pages Pages you can read (workspace login only)
439
553
  plot search "<words>" Find a page or a moment (workspace login only)
440
554
 
@@ -449,7 +563,16 @@ Follow a meeting that is still happening:
449
563
  tool's own timeout, e.g. --timeout 100)
450
564
 
451
565
  Write back (only if the person who gave you access can post):
452
- plot post <page> --body "\u2026" Appears as the person, marked "via <your name>"
566
+ plot message <page> --body "\u2026" Goes into their composer as a draft for them to send
567
+ plot message <page> --send --body "\u2026" Appears at once, as you, for the person:
568
+ only when they asked you to post
569
+
570
+ A task page carries a Status (Todo, Doing, Done) and Assignees; plot read
571
+ shows them on the line under the title. If you are working a task, say
572
+ where you stand on the page itself, with the same access as a send:
573
+ plot task <page> --status Doing When you start
574
+ plot task <page> --status Done When it is finished
575
+ plot task <page> Just print the Status and assignees
453
576
 
454
577
  <page> is a page id or a link like https://www.plot.io/p/<id>.
455
578
 
@@ -527,17 +650,80 @@ stored cursor the server refuses is dropped and the watch starts over.
527
650
 
528
651
  The page's topics as JSON: title, emoji, summary and points with their
529
652
  source quotes and message ids.`,
653
+ files: `plot files <page> [--json]
654
+ plot files <page> --get <id> [--out <path>] [--original]
655
+
656
+ The files people attached to messages on the page: name, type, size, an
657
+ image's pixel size, and the id. The same ids "plot read" prints.
658
+
659
+ --get <id> fetches one and writes it where you can open it, printing the
660
+ path it wrote. That is the point of this command: your file-reading tool
661
+ takes a path, and the signed URL behind the file lives five minutes.
662
+
663
+ For an image you get the preview Plot made (bounded, WebP, a fraction of
664
+ the bytes) unless you ask for --original; everything else has only the
665
+ original. The bytes come straight from storage, never through Plot.
666
+ --get <id> Fetch this file
667
+ --out <path> Write it here instead of the cache directory
668
+ ($XDG_CACHE_HOME/plot/files/<page>/, else ~/.cache/...).
669
+ "--out -" writes the bytes to stdout and nothing to disk
670
+ --original The file as uploaded, rather than the preview
671
+ --variant <v> original or preview, said explicitly
672
+ --json The file's fields, with the path it was written to`,
673
+ task: `plot task <page> [--status Todo|Doing|Done] [--assign <userId>]...
674
+ [--unassign-all] [--json]
675
+
676
+ A task page's fields, as the line under its title in "plot read": Status
677
+ and Assignees, and the page it is part of. With no options it prints them;
678
+ with any, it sets them and prints the result. An option left out leaves
679
+ that field as it is. A page that is not a task is refused before anything
680
+ is written.
681
+ --status <s> Todo, Doing or Done
682
+ --assign <id> Make this user an assignee; repeat for more. Replaces the
683
+ assignees, so name everyone who should stay. User ids are
684
+ the members' ids in "plot read <page> --json"
685
+ --unassign-all Leave the task with no assignees
686
+ --json Print the task block (status, assignees with id and name)
687
+
688
+ Setting needs a member who can post: a viewer's grant is refused (read_only)
689
+ as a send is.`,
530
690
  search: `plot search <query> [--limit <n>] [--next <cursor>] [--json]
531
691
 
532
692
  Searches every page the workspace grant can read: messages, topics and page
533
693
  fields, each with pageId, pageTitle, url, snippet and at. Needs "plot login".`,
534
- post: `plot post <page> [--branch <id>] [--body <text>]
694
+ message: `plot message <page> [--branch <id>] [--note <line>] [--id <id>]
695
+ [--body <text> | stdin]
696
+ plot message <page> --send \u2026
697
+ plot message <page> --show [<id>]
698
+ plot message <page> --discard [<id>]
699
+
700
+ Puts Markdown words (from --body, else stdin) into the composer of the
701
+ person who granted access, on that page, as a draft: nobody else sees it,
702
+ and it reaches the page only when they send it (or they discard it). Prints
703
+ the message id and the page URL. One draft per lane: a second draft on the
704
+ same lane replaces the first, and says so.
705
+ --branch <id> Draft a reply on that branch instead of the main thread
706
+ --note <line> One line to the person, shown with the draft (200 chars)
707
+ --id <id> Use this message id (8 to 64 of A-Z a-z 0-9 _ -). The same
708
+ id again updates that draft in place. Each run otherwise
709
+ makes one id, so a retry after a dropped connection lands
710
+ once
711
+
712
+ --send posts it now instead, shown as your agent, for the person. Use it
713
+ only when they asked you to post. With no words given it sends your earlier
714
+ draft as it stands, formatting intact: the one --id names, else the last one
715
+ made here. A grant that cannot post is refused (read_only), never quietly
716
+ turned into a draft, and so is one whose person allowed drafts only
717
+ (draft_only) \u2014 the same command without --send still works.
535
718
 
536
- Posts a text message onto the page, from --body or else from stdin. It
537
- appears as the person who granted access, marked as posted via your agent.
538
- Needs a member who can post; a viewer's grant is refused (read_only). Each
539
- run sends one idempotency id, so a retry after a dropped connection posts
540
- once.`,
719
+ --show reads a draft back (its words and note), or says it was sent.
720
+ --discard withdraws a draft; a sent message cannot be withdrawn here
721
+ (not_a_draft). Both act on <id>, else --id, else the last message this
722
+ machine made on the page.
723
+
724
+ Needs a member who can post: a viewer's grant is refused (read_only) for a
725
+ draft as for a send. plot whoami says "Writes: drafts only" for a grant that
726
+ was connected without the send.`,
541
727
  "mcp-config": `plot mcp-config [--host <host>] [--write] [--token]
542
728
  [--inline-token] [<page>]
543
729
 
@@ -726,7 +912,10 @@ function renderPages(pages2, next) {
726
912
  const rows = pages2.map((page2) => {
727
913
  const extras = [
728
914
  page2.unread ? `${page2.unread} unread` : "",
729
- page2.canPost === false ? "read-only" : ""
915
+ // Three states, not two: a draft-only grant may still write into the
916
+ // person's composer here, and labelling that "read-only" tells the
917
+ // agent not to try.
918
+ page2.canPost === false ? page2.canDraft ? "drafts only" : "read-only" : ""
730
919
  ].filter(Boolean);
731
920
  const when = formatTime(page2.updatedAt);
732
921
  return [
@@ -791,6 +980,11 @@ function renderUpdate(update) {
791
980
  ].filter(Boolean);
792
981
  const tag = marks.length ? ` [${marks.join(", ")}]` : "";
793
982
  out.push(`${line.authorName || "Unknown"}${tag}: ${line.text ?? ""}`);
983
+ for (const file of line.files ?? []) {
984
+ out.push(
985
+ ` [Attachment: ${file.name} \xB7 ${file.contentType} \xB7 id ${file.id}]`
986
+ );
987
+ }
794
988
  }
795
989
  out.push("");
796
990
  }
@@ -807,10 +1001,35 @@ function renderUpdate(update) {
807
1001
  // src/store.ts
808
1002
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
809
1003
  import { basename, dirname, join } from "node:path";
1004
+ function pageMemory(page2) {
1005
+ const kept = {};
1006
+ if (page2?.title) kept.title = page2.title;
1007
+ if (page2?.cursor) kept.cursor = page2.cursor;
1008
+ if (page2?.lastMessageId) kept.lastMessageId = page2.lastMessageId;
1009
+ return kept;
1010
+ }
1011
+ function dropPageCredential(entry, pageId) {
1012
+ const page2 = entry.pages?.[pageId];
1013
+ if (!page2) return;
1014
+ const kept = pageMemory(page2);
1015
+ if (kept.cursor || kept.lastMessageId) entry.pages[pageId] = kept;
1016
+ else delete entry.pages[pageId];
1017
+ }
1018
+ function setLastMessageId(entry, pageId, messageId) {
1019
+ const page2 = { ...entry.pages?.[pageId], lastMessageId: messageId };
1020
+ if (messageId === void 0) delete page2.lastMessageId;
1021
+ entry.pages ??= {};
1022
+ if (Object.keys(page2).length === 0) delete entry.pages[pageId];
1023
+ else entry.pages[pageId] = page2;
1024
+ }
810
1025
  function credentialsPath(env, homeDir) {
811
1026
  const configHome = env.XDG_CONFIG_HOME?.trim() || join(homeDir, ".config");
812
1027
  return join(configHome, "plot", "credentials.json");
813
1028
  }
1029
+ function filesCachePath(env, homeDir, pageId) {
1030
+ const cacheHome = env.XDG_CACHE_HOME?.trim() || join(homeDir, ".cache");
1031
+ return join(cacheHome, "plot", "files", pageId);
1032
+ }
814
1033
  async function loadStore(path) {
815
1034
  let text;
816
1035
  try {
@@ -935,7 +1154,7 @@ async function withToken(rt, pageId, fn) {
935
1154
  });
936
1155
  if (candidates.length === 0) {
937
1156
  throw new CliError(
938
- pageId ? `No credential for page ${pageId} on ${rt.origin}. Redeem the code you were given with "plot open temp_code_\u2026", or connect a workspace with "plot login".` : `No workspace credential for ${rt.origin}. Run "plot login" (a code from "plot open" reaches only its one page).`
1157
+ pageId ? `No credential for page ${pageId} on ${rt.origin}. Redeem the code you were given with "plot open temp_code_\u2026", or connect a workspace with "plot login".` : `No workspace credential for ${rt.origin}. Run "plot login", or set PLOT_TOKEN to a token from Plot's Settings > Agents (a code from "plot open" reaches only its one page).`
939
1158
  );
940
1159
  }
941
1160
  for (let i = 0; i < candidates.length; i++) {
@@ -960,10 +1179,8 @@ async function withToken(rt, pageId, fn) {
960
1179
  async function forget(rt, candidate, pageId) {
961
1180
  await updateOrigin(rt, (entry) => {
962
1181
  if (candidate.source === "workspace") delete entry.workspace;
963
- if (candidate.source === "page" && pageId && entry.pages?.[pageId]) {
964
- const { cursor, title } = entry.pages[pageId];
965
- if (cursor) entry.pages[pageId] = { cursor, title };
966
- else delete entry.pages[pageId];
1182
+ if (candidate.source === "page" && pageId) {
1183
+ dropPageCredential(entry, pageId);
967
1184
  }
968
1185
  });
969
1186
  }
@@ -1027,7 +1244,7 @@ async function open(rt) {
1027
1244
  rt.ctx.out(
1028
1245
  [
1029
1246
  `Opened "${title}"${by}. ${canPost ? "Read and post" : "Read-only"} access until ${session.expiresAt}; each read extends it${session.absoluteExpiresAt ? `, up to ${session.absoluteExpiresAt}` : ""}.`,
1030
- `Next: plot read ${pageId}${canPost ? `, plot post ${pageId} --body "\u2026"` : ""} (plot help agents for more)`,
1247
+ `Next: plot read ${pageId}${canPost ? `, plot message ${pageId} --body "\u2026"` : ""} (plot help agents for more)`,
1031
1248
  DATA_NOT_INSTRUCTIONS
1032
1249
  ].join("\n")
1033
1250
  );
@@ -1210,11 +1427,7 @@ async function logout(rt) {
1210
1427
  for (const [id, page2] of Object.entries(e.pages ?? {})) {
1211
1428
  if (page2.token && revoked.has(page2.token)) {
1212
1429
  if (rt.values.all) delete e.pages[id];
1213
- else {
1214
- delete page2.token;
1215
- delete page2.expiresAt;
1216
- if (!page2.cursor) delete e.pages[id];
1217
- }
1430
+ else dropPageCredential(e, id);
1218
1431
  }
1219
1432
  }
1220
1433
  });
@@ -1251,6 +1464,13 @@ function describeMe(me, source, origin) {
1251
1464
  const rows = [
1252
1465
  ["API", origin],
1253
1466
  ["Credential", `${me.scope ?? "unknown"} scope (from ${source})`],
1467
+ // Only worth a row when it is the narrower one: "can post" is what a
1468
+ // credential has always been able to do, and whether it can here still
1469
+ // depends on the page.
1470
+ [
1471
+ "Writes",
1472
+ me.capability === "draft" ? "drafts only, cannot post" : void 0
1473
+ ],
1254
1474
  ["Agent", me.agent],
1255
1475
  ["Granted by", me.grantedBy],
1256
1476
  ["Workspace", me.workspace?.name],
@@ -1266,37 +1486,178 @@ function describeMe(me, source, origin) {
1266
1486
  return rows.filter(([, value]) => value).map(([label, value]) => `${`${label}:`.padEnd(12)}${value}`).join("\n");
1267
1487
  }
1268
1488
 
1489
+ // src/commands/files.ts
1490
+ import { mkdir as mkdir2, mkdtemp, writeFile as writeFile2 } from "node:fs/promises";
1491
+ import { tmpdir } from "node:os";
1492
+ import { basename as basename2, isAbsolute, join as join2, resolve, sep } from "node:path";
1493
+ function renderFiles(files2) {
1494
+ if (files2.length === 0) return "No files on this page.";
1495
+ return files2.map((file) => {
1496
+ const parts = [file.name, file.contentType, formatSize(file.size)];
1497
+ if (file.image) parts.push(`${file.image.width}\xD7${file.image.height}`);
1498
+ parts.push(`id ${file.id}`);
1499
+ return parts.join(" \xB7 ");
1500
+ }).join("\n");
1501
+ }
1502
+ function formatSize(bytes) {
1503
+ if (bytes < 1024) return `${bytes} B`;
1504
+ const kb = bytes / 1024;
1505
+ if (kb < 1024) return `${Math.round(kb)} KB`;
1506
+ const mb = kb / 1024;
1507
+ return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)} MB`;
1508
+ }
1509
+ function safeFileName(name) {
1510
+ const base = basename2(name).replace(/[^A-Za-z0-9._-]+/g, "-");
1511
+ const trimmed = base.replace(/^[.-]+/, "").slice(0, 120);
1512
+ return trimmed === "" ? "file" : trimmed;
1513
+ }
1514
+ var EXTENSION_FOR = {
1515
+ "image/webp": ".webp",
1516
+ "image/png": ".png",
1517
+ "image/jpeg": ".jpg",
1518
+ "image/gif": ".gif",
1519
+ "image/avif": ".avif"
1520
+ };
1521
+ function nameForBytes(name, contentType) {
1522
+ const want = EXTENSION_FOR[contentType];
1523
+ if (!want) return name;
1524
+ const dot = name.lastIndexOf(".");
1525
+ const stem = dot > 0 ? name.slice(0, dot) : name;
1526
+ return name.slice(dot).toLowerCase() === want ? name : `${stem}${want}`;
1527
+ }
1528
+ async function listFiles(rt, pageId, token) {
1529
+ const read2 = await rt.api.readJson(token, pageId);
1530
+ const messages = Array.isArray(read2.body.messages) ? read2.body.messages : [];
1531
+ return messages.flatMap((message2) => message2.attachments ?? []);
1532
+ }
1533
+ function variantFlag(rt) {
1534
+ const original = rt.values.original === true;
1535
+ const asked = stringFlag(rt, "variant");
1536
+ if (asked !== void 0 && original) {
1537
+ throw new UsageError("--variant does not go with --original.");
1538
+ }
1539
+ if (original) return "original";
1540
+ if (asked === void 0) return void 0;
1541
+ if (asked !== "original" && asked !== "preview") {
1542
+ throw new UsageError(
1543
+ `--variant "${asked}" is not a variant: use original or preview.`
1544
+ );
1545
+ }
1546
+ return asked;
1547
+ }
1548
+ function contained(dir, name) {
1549
+ const path = resolve(dir, name);
1550
+ if (path !== dir && !path.startsWith(resolve(dir) + sep)) {
1551
+ throw new CliError(
1552
+ `Refusing to write outside ${dir}: that file's details do not make a filename.`
1553
+ );
1554
+ }
1555
+ return path;
1556
+ }
1557
+ async function write(path, bytes) {
1558
+ await mkdir2(join2(path, ".."), { recursive: true });
1559
+ await writeFile2(path, bytes);
1560
+ }
1561
+ async function files(rt) {
1562
+ const pageId = parsePageRef(rt.positionals[0]);
1563
+ if (rt.positionals.length > 1) {
1564
+ throw new UsageError(`Unexpected argument "${rt.positionals[1]}".`);
1565
+ }
1566
+ const id = stringFlag(rt, "get");
1567
+ if (id === void 0 && rt.values.get !== void 0) {
1568
+ throw new UsageError(
1569
+ '--get needs the id of a file. Run "plot files" for them.'
1570
+ );
1571
+ }
1572
+ const variant = variantFlag(rt);
1573
+ const out = stringFlag(rt, "out");
1574
+ if (id === void 0 && (out !== void 0 || variant !== void 0)) {
1575
+ throw new UsageError(
1576
+ "--out, --variant and --original only apply with --get <id>."
1577
+ );
1578
+ }
1579
+ if (id === void 0) {
1580
+ const list = await withToken(
1581
+ rt,
1582
+ pageId,
1583
+ (token) => listFiles(rt, pageId, token)
1584
+ );
1585
+ if (rt.values.json) printJson(rt, list);
1586
+ else rt.ctx.out(renderFiles(list));
1587
+ return;
1588
+ }
1589
+ const { found, bytes } = await withToken(rt, pageId, async (token) => {
1590
+ const found2 = await rt.api.attachment(token, pageId, id, variant);
1591
+ return { found: found2, bytes: await rt.api.download(found2.url) };
1592
+ });
1593
+ if (out === "-") {
1594
+ rt.ctx.writeStdout(bytes);
1595
+ return;
1596
+ }
1597
+ const label = found.variant === "preview" || found.variant === "original" ? found.variant : "file";
1598
+ const name = `${safeFileName(id)}-${label}-${nameForBytes(
1599
+ safeFileName(found.name),
1600
+ found.urlContentType
1601
+ )}`;
1602
+ const dir = filesCachePath(rt.ctx.env, rt.ctx.homeDir, pageId);
1603
+ const chosen = out === void 0 ? contained(dir, name) : isAbsolute(out) ? out : resolve(rt.ctx.cwd, out);
1604
+ let path = chosen;
1605
+ try {
1606
+ await write(chosen, bytes);
1607
+ } catch (err) {
1608
+ if (out !== void 0) {
1609
+ throw new CliError(`Could not write ${chosen}: ${err.message}`);
1610
+ }
1611
+ path = contained(await mkdtemp(join2(tmpdir(), "plot-files-")), name);
1612
+ try {
1613
+ await write(path, bytes);
1614
+ } catch (second) {
1615
+ throw new CliError(
1616
+ `Could not write ${chosen} (${err.message}) or ${path} (${second.message}).`
1617
+ );
1618
+ }
1619
+ rt.ctx.err(
1620
+ `Could not write under ${filesCachePath(rt.ctx.env, rt.ctx.homeDir, pageId)}; used the temp directory instead. A temp directory may differ between calls in a sandbox, so read this file now rather than later.`
1621
+ );
1622
+ }
1623
+ if (rt.values.json) {
1624
+ printJson(rt, { ...found, path });
1625
+ } else {
1626
+ rt.ctx.out(path);
1627
+ }
1628
+ }
1629
+
1269
1630
  // src/commands/mcpConfig.ts
1270
- import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
1271
- import { dirname as dirname2, join as join2 } from "node:path";
1631
+ import { chmod as chmod2, mkdir as mkdir3, readFile as readFile2, writeFile as writeFile3 } from "node:fs/promises";
1632
+ import { dirname as dirname2, join as join3 } from "node:path";
1272
1633
  var withHeaders = (headers) => headers ? { headers } : {};
1273
1634
  var HOSTS = {
1274
1635
  "claude-code": {
1275
1636
  root: "mcpServers",
1276
1637
  server: (url, headers) => ({ type: "http", url, ...withHeaders(headers) }),
1277
- file: (rt) => join2(rt.ctx.cwd, ".mcp.json"),
1638
+ file: (rt) => join3(rt.ctx.cwd, ".mcp.json"),
1278
1639
  where: ".mcp.json in the project",
1279
1640
  envRef: (name) => `\${${name}}`
1280
1641
  },
1281
1642
  cursor: {
1282
1643
  root: "mcpServers",
1283
1644
  server: (url, headers) => ({ url, ...withHeaders(headers) }),
1284
- file: (rt) => join2(rt.ctx.homeDir, ".cursor", "mcp.json"),
1645
+ file: (rt) => join3(rt.ctx.homeDir, ".cursor", "mcp.json"),
1285
1646
  where: "~/.cursor/mcp.json",
1286
1647
  envRef: (name) => `\${env:${name}}`
1287
1648
  },
1288
1649
  vscode: {
1289
1650
  root: "servers",
1290
1651
  server: (url, headers) => ({ type: "http", url, ...withHeaders(headers) }),
1291
- file: (rt) => join2(rt.ctx.cwd, ".vscode", "mcp.json"),
1652
+ file: (rt) => join3(rt.ctx.cwd, ".vscode", "mcp.json"),
1292
1653
  where: ".vscode/mcp.json in the project",
1293
1654
  envRef: (name) => `\${env:${name}}`
1294
1655
  },
1295
1656
  zed: {
1296
1657
  root: "context_servers",
1297
1658
  server: (url, headers) => ({ url, ...withHeaders(headers) }),
1298
- file: (rt) => join2(
1299
- rt.ctx.env.XDG_CONFIG_HOME?.trim() || join2(rt.ctx.homeDir, ".config"),
1659
+ file: (rt) => join3(
1660
+ rt.ctx.env.XDG_CONFIG_HOME?.trim() || join3(rt.ctx.homeDir, ".config"),
1300
1661
  "zed",
1301
1662
  "settings.json"
1302
1663
  ),
@@ -1441,8 +1802,8 @@ async function mcpConfig(rt) {
1441
1802
  }
1442
1803
  const root = existing[spec.root] ?? {};
1443
1804
  existing[spec.root] = { ...root, ...block[spec.root] };
1444
- await mkdir2(dirname2(file), { recursive: true });
1445
- await writeFile2(file, `${JSON.stringify(existing, null, 2)}
1805
+ await mkdir3(dirname2(file), { recursive: true });
1806
+ await writeFile3(file, `${JSON.stringify(existing, null, 2)}
1446
1807
  `, {
1447
1808
  mode: created ? 384 : void 0
1448
1809
  });
@@ -1503,35 +1864,241 @@ async function search(rt) {
1503
1864
  if (rt.values.json) printJson(rt, result);
1504
1865
  else rt.ctx.out(renderSearch(query, result.results ?? [], result.next));
1505
1866
  }
1506
- async function post(rt) {
1507
- const pageId = parsePageRef(rt.positionals[0]);
1867
+ var MESSAGE_ID = /^[A-Za-z0-9_-]{8,64}$/;
1868
+ function checkedId(raw, what) {
1869
+ if (!MESSAGE_ID.test(raw)) {
1870
+ throw new UsageError(
1871
+ `${what} "${raw}" is not a message id (8 to 64 of A-Z a-z 0-9 _ -).`
1872
+ );
1873
+ }
1874
+ return raw;
1875
+ }
1876
+ async function messageBody(rt) {
1508
1877
  let body = typeof rt.values.body === "string" ? rt.values.body : void 0;
1509
- if (body === void 0) {
1510
- if (rt.ctx.stdinIsTTY) {
1511
- throw new UsageError("Give the message with --body, or pipe it on stdin.");
1878
+ if (body === void 0 && !rt.ctx.stdinIsTTY) body = await rt.ctx.readStdin();
1879
+ body = body?.trim();
1880
+ return body || void 0;
1881
+ }
1882
+ async function existingId(rt, pageId) {
1883
+ const named = rt.positionals[1] ?? stringFlag(rt, "id");
1884
+ if (named) return checkedId(named, "Message id");
1885
+ const remembered = (await originEntry(rt)).pages?.[pageId]?.lastMessageId;
1886
+ if (remembered) return remembered;
1887
+ throw new UsageError(
1888
+ `No message id given, and none remembered for page ${pageId}: name one with --id.`
1889
+ );
1890
+ }
1891
+ async function rememberMessage(rt, pageId, messageId) {
1892
+ await updateOrigin(rt, (entry) => setLastMessageId(entry, pageId, messageId));
1893
+ }
1894
+ function where(title, url) {
1895
+ const named = title ? `"${title}"` : "";
1896
+ if (named && url) return `${named} (${url})`;
1897
+ return named || url || "the page";
1898
+ }
1899
+ function renderCreated(status, created, title) {
1900
+ const m = created.message;
1901
+ const agent = created.agent || "your agent";
1902
+ const place = where(title, m?.url);
1903
+ const lines = [];
1904
+ if (created.replaced) {
1905
+ lines.push("Replaced the earlier draft on this lane; its id is gone.");
1906
+ }
1907
+ if (m?.status === "sent" && status === 200) {
1908
+ lines.push(
1909
+ created.agent ? `Already sent as ${created.agent}, for the person who granted access, on ${place} (id ${m.id}). Nothing new was posted.` : `Already sent by the person who granted access, from their composer, on ${place} (id ${m.id}). Nothing new was posted.`
1910
+ );
1911
+ } else if (m?.status === "sent") {
1912
+ lines.push(
1913
+ `Sent as ${agent}, for the person who granted access, on ${place} (id ${m.id}).`
1914
+ );
1915
+ } else {
1916
+ const verb = status === 201 || created.replaced ? "Drafted" : "Updated the draft";
1917
+ lines.push(
1918
+ `${verb} into the composer of the person who granted access, on ${place} (id ${m?.id}).`
1919
+ );
1920
+ if (m?.canSend === false) {
1921
+ lines.push(
1922
+ "Nobody else sees it: they edit it and send it, or discard it. This connection cannot post, so sending it is theirs to do."
1923
+ );
1924
+ } else {
1925
+ lines.push(
1926
+ "Nobody else sees it: they edit it and send it, or discard it. Send it yourself only if they ask:",
1927
+ ` plot message ${m?.pageId ?? "<page>"} --send --id ${m?.id}`
1928
+ );
1512
1929
  }
1513
- body = await rt.ctx.readStdin();
1514
1930
  }
1515
- body = body.trim();
1516
- if (!body) throw new UsageError("The message is empty.");
1517
- const id = newClientId();
1931
+ return lines.join("\n");
1932
+ }
1933
+ function renderMessage(m, title) {
1934
+ const place = where(title, m.url);
1935
+ if (m.status === "sent") {
1936
+ return `Message ${m.id} is sent: it is on ${place}${m.messageId ? ` as ${m.messageId}` : ""}.`;
1937
+ }
1938
+ const lines = [
1939
+ `Draft ${m.id} is waiting in the person's composer on ${place}${m.branchId ? `, in reply ${m.branchId}` : ""}.`
1940
+ ];
1941
+ if (m.note) lines.push(`Note: ${m.note}`);
1942
+ if (m.body !== void 0) lines.push("", m.body);
1943
+ return lines.join("\n");
1944
+ }
1945
+ async function message(rt) {
1946
+ const pageId = parsePageRef(rt.positionals[0]);
1947
+ const show = rt.values.show === true;
1948
+ const discard = rt.values.discard === true;
1949
+ const send = rt.values.send === true;
1950
+ if ([show, discard, send].filter(Boolean).length > 1) {
1951
+ throw new UsageError("Choose one of --send, --show and --discard.");
1952
+ }
1953
+ const title = (await originEntry(rt)).pages?.[pageId]?.title;
1954
+ if (show || discard) {
1955
+ for (const flag of ["body", "branch", "note"]) {
1956
+ if (rt.values[flag] !== void 0) {
1957
+ throw new UsageError(
1958
+ `--${flag} does not go with --${show ? "show" : "discard"}.`
1959
+ );
1960
+ }
1961
+ }
1962
+ if (rt.positionals.length > 2) {
1963
+ throw new UsageError("Too many arguments: <page> and at most one id.");
1964
+ }
1965
+ const id2 = await existingId(rt, pageId);
1966
+ if (show) {
1967
+ const found = await withToken(
1968
+ rt,
1969
+ pageId,
1970
+ (token) => rt.api.getMessage(token, pageId, id2)
1971
+ );
1972
+ if (rt.values.json) printJson(rt, found);
1973
+ else rt.ctx.out(renderMessage(found, title));
1974
+ return;
1975
+ }
1976
+ await withToken(
1977
+ rt,
1978
+ pageId,
1979
+ (token) => rt.api.discardMessage(token, pageId, id2)
1980
+ );
1981
+ const remembered = (await originEntry(rt)).pages?.[pageId]?.lastMessageId;
1982
+ if (remembered === id2) await rememberMessage(rt, pageId, void 0);
1983
+ if (rt.values.json) printJson(rt, { id: id2, discarded: true });
1984
+ else
1985
+ rt.ctx.out(
1986
+ `Discarded draft ${id2}; it is gone from the person's composer.`
1987
+ );
1988
+ return;
1989
+ }
1990
+ if (rt.positionals.length > 1) {
1991
+ throw new UsageError(
1992
+ `Unexpected argument "${rt.positionals[1]}". Give the words with --body, or pipe them on stdin.`
1993
+ );
1994
+ }
1995
+ const branchId = stringFlag(rt, "branch");
1996
+ const note = stringFlag(rt, "note");
1997
+ const body = await messageBody(rt);
1998
+ let id;
1999
+ if (body !== void 0) {
2000
+ const named = stringFlag(rt, "id");
2001
+ id = named ? checkedId(named, "--id") : newClientId();
2002
+ } else if (send) {
2003
+ id = await existingId(rt, pageId);
2004
+ } else {
2005
+ throw new UsageError(
2006
+ rt.ctx.stdinIsTTY ? "Give the message with --body, or pipe it on stdin." : "The message is empty."
2007
+ );
2008
+ }
1518
2009
  const created = await withToken(
1519
2010
  rt,
1520
2011
  pageId,
1521
- (token) => rt.api.post(
2012
+ (token) => rt.api.createMessage(
1522
2013
  token,
1523
2014
  pageId,
1524
- { body, branchId: stringFlag(rt, "branch"), id },
2015
+ {
2016
+ body,
2017
+ id,
2018
+ branchId,
2019
+ send,
2020
+ note
2021
+ },
1525
2022
  { sleep: rt.ctx.sleep }
1526
2023
  )
2024
+ ).catch((err) => {
2025
+ if (body === void 0 && err instanceof ApiError && err.code === "no_draft") {
2026
+ throw new CliError(
2027
+ `No draft to send here: ${id} is not a draft of yours on page ${pageId}. (${err.message})`
2028
+ );
2029
+ }
2030
+ throw err;
2031
+ });
2032
+ await rememberMessage(rt, pageId, created.body.message?.id ?? id);
2033
+ if (rt.values.json) printJson(rt, created.body);
2034
+ else rt.ctx.out(renderCreated(created.status, created.body, title));
2035
+ }
2036
+
2037
+ // src/commands/task.ts
2038
+ function statusFlag(rt) {
2039
+ const raw = stringFlag(rt, "status");
2040
+ if (raw === void 0) {
2041
+ if (rt.values.status !== void 0) throw badStatus("");
2042
+ return void 0;
2043
+ }
2044
+ const status = TASK_STATUSES.find((s) => s.toLowerCase() === raw.toLowerCase());
2045
+ if (!status) throw badStatus(raw);
2046
+ return status;
2047
+ }
2048
+ function badStatus(raw) {
2049
+ return new UsageError(
2050
+ `--status "${raw}" is not a status: use ${TASK_STATUSES.join(", ")}.`
1527
2051
  );
1528
- if (rt.values.json) printJson(rt, created);
1529
- else {
1530
- const via = created.postedByAgent ? ` via ${created.postedByAgent}` : "";
1531
- rt.ctx.out(
1532
- `Posted${via}: ${created.message?.url ?? created.message?.id ?? ""}`.trim()
1533
- );
2052
+ }
2053
+ function assigneeFlags(rt) {
2054
+ const raw = rt.values.assign;
2055
+ const given = (Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw]).map((id) => String(id).trim()).filter(Boolean);
2056
+ const all = rt.values["unassign-all"] === true;
2057
+ if (all && given.length > 0) {
2058
+ throw new UsageError("--assign does not go with --unassign-all.");
1534
2059
  }
2060
+ if (all) return [];
2061
+ if (raw !== void 0 && given.length === 0) {
2062
+ throw new UsageError("--assign needs a user id.");
2063
+ }
2064
+ return given.length > 0 ? given : void 0;
2065
+ }
2066
+ function notATask(page2, pageId) {
2067
+ const name = page2.title ? `"${page2.title}"` : pageId;
2068
+ return new CliError(
2069
+ `Not a task page: ${name}${page2.type ? ` (a ${page2.type})` : ""}. Only a task has a Status and assignees.`
2070
+ );
2071
+ }
2072
+ function renderTask(page2, task2) {
2073
+ const parts = ["Task", `Status: ${task2.status ?? "not set"}`];
2074
+ const names = task2.assignees.map((a) => a.name || a.id);
2075
+ if (names.length > 0) parts.push(`Assignees: ${names.join(", ")}`);
2076
+ if (page2.parentUrl) parts.push(`Part of ${page2.parentUrl}`);
2077
+ return page2.url ? `${parts.join(" \xB7 ")}
2078
+ ${page2.url}` : parts.join(" \xB7 ");
2079
+ }
2080
+ async function task(rt) {
2081
+ const pageId = parsePageRef(rt.positionals[0]);
2082
+ if (rt.positionals.length > 1) {
2083
+ throw new UsageError(`Unexpected argument "${rt.positionals[1]}".`);
2084
+ }
2085
+ const status = statusFlag(rt);
2086
+ const assigneeIds = assigneeFlags(rt);
2087
+ const writes = status !== void 0 || assigneeIds !== void 0;
2088
+ const result = await withToken(rt, pageId, async (token) => {
2089
+ const read2 = await rt.api.readJson(token, pageId);
2090
+ const page2 = read2.body.page;
2091
+ if (page2.type !== "task" || !page2.task) throw notATask(page2, pageId);
2092
+ if (!writes) return { page: page2, task: page2.task };
2093
+ return rt.api.updateTask(token, pageId, { status, assigneeIds }).catch((err) => {
2094
+ if (err instanceof ApiError && err.code === "not_a_task") {
2095
+ throw notATask(page2, pageId);
2096
+ }
2097
+ throw err;
2098
+ });
2099
+ });
2100
+ if (rt.values.json) printJson(rt, result.task);
2101
+ else rt.ctx.out(renderTask(result.page, result.task));
1535
2102
  }
1536
2103
 
1537
2104
  // src/commands/watch.ts
@@ -1638,13 +2205,38 @@ var COMMANDS = {
1638
2205
  }
1639
2206
  },
1640
2207
  topics: { run: topics },
2208
+ files: {
2209
+ run: files,
2210
+ options: {
2211
+ get: { type: "string" },
2212
+ out: { type: "string" },
2213
+ variant: { type: "string" },
2214
+ original: { type: "boolean" }
2215
+ }
2216
+ },
2217
+ task: {
2218
+ run: task,
2219
+ options: {
2220
+ status: { type: "string" },
2221
+ assign: { type: "string", multiple: true },
2222
+ "unassign-all": { type: "boolean" }
2223
+ }
2224
+ },
1641
2225
  search: {
1642
2226
  run: search,
1643
2227
  options: { next: { type: "string" }, limit: { type: "string" } }
1644
2228
  },
1645
- post: {
1646
- run: post,
1647
- options: { branch: { type: "string" }, body: { type: "string" } }
2229
+ message: {
2230
+ run: message,
2231
+ options: {
2232
+ branch: { type: "string" },
2233
+ body: { type: "string" },
2234
+ note: { type: "string" },
2235
+ id: { type: "string" },
2236
+ send: { type: "boolean" },
2237
+ show: { type: "boolean" },
2238
+ discard: { type: "boolean" }
2239
+ }
1648
2240
  },
1649
2241
  "mcp-config": {
1650
2242
  run: mcpConfig,
@@ -1719,8 +2311,8 @@ import { createServer } from "node:http";
1719
2311
  function startLoopback(state) {
1720
2312
  let resolveCode;
1721
2313
  let rejectCode;
1722
- const code = new Promise((resolve, reject) => {
1723
- resolveCode = resolve;
2314
+ const code = new Promise((resolve2, reject) => {
2315
+ resolveCode = resolve2;
1724
2316
  rejectCode = reject;
1725
2317
  });
1726
2318
  code.catch(() => {
@@ -1769,13 +2361,13 @@ function startLoopback(state) {
1769
2361
  );
1770
2362
  resolveCode(value);
1771
2363
  });
1772
- return new Promise((resolve, reject) => {
2364
+ return new Promise((resolve2, reject) => {
1773
2365
  server.once("error", reject);
1774
2366
  server.listen(0, "127.0.0.1", () => {
1775
2367
  server.off("error", reject);
1776
2368
  const { port } = server.address();
1777
2369
  let closed = false;
1778
- resolve({
2370
+ resolve2({
1779
2371
  redirectUri: `http://127.0.0.1:${port}/callback`,
1780
2372
  code,
1781
2373
  close: () => {
@@ -1796,8 +2388,8 @@ function oauthFailure(error, description) {
1796
2388
  }
1797
2389
  return new CliError(`Login failed: the browser returned "${error}".`);
1798
2390
  }
1799
- function page(res, status, title, message) {
1800
- const body = `<!doctype html><meta charset="utf-8"><title>${escape(title)}</title><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem"><h1 style="font-size:1.25rem">${escape(title)}</h1><p>${escape(message)}</p></body>`;
2391
+ function page(res, status, title, message2) {
2392
+ const body = `<!doctype html><meta charset="utf-8"><title>${escape(title)}</title><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem"><h1 style="font-size:1.25rem">${escape(title)}</h1><p>${escape(message2)}</p></body>`;
1801
2393
  res.writeHead(status, {
1802
2394
  "Content-Type": "text/html; charset=utf-8",
1803
2395
  "Cache-Control": "no-store",
@@ -1821,13 +2413,14 @@ function nodeContext() {
1821
2413
  `),
1822
2414
  err: (text) => process.stderr.write(text.endsWith("\n") ? text : `${text}
1823
2415
  `),
2416
+ writeStdout: (bytes) => process.stdout.write(bytes),
1824
2417
  readStdin: async () => {
1825
2418
  const chunks = [];
1826
2419
  for await (const chunk of process.stdin) chunks.push(chunk);
1827
2420
  return Buffer.concat(chunks).toString("utf8");
1828
2421
  },
1829
2422
  stdinIsTTY: Boolean(process.stdin.isTTY),
1830
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
2423
+ sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)),
1831
2424
  now: () => Date.now(),
1832
2425
  openUrl: (url) => openInBrowser(url, process.platform),
1833
2426
  listenLoopback: (state) => startLoopback(state).catch(() => void 0),
@@ -1838,8 +2431,8 @@ function nodeContext() {
1838
2431
  };
1839
2432
  }
1840
2433
  function promptLine(question, signal) {
1841
- return new Promise((resolve) => {
1842
- if (signal.aborted) return resolve(void 0);
2434
+ return new Promise((resolve2) => {
2435
+ if (signal.aborted) return resolve2(void 0);
1843
2436
  const rl = createInterface({
1844
2437
  input: process.stdin,
1845
2438
  output: process.stderr,
@@ -1847,7 +2440,7 @@ function promptLine(question, signal) {
1847
2440
  });
1848
2441
  let answered = false;
1849
2442
  rl.once("close", () => {
1850
- if (!answered) resolve(void 0);
2443
+ if (!answered) resolve2(void 0);
1851
2444
  });
1852
2445
  signal.addEventListener(
1853
2446
  "abort",
@@ -1861,23 +2454,23 @@ function promptLine(question, signal) {
1861
2454
  rl.question(question, (answer) => {
1862
2455
  answered = true;
1863
2456
  rl.close();
1864
- resolve(answer);
2457
+ resolve2(answer);
1865
2458
  });
1866
2459
  });
1867
2460
  }
1868
2461
  function openInBrowser(url, platform) {
1869
2462
  const command = platform === "darwin" ? "open" : platform === "linux" ? "xdg-open" : null;
1870
2463
  if (!command) return Promise.resolve(false);
1871
- return new Promise((resolve) => {
2464
+ return new Promise((resolve2) => {
1872
2465
  try {
1873
2466
  const child = spawn(command, [url], { stdio: "ignore", detached: true });
1874
- child.once("error", () => resolve(false));
2467
+ child.once("error", () => resolve2(false));
1875
2468
  child.once("spawn", () => {
1876
2469
  child.unref();
1877
- resolve(true);
2470
+ resolve2(true);
1878
2471
  });
1879
2472
  } catch {
1880
- resolve(false);
2473
+ resolve2(false);
1881
2474
  }
1882
2475
  });
1883
2476
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plotio/cli",
3
- "version": "0.3.0",
4
- "description": "Read Plot pages, follow a meeting as it happens, and post back, from any agent with a shell.",
3
+ "version": "0.5.0",
4
+ "description": "Read Plot pages, follow a meeting as it happens, and draft or send messages, from any agent with a shell.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "bin": {