@plotio/cli 0.4.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 +6 -1
  2. package/dist/plot.js +403 -33
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -38,6 +38,10 @@ a hash. Each is kept once redeemed, so later shells need nothing.
38
38
  `PLOT_API_URL` unless it is production) and every command works with no login
39
39
  and nothing stored, which is what a sandbox or a CI job wants.
40
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**.
44
+
41
45
  `plot login` ends three ways, and what it can see decides which:
42
46
 
43
47
  - **This machine's browser.** It opens, you approve, and it hands the terminal
@@ -63,9 +67,10 @@ a hash. Each is kept once redeemed, so later shells need nothing.
63
67
  | `plot read <page> [--json]` | The page as Markdown (topics included), or as JSON; stores the updates cursor |
64
68
  | `plot watch <page> [--level …] [options]` | Wait for the next update, print it, store the cursor |
65
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 |
66
71
  | `plot search <query> [--limit] [--next] [--json]` | Search every page the workspace grant can read |
67
72
  | `plot message <page> [--body <t>] [--branch] [--note] [--id]` | Draft a message into the person's composer (body from `--body`, else stdin) |
68
- | `plot message <page> --send …` | Post it as the person instead, marked "via" your agent |
73
+ | `plot message <page> --send …` | Post it now instead, shown as your agent, for the person |
69
74
  | `plot message <page> --show\|--discard [<id>]` | Read back or withdraw a draft (the last one made here by default) |
70
75
  | `plot mcp-config [--host <h>] [--token] [--write] [<page>]` | The MCP server block for a host: the `<api>/mcp` URL, signed in with OAuth |
71
76
  | `plot help agents` | The guide for an AI agent |
package/dist/plot.js CHANGED
@@ -21,7 +21,7 @@ Run "plot --help" for usage.`, 2);
21
21
  };
22
22
 
23
23
  // src/version.ts
24
- var VERSION = "0.4.0";
24
+ var VERSION = "0.5.0";
25
25
 
26
26
  // src/api.ts
27
27
  var DEFAULT_API_URL = "https://agents.plot.io";
@@ -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(
@@ -442,6 +505,8 @@ Usage: plot <command> [options]
442
505
  read <page> [--json] The page as Markdown, topics included
443
506
  watch <page> [--level <level>] Wait for what is new since the last watch
444
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
445
510
  search <query> [--limit] [--next] Search your pages (workspace login)
446
511
  message <page> [--body <text> | stdin] Draft a message into the person's composer
447
512
  message <page> --send \u2026 Or post it as them, only when they asked
@@ -478,6 +543,12 @@ Read:
478
543
  A later watch starts from the end of this read.
479
544
  plot read <page> --json The same, structured
480
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
481
552
  plot pages Pages you can read (workspace login only)
482
553
  plot search "<words>" Find a page or a moment (workspace login only)
483
554
 
@@ -493,9 +564,16 @@ Follow a meeting that is still happening:
493
564
 
494
565
  Write back (only if the person who gave you access can post):
495
566
  plot message <page> --body "\u2026" Goes into their composer as a draft for them to send
496
- plot message <page> --send --body "\u2026" Appears as the person at once, marked "via <your name>":
567
+ plot message <page> --send --body "\u2026" Appears at once, as you, for the person:
497
568
  only when they asked you to post
498
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
576
+
499
577
  <page> is a page id or a link like https://www.plot.io/p/<id>.
500
578
 
501
579
  ${DATA_NOT_INSTRUCTIONS}
@@ -572,6 +650,43 @@ stored cursor the server refuses is dropped and the watch starts over.
572
650
 
573
651
  The page's topics as JSON: title, emoji, summary and points with their
574
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.`,
575
690
  search: `plot search <query> [--limit <n>] [--next <cursor>] [--json]
576
691
 
577
692
  Searches every page the workspace grant can read: messages, topics and page
@@ -594,11 +709,12 @@ same lane replaces the first, and says so.
594
709
  makes one id, so a retry after a dropped connection lands
595
710
  once
596
711
 
597
- --send posts it now instead, as the person, marked "via <your agent>". Use it
712
+ --send posts it now instead, shown as your agent, for the person. Use it
598
713
  only when they asked you to post. With no words given it sends your earlier
599
714
  draft as it stands, formatting intact: the one --id names, else the last one
600
715
  made here. A grant that cannot post is refused (read_only), never quietly
601
- turned into a draft.
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.
602
718
 
603
719
  --show reads a draft back (its words and note), or says it was sent.
604
720
  --discard withdraws a draft; a sent message cannot be withdrawn here
@@ -606,7 +722,8 @@ turned into a draft.
606
722
  machine made on the page.
607
723
 
608
724
  Needs a member who can post: a viewer's grant is refused (read_only) for a
609
- draft as for a send.`,
725
+ draft as for a send. plot whoami says "Writes: drafts only" for a grant that
726
+ was connected without the send.`,
610
727
  "mcp-config": `plot mcp-config [--host <host>] [--write] [--token]
611
728
  [--inline-token] [<page>]
612
729
 
@@ -795,7 +912,10 @@ function renderPages(pages2, next) {
795
912
  const rows = pages2.map((page2) => {
796
913
  const extras = [
797
914
  page2.unread ? `${page2.unread} unread` : "",
798
- 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" : ""
799
919
  ].filter(Boolean);
800
920
  const when = formatTime(page2.updatedAt);
801
921
  return [
@@ -860,6 +980,11 @@ function renderUpdate(update) {
860
980
  ].filter(Boolean);
861
981
  const tag = marks.length ? ` [${marks.join(", ")}]` : "";
862
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
+ }
863
988
  }
864
989
  out.push("");
865
990
  }
@@ -901,6 +1026,10 @@ function credentialsPath(env, homeDir) {
901
1026
  const configHome = env.XDG_CONFIG_HOME?.trim() || join(homeDir, ".config");
902
1027
  return join(configHome, "plot", "credentials.json");
903
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
+ }
904
1033
  async function loadStore(path) {
905
1034
  let text;
906
1035
  try {
@@ -1335,6 +1464,13 @@ function describeMe(me, source, origin) {
1335
1464
  const rows = [
1336
1465
  ["API", origin],
1337
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
+ ],
1338
1474
  ["Agent", me.agent],
1339
1475
  ["Granted by", me.grantedBy],
1340
1476
  ["Workspace", me.workspace?.name],
@@ -1350,37 +1486,178 @@ function describeMe(me, source, origin) {
1350
1486
  return rows.filter(([, value]) => value).map(([label, value]) => `${`${label}:`.padEnd(12)}${value}`).join("\n");
1351
1487
  }
1352
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
+
1353
1630
  // src/commands/mcpConfig.ts
1354
- import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
1355
- 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";
1356
1633
  var withHeaders = (headers) => headers ? { headers } : {};
1357
1634
  var HOSTS = {
1358
1635
  "claude-code": {
1359
1636
  root: "mcpServers",
1360
1637
  server: (url, headers) => ({ type: "http", url, ...withHeaders(headers) }),
1361
- file: (rt) => join2(rt.ctx.cwd, ".mcp.json"),
1638
+ file: (rt) => join3(rt.ctx.cwd, ".mcp.json"),
1362
1639
  where: ".mcp.json in the project",
1363
1640
  envRef: (name) => `\${${name}}`
1364
1641
  },
1365
1642
  cursor: {
1366
1643
  root: "mcpServers",
1367
1644
  server: (url, headers) => ({ url, ...withHeaders(headers) }),
1368
- file: (rt) => join2(rt.ctx.homeDir, ".cursor", "mcp.json"),
1645
+ file: (rt) => join3(rt.ctx.homeDir, ".cursor", "mcp.json"),
1369
1646
  where: "~/.cursor/mcp.json",
1370
1647
  envRef: (name) => `\${env:${name}}`
1371
1648
  },
1372
1649
  vscode: {
1373
1650
  root: "servers",
1374
1651
  server: (url, headers) => ({ type: "http", url, ...withHeaders(headers) }),
1375
- file: (rt) => join2(rt.ctx.cwd, ".vscode", "mcp.json"),
1652
+ file: (rt) => join3(rt.ctx.cwd, ".vscode", "mcp.json"),
1376
1653
  where: ".vscode/mcp.json in the project",
1377
1654
  envRef: (name) => `\${env:${name}}`
1378
1655
  },
1379
1656
  zed: {
1380
1657
  root: "context_servers",
1381
1658
  server: (url, headers) => ({ url, ...withHeaders(headers) }),
1382
- file: (rt) => join2(
1383
- 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"),
1384
1661
  "zed",
1385
1662
  "settings.json"
1386
1663
  ),
@@ -1525,8 +1802,8 @@ async function mcpConfig(rt) {
1525
1802
  }
1526
1803
  const root = existing[spec.root] ?? {};
1527
1804
  existing[spec.root] = { ...root, ...block[spec.root] };
1528
- await mkdir2(dirname2(file), { recursive: true });
1529
- await writeFile2(file, `${JSON.stringify(existing, null, 2)}
1805
+ await mkdir3(dirname2(file), { recursive: true });
1806
+ await writeFile3(file, `${JSON.stringify(existing, null, 2)}
1530
1807
  `, {
1531
1808
  mode: created ? 384 : void 0
1532
1809
  });
@@ -1629,19 +1906,27 @@ function renderCreated(status, created, title) {
1629
1906
  }
1630
1907
  if (m?.status === "sent" && status === 200) {
1631
1908
  lines.push(
1632
- created.agent ? `Already sent as the person who granted access, marked "via ${created.agent}", 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.`
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.`
1633
1910
  );
1634
1911
  } else if (m?.status === "sent") {
1635
1912
  lines.push(
1636
- `Sent as the person who granted access, marked "via ${agent}", on ${place} (id ${m.id}).`
1913
+ `Sent as ${agent}, for the person who granted access, on ${place} (id ${m.id}).`
1637
1914
  );
1638
1915
  } else {
1639
1916
  const verb = status === 201 || created.replaced ? "Drafted" : "Updated the draft";
1640
1917
  lines.push(
1641
- `${verb} into the composer of the person who granted access, on ${place} (id ${m?.id}).`,
1642
- "Nobody else sees it: they edit it and send it, or discard it. Send it yourself only if they ask:",
1643
- ` plot message ${m?.pageId ?? "<page>"} --send --id ${m?.id}`
1918
+ `${verb} into the composer of the person who granted access, on ${place} (id ${m?.id}).`
1644
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
+ );
1929
+ }
1645
1930
  }
1646
1931
  return lines.join("\n");
1647
1932
  }
@@ -1749,6 +2034,73 @@ async function message(rt) {
1749
2034
  else rt.ctx.out(renderCreated(created.status, created.body, title));
1750
2035
  }
1751
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(", ")}.`
2051
+ );
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.");
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));
2102
+ }
2103
+
1752
2104
  // src/commands/watch.ts
1753
2105
  var DEFAULT_INTERVAL_SECONDS = 3;
1754
2106
  function seconds(rt, key, fallback) {
@@ -1853,6 +2205,23 @@ var COMMANDS = {
1853
2205
  }
1854
2206
  },
1855
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
+ },
1856
2225
  search: {
1857
2226
  run: search,
1858
2227
  options: { next: { type: "string" }, limit: { type: "string" } }
@@ -1942,8 +2311,8 @@ import { createServer } from "node:http";
1942
2311
  function startLoopback(state) {
1943
2312
  let resolveCode;
1944
2313
  let rejectCode;
1945
- const code = new Promise((resolve, reject) => {
1946
- resolveCode = resolve;
2314
+ const code = new Promise((resolve2, reject) => {
2315
+ resolveCode = resolve2;
1947
2316
  rejectCode = reject;
1948
2317
  });
1949
2318
  code.catch(() => {
@@ -1992,13 +2361,13 @@ function startLoopback(state) {
1992
2361
  );
1993
2362
  resolveCode(value);
1994
2363
  });
1995
- return new Promise((resolve, reject) => {
2364
+ return new Promise((resolve2, reject) => {
1996
2365
  server.once("error", reject);
1997
2366
  server.listen(0, "127.0.0.1", () => {
1998
2367
  server.off("error", reject);
1999
2368
  const { port } = server.address();
2000
2369
  let closed = false;
2001
- resolve({
2370
+ resolve2({
2002
2371
  redirectUri: `http://127.0.0.1:${port}/callback`,
2003
2372
  code,
2004
2373
  close: () => {
@@ -2044,13 +2413,14 @@ function nodeContext() {
2044
2413
  `),
2045
2414
  err: (text) => process.stderr.write(text.endsWith("\n") ? text : `${text}
2046
2415
  `),
2416
+ writeStdout: (bytes) => process.stdout.write(bytes),
2047
2417
  readStdin: async () => {
2048
2418
  const chunks = [];
2049
2419
  for await (const chunk of process.stdin) chunks.push(chunk);
2050
2420
  return Buffer.concat(chunks).toString("utf8");
2051
2421
  },
2052
2422
  stdinIsTTY: Boolean(process.stdin.isTTY),
2053
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
2423
+ sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)),
2054
2424
  now: () => Date.now(),
2055
2425
  openUrl: (url) => openInBrowser(url, process.platform),
2056
2426
  listenLoopback: (state) => startLoopback(state).catch(() => void 0),
@@ -2061,8 +2431,8 @@ function nodeContext() {
2061
2431
  };
2062
2432
  }
2063
2433
  function promptLine(question, signal) {
2064
- return new Promise((resolve) => {
2065
- if (signal.aborted) return resolve(void 0);
2434
+ return new Promise((resolve2) => {
2435
+ if (signal.aborted) return resolve2(void 0);
2066
2436
  const rl = createInterface({
2067
2437
  input: process.stdin,
2068
2438
  output: process.stderr,
@@ -2070,7 +2440,7 @@ function promptLine(question, signal) {
2070
2440
  });
2071
2441
  let answered = false;
2072
2442
  rl.once("close", () => {
2073
- if (!answered) resolve(void 0);
2443
+ if (!answered) resolve2(void 0);
2074
2444
  });
2075
2445
  signal.addEventListener(
2076
2446
  "abort",
@@ -2084,23 +2454,23 @@ function promptLine(question, signal) {
2084
2454
  rl.question(question, (answer) => {
2085
2455
  answered = true;
2086
2456
  rl.close();
2087
- resolve(answer);
2457
+ resolve2(answer);
2088
2458
  });
2089
2459
  });
2090
2460
  }
2091
2461
  function openInBrowser(url, platform) {
2092
2462
  const command = platform === "darwin" ? "open" : platform === "linux" ? "xdg-open" : null;
2093
2463
  if (!command) return Promise.resolve(false);
2094
- return new Promise((resolve) => {
2464
+ return new Promise((resolve2) => {
2095
2465
  try {
2096
2466
  const child = spawn(command, [url], { stdio: "ignore", detached: true });
2097
- child.once("error", () => resolve(false));
2467
+ child.once("error", () => resolve2(false));
2098
2468
  child.once("spawn", () => {
2099
2469
  child.unref();
2100
- resolve(true);
2470
+ resolve2(true);
2101
2471
  });
2102
2472
  } catch {
2103
- resolve(false);
2473
+ resolve2(false);
2104
2474
  }
2105
2475
  });
2106
2476
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plotio/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
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",