@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.
- package/README.md +29 -17
- package/dist/plot.js +682 -89
- 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
|
|
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
|
|
52
|
-
|
|
|
53
|
-
| `plot open <temp_code_…> [--agent <name>]`
|
|
54
|
-
| `plot login [--agent] [--no-browser]`
|
|
55
|
-
| `plot login --code <code>`
|
|
56
|
-
| `plot logout [<page>] [--all]`
|
|
57
|
-
| `plot whoami [<page>] [--json]`
|
|
58
|
-
| `plot pages [--limit <n>] [--next <c>] [--json]`
|
|
59
|
-
| `plot read <page> [--json]`
|
|
60
|
-
| `plot watch <page> [--level …] [options]`
|
|
61
|
-
| `plot topics <page>`
|
|
62
|
-
| `plot
|
|
63
|
-
| `plot
|
|
64
|
-
| `plot
|
|
65
|
-
| `plot
|
|
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(
|
|
10
|
-
super(
|
|
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(
|
|
17
|
-
super(`${
|
|
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.
|
|
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,
|
|
33
|
-
super(
|
|
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
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
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
|
|
261
|
-
const body = {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
300
|
-
return new ApiError(response.status, code,
|
|
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
|
-
|
|
410
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
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
|
|
964
|
-
|
|
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
|
|
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
|
|
1271
|
-
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";
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
1299
|
-
rt.ctx.env.XDG_CONFIG_HOME?.trim() ||
|
|
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
|
|
1445
|
-
await
|
|
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
|
-
|
|
1507
|
-
|
|
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
|
-
|
|
1511
|
-
|
|
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
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
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.
|
|
2012
|
+
(token) => rt.api.createMessage(
|
|
1522
2013
|
token,
|
|
1523
2014
|
pageId,
|
|
1524
|
-
{
|
|
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
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
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
|
-
|
|
1646
|
-
run:
|
|
1647
|
-
options: {
|
|
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((
|
|
1723
|
-
resolveCode =
|
|
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((
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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((
|
|
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((
|
|
1842
|
-
if (signal.aborted) return
|
|
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)
|
|
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
|
-
|
|
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((
|
|
2464
|
+
return new Promise((resolve2) => {
|
|
1872
2465
|
try {
|
|
1873
2466
|
const child = spawn(command, [url], { stdio: "ignore", detached: true });
|
|
1874
|
-
child.once("error", () =>
|
|
2467
|
+
child.once("error", () => resolve2(false));
|
|
1875
2468
|
child.once("spawn", () => {
|
|
1876
2469
|
child.unref();
|
|
1877
|
-
|
|
2470
|
+
resolve2(true);
|
|
1878
2471
|
});
|
|
1879
2472
|
} catch {
|
|
1880
|
-
|
|
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.
|
|
4
|
-
"description": "Read Plot pages, follow a meeting as it happens, and
|
|
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": {
|