@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.
- package/README.md +6 -1
- package/dist/plot.js +403 -33
- 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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1355
|
-
import { dirname as dirname2, join as
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
1383
|
-
rt.ctx.env.XDG_CONFIG_HOME?.trim() ||
|
|
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
|
|
1529
|
-
await
|
|
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,
|
|
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,
|
|
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((
|
|
1946
|
-
resolveCode =
|
|
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((
|
|
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
|
-
|
|
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((
|
|
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((
|
|
2065
|
-
if (signal.aborted) return
|
|
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)
|
|
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
|
-
|
|
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((
|
|
2464
|
+
return new Promise((resolve2) => {
|
|
2095
2465
|
try {
|
|
2096
2466
|
const child = spawn(command, [url], { stdio: "ignore", detached: true });
|
|
2097
|
-
child.once("error", () =>
|
|
2467
|
+
child.once("error", () => resolve2(false));
|
|
2098
2468
|
child.once("spawn", () => {
|
|
2099
2469
|
child.unref();
|
|
2100
|
-
|
|
2470
|
+
resolve2(true);
|
|
2101
2471
|
});
|
|
2102
2472
|
} catch {
|
|
2103
|
-
|
|
2473
|
+
resolve2(false);
|
|
2104
2474
|
}
|
|
2105
2475
|
});
|
|
2106
2476
|
}
|