sumibako 0.2.1 → 0.2.2

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/index.mjs +969 -911
  2. package/package.json +1 -1
  3. package/SKILL.md +0 -235
package/index.mjs CHANGED
@@ -1,911 +1,969 @@
1
- #!/usr/bin/env node
2
- /**
3
- * sumibako - file what your coding agent wrote into your vault.
4
- *
5
- * Zero dependencies, one file, Node 18 or newer. That is a deliberate ceiling:
6
- * this runs inside somebody else's agent session, often on a machine where the
7
- * install has to be instant and silent, and a dependency tree is a thing that
8
- * can break there in ways nobody will debug.
9
- *
10
- * Why a CLI at all, when the same job could be an MCP server. Two reasons that
11
- * matter in practice. An MCP tool call carries the document through the model's
12
- * context to get it to the server, so filing a 30KB plan means re-emitting
13
- * 30KB of tokens; `sumibako publish plan.md` sends a file the agent has already
14
- * written to disk, and the model never sees it twice. And a shell exists in
15
- * every coding agent there is, while MCP support differs between them and
16
- * changes with the spec. An MCP server can come later and reuse this same API.
17
- */
18
-
19
- import { spawn } from "node:child_process";
20
- import crypto from "node:crypto";
21
- import fs from "node:fs";
22
- import os from "node:os";
23
- import path from "node:path";
24
- import process from "node:process";
25
-
26
- const VERSION = "0.2.0";
27
-
28
- /**
29
- * Exit code for "this machine is not connected yet".
30
- *
31
- * Distinct from 1 so that the thing reading this output can tell a missing
32
- * credential from a failed command. That distinction is the whole reason the
33
- * connect flow works from inside an agent session: the skill tells the agent
34
- * that 3 means "show the user the link and run the same command again", and
35
- * every other non-zero code means the command genuinely failed.
36
- */
37
- const EXIT_NEEDS_AUTH = 3;
38
-
39
- /** Where the token lives when it is not in the environment. */
40
- const CONFIG_DIR = path.join(os.homedir(), ".sumibako");
41
- const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
42
-
43
- /**
44
- * Where the API lives.
45
- *
46
- * The app's own domain rather than the Convex deployment behind it. This string
47
- * ends up in config files, CI secrets and other people's shell history, so it
48
- * has to be one that stays true - `next.config.mjs` rewrites it onto whichever
49
- * deployment is current.
50
- */
51
- const DEFAULT_API = "https://sumibako.com/api/agent";
52
-
53
- // ---------------------------------------------------------------------------
54
- // Output
55
- // ---------------------------------------------------------------------------
56
-
57
- const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
58
- /**
59
- * ANSI colour, with the escape byte written as an escape rather than typed.
60
- *
61
- * A literal escape character in source is invisible in every diff and every
62
- * review, and survives exactly until something normalises the file.
63
- */
64
- const ESC = "\u001b";
65
- const paint = (code, text) =>
66
- useColor ? `${ESC}[${code}m${text}${ESC}[0m` : text;
67
- const dim = (text) => paint("2", text);
68
- const bold = (text) => paint("1", text);
69
- const green = (text) => paint("32", text);
70
- const red = (text) => paint("31", text);
71
- const yellow = (text) => paint("33", text);
72
-
73
- function die(message, hint) {
74
- console.error(`${red("error")} ${message}`);
75
- if (hint) console.error(dim(` ${hint}`));
76
- process.exit(1);
77
- }
78
-
79
- /**
80
- * Stops with the "connect this machine" instructions, and exit code 3.
81
- *
82
- * Written to stdout rather than stderr, unlike every other failure here. This
83
- * is not an error report: it is a link somebody has to open, and the caller is
84
- * as often an agent relaying it into a chat window as a person reading a
85
- * terminal. Errors go to stderr because they are diagnostics; this is content.
86
- */
87
- function needsAuth(url, code) {
88
- console.log(`${bold("Connect this machine to Sumibako:")}`);
89
- console.log(url);
90
- console.log();
91
- console.log(dim(`It should show the code ${bold(code)}. If it does not, the`));
92
- console.log(dim("page belongs to a different request - close it."));
93
- console.log();
94
- console.log(dim("Then run the same command again."));
95
- process.exit(EXIT_NEEDS_AUTH);
96
- }
97
-
98
- // ---------------------------------------------------------------------------
99
- // Config
100
- // ---------------------------------------------------------------------------
101
-
102
- function readConfig() {
103
- try {
104
- return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
105
- } catch {
106
- return {};
107
- }
108
- }
109
-
110
- function writeConfig(config) {
111
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
112
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", {
113
- // The file holds a live credential. 0600 is the difference between "only
114
- // me" and "every process running as any user on this machine", and it
115
- // costs one argument. Windows ignores the mode, which is why the token is
116
- // also accepted from the environment.
117
- mode: 0o600,
118
- });
119
- try {
120
- fs.chmodSync(CONFIG_FILE, 0o600);
121
- } catch {
122
- // Best effort: some filesystems do not support it.
123
- }
124
- }
125
-
126
- /**
127
- * The token, from the environment first and the config file second.
128
- *
129
- * Environment first so CI can inject one without writing to a home directory
130
- * that may not persist, and so a person can override a stale saved token for
131
- * one command without editing a file.
132
- */
133
- function resolveToken() {
134
- const fromEnv = process.env.SUMIBAKO_TOKEN?.trim();
135
- if (fromEnv) return fromEnv;
136
- return readConfig().token ?? null;
137
- }
138
-
139
- function resolveApi() {
140
- return (
141
- process.env.SUMIBAKO_API?.trim().replace(/\/+$/, "") ||
142
- readConfig().api ||
143
- DEFAULT_API
144
- );
145
- }
146
-
147
- // ---------------------------------------------------------------------------
148
- // HTTP
149
- // ---------------------------------------------------------------------------
150
-
151
- /**
152
- * One request, with the two failures that are about the address rather than
153
- * the call: an unreachable host, and a server that answered but not as this
154
- * API. Everything else is handed back for the caller to interpret.
155
- */
156
- async function rawCall(method, base, endpoint, { body, query, token } = {}) {
157
- const url = new URL(base + endpoint);
158
- for (const [key, value] of Object.entries(query ?? {})) {
159
- if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
160
- }
161
-
162
- let response;
163
- try {
164
- response = await fetch(url, {
165
- method,
166
- headers: {
167
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
168
- ...(body ? { "Content-Type": "application/json" } : {}),
169
- },
170
- body: body ? JSON.stringify(body) : undefined,
171
- });
172
- } catch (error) {
173
- die(
174
- `Could not reach ${url.origin}.`,
175
- error instanceof Error ? error.message : undefined,
176
- );
177
- }
178
-
179
- const text = await response.text();
180
- const contentType = response.headers.get("content-type") ?? "";
181
-
182
- let payload = null;
183
- if (text && contentType.includes("json")) {
184
- try {
185
- payload = JSON.parse(text);
186
- } catch {
187
- // Handled below, along with every other not-our-API response.
188
- }
189
- }
190
-
191
- /*
192
- Reached a server, but not this API.
193
-
194
- Overwhelmingly this means the base URL points at the web app rather than the
195
- API, and the app answered with an HTML page. Printing that page is how a
196
- one-line configuration mistake turns into a screenful of markup with the
197
- actual problem nowhere in it, so the body is deliberately not shown.
198
- */
199
- if (payload === null) {
200
- die(
201
- `${base} is not answering as the Sumibako API (HTTP ${response.status}, ${contentType || "no content type"}).`,
202
- "Check the address: pass --api <url>, or set SUMIBAKO_API.",
203
- );
204
- }
205
-
206
- return { response, payload, text };
207
- }
208
-
209
- async function callApi(method, endpoint, { body, token, query, api } = {}) {
210
- const base = api ?? resolveApi();
211
-
212
- /*
213
- A token, or the connect flow, before anything is sent.
214
-
215
- `ensureToken` can exit the process here, which is deliberate: every caller
216
- of this function needs a credential, and there is nothing sensible for one
217
- of them to do with "there isn't one" that is not already done better in one
218
- place.
219
- */
220
- const authToken = token ?? (await ensureToken(base));
221
-
222
- const { response, payload, text } = await rawCall(method, base, endpoint, {
223
- body,
224
- query,
225
- token: authToken,
226
- });
227
-
228
- if (!response.ok) {
229
- const message = payload?.error?.message ?? text.slice(0, 300) ?? "Request failed.";
230
- const code = payload?.error?.code;
231
-
232
- // The three failures worth explaining rather than just reporting, because
233
- // each has a next step the person cannot guess from the message alone.
234
- if (response.status === 401) {
235
- /*
236
- A saved token that the server refuses is a revoked one, and keeping it
237
- would wedge this machine: every future command would authenticate with
238
- it, fail, and print the same advice. Dropping it means the next command
239
- starts a connect flow instead, which is the thing the person was going
240
- to have to do anyway.
241
-
242
- Only when it came from the config file. A token in the environment is
243
- not ours to forget, and pretending to have forgotten it would send
244
- somebody looking in the wrong place.
245
- */
246
- if (process.env.SUMIBAKO_TOKEN?.trim()) {
247
- // Running it again would fail identically, forever: the environment
248
- // wins over the config file, so there is nothing for a connect flow
249
- // to take effect on until that variable is dealt with.
250
- die(
251
- message,
252
- "SUMIBAKO_TOKEN is set. Unset it and run the same command again to connect this machine.",
253
- );
254
- }
255
- const config = readConfig();
256
- if (config.token) {
257
- delete config.token;
258
- writeConfig(config);
259
- }
260
- die(message, "Run the same command again to connect this machine.");
261
- }
262
- if (response.status === 403) {
263
- die(message, "Create a token with publishing allowed, in Settings.");
264
- }
265
- if (response.status === 429) {
266
- const retry = response.headers.get("Retry-After");
267
- die(message, retry ? `Wait ${retry} seconds and try again.` : undefined);
268
- }
269
- die(`${message}${code ? dim(` (${code})`) : ""}`);
270
- }
271
-
272
- return payload;
273
- }
274
-
275
- // ---------------------------------------------------------------------------
276
- // Connecting a machine
277
- // ---------------------------------------------------------------------------
278
-
279
- /*
280
- Getting a token without anybody typing one.
281
-
282
- The old flow was: open the app, mint a token, copy it, paste it here. That
283
- reads as four steps and is really one problem - the paste can only be done by
284
- a person sitting at this terminal, so connecting had to happen before the
285
- agent was any use, and an agent that hit a missing token could do nothing but
286
- give up and explain.
287
-
288
- This is the device authorization grant. We hold 32 random bytes, send their
289
- hash, get a short code back, and the approval happens in a browser where the
290
- person already has a session. The bytes are what redeems the request, so the
291
- code travelling through a URL and a scrollback gives nothing away.
292
-
293
- Two properties are load-bearing for the agent case. Nothing here reads stdin,
294
- so it works with no terminal attached. And nothing blocks for long: the first
295
- run prints a link and exits, and a later run collects the token, so an agent
296
- relays one line to its user and carries on instead of sitting inside a
297
- command until the harness kills it.
298
- */
299
-
300
- /** How long a redeem waits before giving the link back to the caller. */
301
- const REDEEM_WAIT_MS = 20_000;
302
-
303
- /** Gap between redeem attempts while waiting. */
304
- const REDEEM_INTERVAL_MS = 2_000;
305
-
306
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
307
-
308
- /**
309
- * Opens a URL in the desktop browser, and does not care if it cannot.
310
- *
311
- * Called only when stdout is a terminal. An agent, a container and a CI job all
312
- * fail that test, which is exactly right: there is no browser to open there,
313
- * and the URL is already printed for whoever can open one. That one condition
314
- * covers every headless case without a flag to remember.
315
- */
316
- function openInBrowser(url) {
317
- try {
318
- const [command, args] =
319
- process.platform === "win32"
320
- ? ["cmd", ["/c", "start", "", url]]
321
- : process.platform === "darwin"
322
- ? ["open", [url]]
323
- : ["xdg-open", [url]];
324
- const child = spawn(command, args, { stdio: "ignore", detached: true });
325
- child.on("error", () => {});
326
- child.unref();
327
- } catch {
328
- // The link is on screen either way.
329
- }
330
- }
331
-
332
- /** Describes this machine for the approval page. Never a secret. */
333
- function describeClient() {
334
- return `${os.hostname()}, ${process.platform}`;
335
- }
336
-
337
- /**
338
- * Opens a connection request and remembers the half of it we must keep.
339
- *
340
- * The verifier is stored next to where the token will go, under the same 0600,
341
- * because between this call and the next command it is the thing that can
342
- * collect a credential.
343
- */
344
- async function startConnect(base) {
345
- const verifier = crypto.randomBytes(32).toString("base64url");
346
- const verifierHash = crypto
347
- .createHash("sha256")
348
- .update(verifier)
349
- .digest("hex");
350
-
351
- const { response, payload } = await rawCall("POST", base, "/v1/cli/start", {
352
- body: { verifierHash, client: describeClient() },
353
- });
354
-
355
- if (!response.ok) {
356
- die(
357
- payload?.error?.message ?? "Could not start the connection.",
358
- "Check the address: pass --api <url>, or set SUMIBAKO_API.",
359
- );
360
- }
361
-
362
- const pending = {
363
- userCode: payload.userCode,
364
- verifier,
365
- verificationUrl: payload.verificationUrl,
366
- expiresAt: payload.expiresAt,
367
- api: base,
368
- };
369
- writeConfig({ ...readConfig(), pending });
370
- return pending;
371
- }
372
-
373
- /** The pending request, if there is one and it is still worth trying. */
374
- function readPending(base) {
375
- const pending = readConfig().pending;
376
- if (!pending?.verifier || !pending?.userCode) return null;
377
- if (typeof pending.expiresAt === "number" && pending.expiresAt < Date.now()) {
378
- return null;
379
- }
380
- // A request opened against one deployment cannot be redeemed at another.
381
- if (pending.api && pending.api !== base) return null;
382
- return pending;
383
- }
384
-
385
- function clearPending() {
386
- const config = readConfig();
387
- delete config.pending;
388
- writeConfig(config);
389
- }
390
-
391
- /**
392
- * Waits for approval, up to `waitMs`, and saves the token when it arrives.
393
- *
394
- * Returns the token, or null if it is still pending. Anything that ends the
395
- * request - denied, expired, collected already - clears the saved request and
396
- * returns null, so the caller starts a fresh one rather than retrying a dead
397
- * code forever.
398
- */
399
- async function redeemPending(base, pending, waitMs) {
400
- const deadline = Date.now() + waitMs;
401
-
402
- for (;;) {
403
- const { response, payload } = await rawCall("POST", base, "/v1/cli/redeem", {
404
- body: { userCode: pending.userCode, verifier: pending.verifier },
405
- });
406
-
407
- if (!response.ok) {
408
- clearPending();
409
- return null;
410
- }
411
-
412
- if (payload.status === "approved") {
413
- const config = readConfig();
414
- delete config.pending;
415
- writeConfig({ ...config, token: payload.token });
416
- return payload;
417
- }
418
-
419
- if (payload.status !== "pending") {
420
- // Denied or expired. Either way this code is finished.
421
- clearPending();
422
- return null;
423
- }
424
-
425
- if (Date.now() + REDEEM_INTERVAL_MS > deadline) return null;
426
- await sleep(REDEEM_INTERVAL_MS);
427
- }
428
- }
429
-
430
- /**
431
- * A usable token, or the connect instructions and exit 3.
432
- *
433
- * The whole flow in one function, because every authenticated command needs
434
- * exactly this and none of them should be deciding any part of it themselves.
435
- */
436
- async function ensureToken(base) {
437
- const saved = resolveToken();
438
- if (saved) return saved;
439
-
440
- const pending = readPending(base);
441
- if (pending) {
442
- const approved = await redeemPending(base, pending, REDEEM_WAIT_MS);
443
- if (approved) return approved.token;
444
-
445
- // Still waiting: hand the same link back rather than opening a second
446
- // request, so the page the person already has open is the right one.
447
- const current = readPending(base);
448
- if (current) needsAuth(current.verificationUrl, current.userCode);
449
- }
450
-
451
- const started = await startConnect(base);
452
- needsAuth(started.verificationUrl, started.userCode);
453
- }
454
-
455
- // ---------------------------------------------------------------------------
456
- // Arguments
457
- // ---------------------------------------------------------------------------
458
-
459
- /** Parses `--flag value`, `--flag=value` and `--boolean` into an object. */
460
- function parseFlags(argv) {
461
- const flags = {};
462
- const positional = [];
463
-
464
- for (let index = 0; index < argv.length; index += 1) {
465
- const item = argv[index];
466
- if (!item.startsWith("--")) {
467
- positional.push(item);
468
- continue;
469
- }
470
- const equals = item.indexOf("=");
471
- if (equals !== -1) {
472
- flags[item.slice(2, equals)] = item.slice(equals + 1);
473
- continue;
474
- }
475
- const name = item.slice(2);
476
- const next = argv[index + 1];
477
- if (next === undefined || next.startsWith("--")) {
478
- flags[name] = true;
479
- } else {
480
- flags[name] = next;
481
- index += 1;
482
- }
483
- }
484
-
485
- return { flags, positional };
486
- }
487
-
488
- /**
489
- * The key a file is filed under, so re-running lands on one page.
490
- *
491
- * The repo-relative path, because that is the identity of an artifact that a
492
- * person and an agent will both agree on: `docs/plans/auth.md` is the plan for
493
- * auth, this week and next. Falls back to the path as given when the file sits
494
- * outside a git repository.
495
- *
496
- * Normalised to forward slashes so the same file keyed from Windows and from
497
- * CI resolves to the same page.
498
- */
499
- function defaultKey(filePath) {
500
- const absolute = path.resolve(filePath);
501
- let directory = path.dirname(absolute);
502
-
503
- for (let depth = 0; depth < 40; depth += 1) {
504
- if (fs.existsSync(path.join(directory, ".git"))) {
505
- return path.relative(directory, absolute).split(path.sep).join("/");
506
- }
507
- const parent = path.dirname(directory);
508
- if (parent === directory) break;
509
- directory = parent;
510
- }
511
-
512
- return absolute.split(path.sep).join("/");
513
- }
514
-
515
- function readMarkdown(filePath) {
516
- if (filePath === "-") {
517
- return fs.readFileSync(0, "utf8");
518
- }
519
- if (!fs.existsSync(filePath)) {
520
- die(`No such file: ${filePath}`);
521
- }
522
- const stats = fs.statSync(filePath);
523
- if (stats.isDirectory()) {
524
- die(`${filePath} is a directory. Point at one Markdown file.`);
525
- }
526
- return fs.readFileSync(filePath, "utf8");
527
- }
528
-
529
- // ---------------------------------------------------------------------------
530
- // Commands
531
- // ---------------------------------------------------------------------------
532
-
533
- /** Prints what a freshly connected machine may do. */
534
- function reportSignedIn(plan, scopes) {
535
- console.log(
536
- `${green("Connected.")} Plan ${bold(plan)}, can ${scopes
537
- .map((scope) => scope.replace("pages:", ""))
538
- .join(", ")}.`,
539
- );
540
- console.log(dim(`Token saved to ${CONFIG_FILE}`));
541
- }
542
-
543
- /**
544
- * Connects this machine.
545
- *
546
- * Three ways in, in the order they are checked. `--token` for somebody pasting
547
- * one deliberately and for scripts; a browser approval for everybody else; and
548
- * `SUMIBAKO_TOKEN` in the environment, which needs no command at all and is
549
- * why CI never reaches this function.
550
- *
551
- * The browser half waits, unlike the connect flow inside `publish`. That
552
- * difference is the point: a person who typed `login` is sitting there and
553
- * expects the command to finish when they are done approving, while an agent
554
- * that hit a missing token needs the link and its turn back.
555
- */
556
- async function login(flags) {
557
- const api = typeof flags.api === "string" ? flags.api.replace(/\/+$/, "") : undefined;
558
- const base = api ?? resolveApi();
559
-
560
- let token = typeof flags.token === "string" ? flags.token.trim() : "";
561
-
562
- // A pasted token still has to work before it is saved, so a mistyped one
563
- // fails here rather than halfway through the next session, and a failed
564
- // login leaves whatever was configured before it untouched.
565
- if (token) {
566
- const who = await callApi("GET", "/v1/whoami", { token, api });
567
- const config = readConfig();
568
- delete config.pending;
569
- writeConfig({ ...config, token, ...(api ? { api } : {}) });
570
- reportSignedIn(who.plan, who.scopes);
571
- return;
572
- }
573
-
574
- const pending = readPending(base) ?? (await startConnect(base));
575
-
576
- console.log(`Open this to connect ${bold(describeClient())}:`);
577
- console.log(pending.verificationUrl);
578
- console.log();
579
- console.log(dim(`The page should show the code ${bold(pending.userCode)}.`));
580
-
581
- if (process.stdout.isTTY) openInBrowser(pending.verificationUrl);
582
-
583
- const remaining = Math.max(0, (pending.expiresAt ?? 0) - Date.now());
584
- if (remaining === 0) {
585
- die("That request expired.", "Run `npx sumibako login` again.");
586
- }
587
-
588
- console.log();
589
- console.log(dim("Waiting for you to approve it..."));
590
-
591
- const approved = await redeemPending(base, pending, remaining);
592
- if (!approved) {
593
- die(
594
- "The request was not approved.",
595
- "Run `npx sumibako login` again, or pass --token to paste one instead.",
596
- );
597
- }
598
-
599
- if (api) writeConfig({ ...readConfig(), api });
600
- reportSignedIn(approved.plan, approved.scopes);
601
- }
602
-
603
- function logout() {
604
- const config = readConfig();
605
- delete config.token;
606
- // Also the half-finished connection, if there is one. Leaving it would let
607
- // the next command silently pick up a token from a request made before the
608
- // person asked to be signed out.
609
- delete config.pending;
610
- writeConfig(config);
611
- console.log(`${green("Signed out.")} The token was removed from this machine.`);
612
- console.log(dim("Revoke it in Settings if it may have leaked."));
613
- }
614
-
615
- async function publish(positional, flags) {
616
- const file = positional[0];
617
- if (!file) {
618
- die("Which file?", "Usage: sumibako publish <file.md> [--public]");
619
- }
620
-
621
- const markdown = readMarkdown(file);
622
- if (!markdown.trim()) die(`${file} is empty.`);
623
-
624
- const key =
625
- typeof flags.key === "string"
626
- ? flags.key
627
- : flags.new === true || file === "-"
628
- ? undefined
629
- : defaultKey(file);
630
-
631
- const wantsPublic = flags.public === true || flags.publish === true;
632
-
633
- const result = await callApi("POST", "/v1/pages", {
634
- body: {
635
- markdown,
636
- externalId: key,
637
- title: typeof flags.title === "string" ? flags.title : undefined,
638
- parentDocument: typeof flags.parent === "string" ? flags.parent : undefined,
639
- publish: wantsPublic ? true : undefined,
640
- },
641
- });
642
-
643
- report(result, { verb: result.created ? "Created" : "Updated" });
644
- }
645
-
646
- /**
647
- * Works out whether an argument names a file on disk or a page id.
648
- *
649
- * A file that exists is keyed by its repo path, the same way `publish` filed
650
- * it. Anything else is taken to be a page id, which is what someone pastes
651
- * after copying it out of a URL.
652
- */
653
- function targetFor(argument, flags) {
654
- if (typeof flags.key === "string") return { externalId: flags.key };
655
- if (!argument) return {};
656
- if (fs.existsSync(argument)) return { externalId: defaultKey(argument) };
657
- return { documentId: argument };
658
- }
659
-
660
- /**
661
- * Adds to a page without sending it back.
662
- *
663
- * The running-log case, which `publish` handles badly: filing a note at the
664
- * end of a session with `publish` means reading the whole page, adding a line
665
- * and writing all of it again. Here the text to add is the only thing that
666
- * crosses the wire.
667
- *
668
- * Words after the target are the text. With none, it is read from stdin, so
669
- * `git log -1 --format=%s | sumibako append CHANGELOG.md` works.
670
- */
671
- async function append(positional, flags) {
672
- const target = targetFor(positional[0], flags);
673
- if (!target.documentId && !target.externalId) {
674
- die("Which page?", "Usage: sumibako append <file.md | page-id> \"text\"");
675
- }
676
-
677
- const inline = positional.slice(1).join(" ");
678
- const text = inline || fs.readFileSync(0, "utf8");
679
- if (!text.trim()) {
680
- die("Nothing to add.", "Pass the text as an argument or pipe it in.");
681
- }
682
-
683
- const result = await callApi("PATCH", "/v1/pages", {
684
- body: {
685
- ...target,
686
- [flags.prepend === true ? "prepend" : "append"]: text,
687
- },
688
- });
689
- report(result, {
690
- verb: flags.prepend === true ? "Prepended to" : "Appended to",
691
- });
692
- }
693
-
694
- /**
695
- * Replaces one exact piece of text in a page.
696
- *
697
- * `--find` matches against the page's Markdown, which is what `open
698
- * --markdown` prints, so the way to use this is to look first and paste. The
699
- * API refuses a match that is not unique rather than guessing, so a failure
700
- * here means "say more", not "try again".
701
- */
702
- async function edit(positional, flags) {
703
- const target = targetFor(positional[0], flags);
704
- if (!target.documentId && !target.externalId) {
705
- die(
706
- "Which page?",
707
- "Usage: sumibako edit <file.md | page-id> --find <old> --replace <new>",
708
- );
709
- }
710
-
711
- const rename = typeof flags.title === "string" ? flags.title : undefined;
712
- const find = typeof flags.find === "string" ? flags.find : undefined;
713
- if (find === undefined && rename === undefined) {
714
- die(
715
- "Nothing to change.",
716
- "Pass --find with --replace, or --title to rename the page.",
717
- );
718
- }
719
- // An empty --replace is a deletion, and has to survive the default below.
720
- const replace = typeof flags.replace === "string" ? flags.replace : "";
721
-
722
- const result = await callApi("PATCH", "/v1/pages", {
723
- body: {
724
- ...target,
725
- ...(find !== undefined ? { find, replace } : {}),
726
- ...(rename !== undefined ? { title: rename } : {}),
727
- },
728
- });
729
- report(result, { verb: "Edited" });
730
- }
731
-
732
- async function unpublish(positional, flags) {
733
- const target = targetFor(positional[0], flags);
734
- if (!target.documentId && !target.externalId) {
735
- die("Which page?", "Usage: sumibako unpublish <file.md | page-id>");
736
- }
737
-
738
- const result = await callApi("POST", "/v1/pages/publish", {
739
- body: { ...target, publish: false },
740
- });
741
- console.log(`${green("Taken down.")} ${bold(result.title)} is private again.`);
742
- console.log(dim(result.url));
743
- }
744
-
745
- async function open(positional, flags) {
746
- const target = targetFor(positional[0], flags);
747
- if (!target.documentId && !target.externalId) {
748
- die("Which page?", "Usage: sumibako open <file.md | page-id>");
749
- }
750
-
751
- const page = await callApi("GET", "/v1/pages", {
752
- query: { id: target.documentId, externalId: target.externalId },
753
- });
754
- console.log(bold(page.title));
755
- console.log(page.url);
756
- if (page.publicUrl) console.log(green(page.publicUrl));
757
-
758
- // Two names for one thing. `--text` came first and is in people's scripts;
759
- // Markdown is strictly the better answer, because it is what you edit and
760
- // send back, so both flags print it rather than keeping a worse output alive
761
- // for the sake of a flag name.
762
- if (flags.markdown === true || flags.text === true) {
763
- console.log();
764
- console.log(page.markdown || page.text);
765
- for (const warning of page.markdownWarnings ?? []) {
766
- console.log(yellow(`note ${warning}`));
767
- }
768
- }
769
- }
770
-
771
- async function search(positional) {
772
- // No words is a question too: "what is in here". It lists the newest pages
773
- // rather than explaining the command to somebody who has just been handed a
774
- // vault they have never seen.
775
- const term = positional.join(" ").trim();
776
-
777
- const { results } = await callApi("GET", "/v1/pages/search", {
778
- query: { q: term || undefined, limit: term ? undefined : 20 },
779
- });
780
-
781
- if (results.length === 0) {
782
- console.log(dim(term ? "Nothing matched." : "This vault has no pages yet."));
783
- return;
784
- }
785
- for (const page of results) {
786
- console.log(`${bold(page.title)}${page.publicUrl ? green(" public") : ""}`);
787
- console.log(dim(` ${page.publicUrl ?? page.url}`));
788
- }
789
- }
790
-
791
- async function whoami() {
792
- const who = await callApi("GET", "/v1/whoami");
793
- const usage = await callApi("GET", "/v1/usage");
794
- console.log(`Plan ${bold(who.plan)} at ${who.site}`);
795
- console.log(`Permissions: ${who.scopes.join(", ")}`);
796
- console.log(
797
- `Pages: ${usage.documents} of ${limit(usage.maxDocuments)} Published: ${usage.published} of ${limit(usage.maxPublished)}`,
798
- );
799
- }
800
-
801
- const limit = (value) => (value === null || !Number.isFinite(value) ? "unlimited" : value);
802
-
803
- /** Prints the outcome of a write, link last so it is the easiest thing to copy. */
804
- function report(result, { verb }) {
805
- console.log(`${green(verb)} ${bold(result.title)}`);
806
- for (const warning of result.warnings ?? []) {
807
- console.log(`${yellow("note")} ${warning}`);
808
- }
809
- console.log(dim(result.url));
810
- if (result.publicUrl) {
811
- console.log();
812
- console.log(`${bold("Share this:")} ${result.publicUrl}`);
813
- }
814
- }
815
-
816
- // ---------------------------------------------------------------------------
817
- // Entry
818
- // ---------------------------------------------------------------------------
819
-
820
- const HELP = `
821
- ${bold("sumibako")} - file what your coding agent wrote into your vault
822
-
823
- ${bold("sumibako login")} [--token <t>] [--api <url>] connect this machine
824
- ${bold("sumibako publish")} <file.md> [--public] file a Markdown file as a page
825
- ${bold("sumibako unpublish")} <file.md> take a published page off the web
826
- ${bold("sumibako append")} <file.md> <text> add to the end of a page
827
- ${bold("sumibako edit")} <file.md> --find ... replace one piece of text
828
- ${bold("sumibako open")} <file.md> [--markdown] print the links, or the page
829
- ${bold("sumibako search")} [words] search your vault, or list it
830
- ${bold("sumibako whoami")} check the token and plan
831
- ${bold("sumibako logout")} forget the token
832
-
833
- ${bold("Options for publish")}
834
- --public publish it and print a shareable link
835
- --title <title> override the title (default: the first heading)
836
- --key <key> the identity of this artifact (default: its repo path)
837
- --new file a new page even if this file was filed before
838
- --parent <page-id> nest it under an existing page
839
-
840
- ${bold("Options for append and edit")}
841
- --prepend add to the start of the page instead of the end
842
- --find <text> the exact text to replace, as open --markdown prints it
843
- --replace <text> what to put there; empty deletes the matched text
844
- --title <title> rename the page
845
- --key <key> name the page by its key rather than a path or an id
846
-
847
- ${bold("Editing a page you did not write")}
848
- open --markdown prints the page as Markdown, which is the same text --find
849
- matches against. A --find that appears twice is refused rather than guessed
850
- at, so quote enough of the surrounding lines to be unambiguous.
851
-
852
- ${bold("How re-running works")}
853
- A file is filed under its path in the repo, so publishing the same file again
854
- updates the same page instead of making a second one. Pass --new when you
855
- genuinely want another page, or --key to choose the identity yourself.
856
-
857
- ${bold("Connecting")}
858
- Any command will start it: with no token, it prints a link to open and exits
859
- with code 3. Open the link, approve it, run the same command again. Nothing
860
- is typed and nothing waits, so an agent can do this without stalling.
861
-
862
- Exit codes: 0 worked, 3 not connected yet, 1 everything else.
863
-
864
- ${bold("Environment")}
865
- SUMIBAKO_TOKEN use this token instead of the saved one
866
- SUMIBAKO_API point at a different deployment
867
- `;
868
-
869
- async function main() {
870
- const [command, ...rest] = process.argv.slice(2);
871
- const { flags, positional } = parseFlags(rest);
872
-
873
- if (!command || command === "help" || flags.help === true) {
874
- console.log(HELP);
875
- return;
876
- }
877
- if (command === "--version" || command === "version") {
878
- console.log(VERSION);
879
- return;
880
- }
881
-
882
- switch (command) {
883
- case "login":
884
- return login(flags);
885
- case "logout":
886
- return logout();
887
- case "publish":
888
- case "push":
889
- return publish(positional, flags);
890
- case "unpublish":
891
- return unpublish(positional, flags);
892
- case "append":
893
- return append(positional, flags);
894
- case "edit":
895
- return edit(positional, flags);
896
- case "open":
897
- case "get":
898
- return open(positional, flags);
899
- case "search":
900
- case "list":
901
- return search(positional);
902
- case "whoami":
903
- return whoami();
904
- default:
905
- die(`Unknown command: ${command}`, "Run `sumibako help` for the list.");
906
- }
907
- }
908
-
909
- main().catch((error) => {
910
- die(error instanceof Error ? error.message : String(error));
911
- });
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sumibako - file what your coding agent wrote into your vault.
4
+ *
5
+ * Zero dependencies, one file, Node 18 or newer. That is a deliberate ceiling:
6
+ * this runs inside somebody else's agent session, often on a machine where the
7
+ * install has to be instant and silent, and a dependency tree is a thing that
8
+ * can break there in ways nobody will debug.
9
+ *
10
+ * Why a CLI at all, when the same job could be an MCP server. Two reasons that
11
+ * matter in practice. An MCP tool call carries the document through the model's
12
+ * context to get it to the server, so filing a 30KB plan means re-emitting
13
+ * 30KB of tokens; `sumibako publish plan.md` sends a file the agent has already
14
+ * written to disk, and the model never sees it twice. And a shell exists in
15
+ * every coding agent there is, while MCP support differs between them and
16
+ * changes with the spec. An MCP server can come later and reuse this same API.
17
+ */
18
+
19
+ import { spawn } from "node:child_process";
20
+ import crypto from "node:crypto";
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import process from "node:process";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ /**
28
+ * The version, read from `package.json` rather than written twice.
29
+ *
30
+ * It was a literal here until `npm version patch` bumped the manifest and left
31
+ * this behind, so a freshly published 0.2.1 introduced itself as 0.2.0. That is
32
+ * a small lie with an outsized cost: the first thing anybody does with a bug
33
+ * report is ask which version, and the answer was wrong.
34
+ *
35
+ * Read lazily, so the only command that needs the file is the one that pays for
36
+ * it, and forgiving of a missing file: a version string is not worth failing a
37
+ * publish over.
38
+ */
39
+ function version() {
40
+ try {
41
+ const here = path.dirname(fileURLToPath(import.meta.url));
42
+ return JSON.parse(
43
+ fs.readFileSync(path.join(here, "package.json"), "utf8"),
44
+ ).version;
45
+ } catch {
46
+ return "unknown";
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Exit code for "this machine is not connected yet".
52
+ *
53
+ * Distinct from 1 so that the thing reading this output can tell a missing
54
+ * credential from a failed command. That distinction is the whole reason the
55
+ * connect flow works from inside an agent session: the skill tells the agent
56
+ * that 3 means "show the user the link and run the same command again", and
57
+ * every other non-zero code means the command genuinely failed.
58
+ */
59
+ const EXIT_NEEDS_AUTH = 3;
60
+
61
+ /** Where the token lives when it is not in the environment. */
62
+ const CONFIG_DIR = path.join(os.homedir(), ".sumibako");
63
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
64
+
65
+ /**
66
+ * Where the API lives.
67
+ *
68
+ * The app's own domain rather than the Convex deployment behind it. This string
69
+ * ends up in config files, CI secrets and other people's shell history, so it
70
+ * has to be one that stays true - `next.config.mjs` rewrites it onto whichever
71
+ * deployment is current.
72
+ */
73
+ const DEFAULT_API = "https://sumibako.com/api/agent";
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Output
77
+ // ---------------------------------------------------------------------------
78
+
79
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
80
+ /**
81
+ * ANSI colour, with the escape byte written as an escape rather than typed.
82
+ *
83
+ * A literal escape character in source is invisible in every diff and every
84
+ * review, and survives exactly until something normalises the file.
85
+ */
86
+ const ESC = "\u001b";
87
+ const paint = (code, text) =>
88
+ useColor ? `${ESC}[${code}m${text}${ESC}[0m` : text;
89
+ const dim = (text) => paint("2", text);
90
+ const bold = (text) => paint("1", text);
91
+ const green = (text) => paint("32", text);
92
+ const red = (text) => paint("31", text);
93
+ const yellow = (text) => paint("33", text);
94
+
95
+ function die(message, hint) {
96
+ console.error(`${red("error")} ${message}`);
97
+ if (hint) console.error(dim(` ${hint}`));
98
+ process.exit(1);
99
+ }
100
+
101
+ /**
102
+ * Stops with the "connect this machine" instructions, and exit code 3.
103
+ *
104
+ * Written to stdout rather than stderr, unlike every other failure here. This
105
+ * is not an error report: it is a link somebody has to open, and the caller is
106
+ * as often an agent relaying it into a chat window as a person reading a
107
+ * terminal. Errors go to stderr because they are diagnostics; this is content.
108
+ */
109
+ function needsAuth(url, code) {
110
+ console.log(`${bold("Connect this machine to Sumibako:")}`);
111
+ console.log(url);
112
+ console.log();
113
+ console.log(dim(`It should show the code ${bold(code)}. If it does not, the`));
114
+ console.log(dim("page belongs to a different request - close it."));
115
+ console.log();
116
+ console.log(dim("Then run the same command again."));
117
+ process.exit(EXIT_NEEDS_AUTH);
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Config
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function readConfig() {
125
+ try {
126
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
127
+ } catch {
128
+ return {};
129
+ }
130
+ }
131
+
132
+ function writeConfig(config) {
133
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
134
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", {
135
+ // The file holds a live credential. 0600 is the difference between "only
136
+ // me" and "every process running as any user on this machine", and it
137
+ // costs one argument. Windows ignores the mode, which is why the token is
138
+ // also accepted from the environment.
139
+ mode: 0o600,
140
+ });
141
+ try {
142
+ fs.chmodSync(CONFIG_FILE, 0o600);
143
+ } catch {
144
+ // Best effort: some filesystems do not support it.
145
+ }
146
+ }
147
+
148
+ /**
149
+ * The token, from the environment first and the config file second.
150
+ *
151
+ * Environment first so CI can inject one without writing to a home directory
152
+ * that may not persist, and so a person can override a stale saved token for
153
+ * one command without editing a file.
154
+ */
155
+ function resolveToken() {
156
+ const fromEnv = process.env.SUMIBAKO_TOKEN?.trim();
157
+ if (fromEnv) return fromEnv;
158
+ return readConfig().token ?? null;
159
+ }
160
+
161
+ function resolveApi() {
162
+ return (
163
+ process.env.SUMIBAKO_API?.trim().replace(/\/+$/, "") ||
164
+ readConfig().api ||
165
+ DEFAULT_API
166
+ );
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // HTTP
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /**
174
+ * One request, with the two failures that are about the address rather than
175
+ * the call: an unreachable host, and a server that answered but not as this
176
+ * API. Everything else is handed back for the caller to interpret.
177
+ */
178
+ async function rawCall(
179
+ method,
180
+ base,
181
+ endpoint,
182
+ { body, query, token, tolerateNetworkError = false } = {},
183
+ ) {
184
+ const url = new URL(base + endpoint);
185
+ for (const [key, value] of Object.entries(query ?? {})) {
186
+ if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
187
+ }
188
+
189
+ let response;
190
+ try {
191
+ response = await fetch(url, {
192
+ method,
193
+ headers: {
194
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
195
+ ...(body ? { "Content-Type": "application/json" } : {}),
196
+ },
197
+ body: body ? JSON.stringify(body) : undefined,
198
+ });
199
+ } catch (error) {
200
+ /*
201
+ A dropped connection, not a refusal.
202
+
203
+ Fatal for a single command, because retrying is not ours to decide:
204
+ `append` and `edit` are not idempotent, and a `fetch` that rejects may
205
+ still have been delivered - the response is what went missing. Quietly
206
+ sending it again could add the same paragraph twice.
207
+
208
+ A poll is the exception, and says so by passing the flag. It repeats the
209
+ same question until it gets an answer or runs out of time, so one lost
210
+ reply should cost a couple of seconds rather than the whole command.
211
+ */
212
+ if (tolerateNetworkError) return null;
213
+ die(
214
+ `Could not reach ${url.origin}.`,
215
+ "Check your connection and run the same command again.",
216
+ );
217
+ }
218
+
219
+ const text = await response.text();
220
+ const contentType = response.headers.get("content-type") ?? "";
221
+
222
+ let payload = null;
223
+ if (text && contentType.includes("json")) {
224
+ try {
225
+ payload = JSON.parse(text);
226
+ } catch {
227
+ // Handled below, along with every other not-our-API response.
228
+ }
229
+ }
230
+
231
+ /*
232
+ Reached a server, but not this API.
233
+
234
+ Overwhelmingly this means the base URL points at the web app rather than the
235
+ API, and the app answered with an HTML page. Printing that page is how a
236
+ one-line configuration mistake turns into a screenful of markup with the
237
+ actual problem nowhere in it, so the body is deliberately not shown.
238
+ */
239
+ if (payload === null) {
240
+ die(
241
+ `${base} is not answering as the Sumibako API (HTTP ${response.status}, ${contentType || "no content type"}).`,
242
+ "Check the address: pass --api <url>, or set SUMIBAKO_API.",
243
+ );
244
+ }
245
+
246
+ return { response, payload, text };
247
+ }
248
+
249
+ async function callApi(method, endpoint, { body, token, query, api } = {}) {
250
+ const base = api ?? resolveApi();
251
+
252
+ /*
253
+ A token, or the connect flow, before anything is sent.
254
+
255
+ `ensureToken` can exit the process here, which is deliberate: every caller
256
+ of this function needs a credential, and there is nothing sensible for one
257
+ of them to do with "there isn't one" that is not already done better in one
258
+ place.
259
+ */
260
+ const authToken = token ?? (await ensureToken(base));
261
+
262
+ const { response, payload, text } = await rawCall(method, base, endpoint, {
263
+ body,
264
+ query,
265
+ token: authToken,
266
+ });
267
+
268
+ if (!response.ok) {
269
+ const message = payload?.error?.message ?? text.slice(0, 300) ?? "Request failed.";
270
+ const code = payload?.error?.code;
271
+
272
+ // The three failures worth explaining rather than just reporting, because
273
+ // each has a next step the person cannot guess from the message alone.
274
+ if (response.status === 401) {
275
+ /*
276
+ A saved token that the server refuses is a revoked one, and keeping it
277
+ would wedge this machine: every future command would authenticate with
278
+ it, fail, and print the same advice. Dropping it means the next command
279
+ starts a connect flow instead, which is the thing the person was going
280
+ to have to do anyway.
281
+
282
+ Only when it came from the config file. A token in the environment is
283
+ not ours to forget, and pretending to have forgotten it would send
284
+ somebody looking in the wrong place.
285
+ */
286
+ if (process.env.SUMIBAKO_TOKEN?.trim()) {
287
+ // Running it again would fail identically, forever: the environment
288
+ // wins over the config file, so there is nothing for a connect flow
289
+ // to take effect on until that variable is dealt with.
290
+ die(
291
+ message,
292
+ "SUMIBAKO_TOKEN is set. Unset it and run the same command again to connect this machine.",
293
+ );
294
+ }
295
+ const config = readConfig();
296
+ if (config.token) {
297
+ delete config.token;
298
+ writeConfig(config);
299
+ }
300
+ die(message, "Run the same command again to connect this machine.");
301
+ }
302
+ if (response.status === 403) {
303
+ die(message, "Create a token with publishing allowed, in Settings.");
304
+ }
305
+ if (response.status === 429) {
306
+ const retry = response.headers.get("Retry-After");
307
+ die(message, retry ? `Wait ${retry} seconds and try again.` : undefined);
308
+ }
309
+ die(`${message}${code ? dim(` (${code})`) : ""}`);
310
+ }
311
+
312
+ return payload;
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Connecting a machine
317
+ // ---------------------------------------------------------------------------
318
+
319
+ /*
320
+ Getting a token without anybody typing one.
321
+
322
+ The old flow was: open the app, mint a token, copy it, paste it here. That
323
+ reads as four steps and is really one problem - the paste can only be done by
324
+ a person sitting at this terminal, so connecting had to happen before the
325
+ agent was any use, and an agent that hit a missing token could do nothing but
326
+ give up and explain.
327
+
328
+ This is the device authorization grant. We hold 32 random bytes, send their
329
+ hash, get a short code back, and the approval happens in a browser where the
330
+ person already has a session. The bytes are what redeems the request, so the
331
+ code travelling through a URL and a scrollback gives nothing away.
332
+
333
+ Two properties are load-bearing for the agent case. Nothing here reads stdin,
334
+ so it works with no terminal attached. And nothing blocks for long: the first
335
+ run prints a link and exits, and a later run collects the token, so an agent
336
+ relays one line to its user and carries on instead of sitting inside a
337
+ command until the harness kills it.
338
+ */
339
+
340
+ /** How long a redeem waits before giving the link back to the caller. */
341
+ const REDEEM_WAIT_MS = 20_000;
342
+
343
+ /** Gap between redeem attempts while waiting. */
344
+ const REDEEM_INTERVAL_MS = 2_000;
345
+
346
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
347
+
348
+ /**
349
+ * Opens a URL in the desktop browser, and does not care if it cannot.
350
+ *
351
+ * Called only when stdout is a terminal. An agent, a container and a CI job all
352
+ * fail that test, which is exactly right: there is no browser to open there,
353
+ * and the URL is already printed for whoever can open one. That one condition
354
+ * covers every headless case without a flag to remember.
355
+ */
356
+ function openInBrowser(url) {
357
+ try {
358
+ const [command, args] =
359
+ process.platform === "win32"
360
+ ? ["cmd", ["/c", "start", "", url]]
361
+ : process.platform === "darwin"
362
+ ? ["open", [url]]
363
+ : ["xdg-open", [url]];
364
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
365
+ child.on("error", () => {});
366
+ child.unref();
367
+ } catch {
368
+ // The link is on screen either way.
369
+ }
370
+ }
371
+
372
+ /** Describes this machine for the approval page. Never a secret. */
373
+ function describeClient() {
374
+ return `${os.hostname()}, ${process.platform}`;
375
+ }
376
+
377
+ /**
378
+ * Opens a connection request and remembers the half of it we must keep.
379
+ *
380
+ * The verifier is stored next to where the token will go, under the same 0600,
381
+ * because between this call and the next command it is the thing that can
382
+ * collect a credential.
383
+ */
384
+ async function startConnect(base) {
385
+ const verifier = crypto.randomBytes(32).toString("base64url");
386
+ const verifierHash = crypto
387
+ .createHash("sha256")
388
+ .update(verifier)
389
+ .digest("hex");
390
+
391
+ const { response, payload } = await rawCall("POST", base, "/v1/cli/start", {
392
+ body: { verifierHash, client: describeClient() },
393
+ });
394
+
395
+ if (!response.ok) {
396
+ die(
397
+ payload?.error?.message ?? "Could not start the connection.",
398
+ "Check the address: pass --api <url>, or set SUMIBAKO_API.",
399
+ );
400
+ }
401
+
402
+ const pending = {
403
+ userCode: payload.userCode,
404
+ verifier,
405
+ verificationUrl: payload.verificationUrl,
406
+ expiresAt: payload.expiresAt,
407
+ api: base,
408
+ };
409
+ writeConfig({ ...readConfig(), pending });
410
+ return pending;
411
+ }
412
+
413
+ /** The pending request, if there is one and it is still worth trying. */
414
+ function readPending(base) {
415
+ const pending = readConfig().pending;
416
+ if (!pending?.verifier || !pending?.userCode) return null;
417
+ if (typeof pending.expiresAt === "number" && pending.expiresAt < Date.now()) {
418
+ return null;
419
+ }
420
+ // A request opened against one deployment cannot be redeemed at another.
421
+ if (pending.api && pending.api !== base) return null;
422
+ return pending;
423
+ }
424
+
425
+ function clearPending() {
426
+ const config = readConfig();
427
+ delete config.pending;
428
+ writeConfig(config);
429
+ }
430
+
431
+ /**
432
+ * Waits for approval, up to `waitMs`, and saves the token when it arrives.
433
+ *
434
+ * Returns the token, or null if it is still pending. Anything that ends the
435
+ * request - denied, expired, collected already - clears the saved request and
436
+ * returns null, so the caller starts a fresh one rather than retrying a dead
437
+ * code forever.
438
+ */
439
+ async function redeemPending(base, pending, waitMs) {
440
+ const deadline = Date.now() + waitMs;
441
+
442
+ for (;;) {
443
+ const attempt = await rawCall("POST", base, "/v1/cli/redeem", {
444
+ body: { userCode: pending.userCode, verifier: pending.verifier },
445
+ tolerateNetworkError: true,
446
+ });
447
+
448
+ /*
449
+ The connection dropped. Wait and ask again rather than giving up.
450
+
451
+ Almost every poll answers "pending" and consumes nothing, so repeating
452
+ one is free. The exception is the single reply that carries the token: if
453
+ that is the one that goes missing, the code has been spent and asking
454
+ again gets `expired`. The cost of that is one more `login`, which is why
455
+ this is a retry rather than something more careful.
456
+ */
457
+ if (attempt === null) {
458
+ if (Date.now() + REDEEM_INTERVAL_MS > deadline) return null;
459
+ await sleep(REDEEM_INTERVAL_MS);
460
+ continue;
461
+ }
462
+
463
+ const { response, payload } = attempt;
464
+
465
+ if (!response.ok) {
466
+ clearPending();
467
+ return null;
468
+ }
469
+
470
+ if (payload.status === "approved") {
471
+ const config = readConfig();
472
+ delete config.pending;
473
+ writeConfig({ ...config, token: payload.token });
474
+ return payload;
475
+ }
476
+
477
+ if (payload.status !== "pending") {
478
+ // Denied or expired. Either way this code is finished.
479
+ clearPending();
480
+ return null;
481
+ }
482
+
483
+ if (Date.now() + REDEEM_INTERVAL_MS > deadline) return null;
484
+ await sleep(REDEEM_INTERVAL_MS);
485
+ }
486
+ }
487
+
488
+ /**
489
+ * A usable token, or the connect instructions and exit 3.
490
+ *
491
+ * The whole flow in one function, because every authenticated command needs
492
+ * exactly this and none of them should be deciding any part of it themselves.
493
+ */
494
+ async function ensureToken(base) {
495
+ const saved = resolveToken();
496
+ if (saved) return saved;
497
+
498
+ const pending = readPending(base);
499
+ if (pending) {
500
+ const approved = await redeemPending(base, pending, REDEEM_WAIT_MS);
501
+ if (approved) return approved.token;
502
+
503
+ // Still waiting: hand the same link back rather than opening a second
504
+ // request, so the page the person already has open is the right one.
505
+ const current = readPending(base);
506
+ if (current) needsAuth(current.verificationUrl, current.userCode);
507
+ }
508
+
509
+ const started = await startConnect(base);
510
+ needsAuth(started.verificationUrl, started.userCode);
511
+ }
512
+
513
+ // ---------------------------------------------------------------------------
514
+ // Arguments
515
+ // ---------------------------------------------------------------------------
516
+
517
+ /** Parses `--flag value`, `--flag=value` and `--boolean` into an object. */
518
+ function parseFlags(argv) {
519
+ const flags = {};
520
+ const positional = [];
521
+
522
+ for (let index = 0; index < argv.length; index += 1) {
523
+ const item = argv[index];
524
+ if (!item.startsWith("--")) {
525
+ positional.push(item);
526
+ continue;
527
+ }
528
+ const equals = item.indexOf("=");
529
+ if (equals !== -1) {
530
+ flags[item.slice(2, equals)] = item.slice(equals + 1);
531
+ continue;
532
+ }
533
+ const name = item.slice(2);
534
+ const next = argv[index + 1];
535
+ if (next === undefined || next.startsWith("--")) {
536
+ flags[name] = true;
537
+ } else {
538
+ flags[name] = next;
539
+ index += 1;
540
+ }
541
+ }
542
+
543
+ return { flags, positional };
544
+ }
545
+
546
+ /**
547
+ * The key a file is filed under, so re-running lands on one page.
548
+ *
549
+ * The repo-relative path, because that is the identity of an artifact that a
550
+ * person and an agent will both agree on: `docs/plans/auth.md` is the plan for
551
+ * auth, this week and next. Falls back to the path as given when the file sits
552
+ * outside a git repository.
553
+ *
554
+ * Normalised to forward slashes so the same file keyed from Windows and from
555
+ * CI resolves to the same page.
556
+ */
557
+ function defaultKey(filePath) {
558
+ const absolute = path.resolve(filePath);
559
+ let directory = path.dirname(absolute);
560
+
561
+ for (let depth = 0; depth < 40; depth += 1) {
562
+ if (fs.existsSync(path.join(directory, ".git"))) {
563
+ return path.relative(directory, absolute).split(path.sep).join("/");
564
+ }
565
+ const parent = path.dirname(directory);
566
+ if (parent === directory) break;
567
+ directory = parent;
568
+ }
569
+
570
+ return absolute.split(path.sep).join("/");
571
+ }
572
+
573
+ function readMarkdown(filePath) {
574
+ if (filePath === "-") {
575
+ return fs.readFileSync(0, "utf8");
576
+ }
577
+ if (!fs.existsSync(filePath)) {
578
+ die(`No such file: ${filePath}`);
579
+ }
580
+ const stats = fs.statSync(filePath);
581
+ if (stats.isDirectory()) {
582
+ die(`${filePath} is a directory. Point at one Markdown file.`);
583
+ }
584
+ return fs.readFileSync(filePath, "utf8");
585
+ }
586
+
587
+ // ---------------------------------------------------------------------------
588
+ // Commands
589
+ // ---------------------------------------------------------------------------
590
+
591
+ /** Prints what a freshly connected machine may do. */
592
+ function reportSignedIn(plan, scopes) {
593
+ console.log(
594
+ `${green("Connected.")} Plan ${bold(plan)}, can ${scopes
595
+ .map((scope) => scope.replace("pages:", ""))
596
+ .join(", ")}.`,
597
+ );
598
+ console.log(dim(`Token saved to ${CONFIG_FILE}`));
599
+ }
600
+
601
+ /**
602
+ * Connects this machine.
603
+ *
604
+ * Three ways in, in the order they are checked. `--token` for somebody pasting
605
+ * one deliberately and for scripts; a browser approval for everybody else; and
606
+ * `SUMIBAKO_TOKEN` in the environment, which needs no command at all and is
607
+ * why CI never reaches this function.
608
+ *
609
+ * The browser half waits, unlike the connect flow inside `publish`. That
610
+ * difference is the point: a person who typed `login` is sitting there and
611
+ * expects the command to finish when they are done approving, while an agent
612
+ * that hit a missing token needs the link and its turn back.
613
+ */
614
+ async function login(flags) {
615
+ const api = typeof flags.api === "string" ? flags.api.replace(/\/+$/, "") : undefined;
616
+ const base = api ?? resolveApi();
617
+
618
+ let token = typeof flags.token === "string" ? flags.token.trim() : "";
619
+
620
+ // A pasted token still has to work before it is saved, so a mistyped one
621
+ // fails here rather than halfway through the next session, and a failed
622
+ // login leaves whatever was configured before it untouched.
623
+ if (token) {
624
+ const who = await callApi("GET", "/v1/whoami", { token, api });
625
+ const config = readConfig();
626
+ delete config.pending;
627
+ writeConfig({ ...config, token, ...(api ? { api } : {}) });
628
+ reportSignedIn(who.plan, who.scopes);
629
+ return;
630
+ }
631
+
632
+ const pending = readPending(base) ?? (await startConnect(base));
633
+
634
+ console.log(`Open this to connect ${bold(describeClient())}:`);
635
+ console.log(pending.verificationUrl);
636
+ console.log();
637
+ console.log(dim(`The page should show the code ${bold(pending.userCode)}.`));
638
+
639
+ if (process.stdout.isTTY) openInBrowser(pending.verificationUrl);
640
+
641
+ const remaining = Math.max(0, (pending.expiresAt ?? 0) - Date.now());
642
+ if (remaining === 0) {
643
+ die("That request expired.", "Run `npx sumibako login` again.");
644
+ }
645
+
646
+ console.log();
647
+ console.log(dim("Waiting for you to approve it..."));
648
+
649
+ const approved = await redeemPending(base, pending, remaining);
650
+ if (!approved) {
651
+ die(
652
+ "The request was not approved.",
653
+ "Run `npx sumibako login` again, or pass --token to paste one instead.",
654
+ );
655
+ }
656
+
657
+ if (api) writeConfig({ ...readConfig(), api });
658
+ reportSignedIn(approved.plan, approved.scopes);
659
+ }
660
+
661
+ function logout() {
662
+ const config = readConfig();
663
+ delete config.token;
664
+ // Also the half-finished connection, if there is one. Leaving it would let
665
+ // the next command silently pick up a token from a request made before the
666
+ // person asked to be signed out.
667
+ delete config.pending;
668
+ writeConfig(config);
669
+ console.log(`${green("Signed out.")} The token was removed from this machine.`);
670
+ console.log(dim("Revoke it in Settings if it may have leaked."));
671
+ }
672
+
673
+ async function publish(positional, flags) {
674
+ const file = positional[0];
675
+ if (!file) {
676
+ die("Which file?", "Usage: sumibako publish <file.md> [--public]");
677
+ }
678
+
679
+ const markdown = readMarkdown(file);
680
+ if (!markdown.trim()) die(`${file} is empty.`);
681
+
682
+ const key =
683
+ typeof flags.key === "string"
684
+ ? flags.key
685
+ : flags.new === true || file === "-"
686
+ ? undefined
687
+ : defaultKey(file);
688
+
689
+ const wantsPublic = flags.public === true || flags.publish === true;
690
+
691
+ const result = await callApi("POST", "/v1/pages", {
692
+ body: {
693
+ markdown,
694
+ externalId: key,
695
+ title: typeof flags.title === "string" ? flags.title : undefined,
696
+ parentDocument: typeof flags.parent === "string" ? flags.parent : undefined,
697
+ publish: wantsPublic ? true : undefined,
698
+ },
699
+ });
700
+
701
+ report(result, { verb: result.created ? "Created" : "Updated" });
702
+ }
703
+
704
+ /**
705
+ * Works out whether an argument names a file on disk or a page id.
706
+ *
707
+ * A file that exists is keyed by its repo path, the same way `publish` filed
708
+ * it. Anything else is taken to be a page id, which is what someone pastes
709
+ * after copying it out of a URL.
710
+ */
711
+ function targetFor(argument, flags) {
712
+ if (typeof flags.key === "string") return { externalId: flags.key };
713
+ if (!argument) return {};
714
+ if (fs.existsSync(argument)) return { externalId: defaultKey(argument) };
715
+ return { documentId: argument };
716
+ }
717
+
718
+ /**
719
+ * Adds to a page without sending it back.
720
+ *
721
+ * The running-log case, which `publish` handles badly: filing a note at the
722
+ * end of a session with `publish` means reading the whole page, adding a line
723
+ * and writing all of it again. Here the text to add is the only thing that
724
+ * crosses the wire.
725
+ *
726
+ * Words after the target are the text. With none, it is read from stdin, so
727
+ * `git log -1 --format=%s | sumibako append CHANGELOG.md` works.
728
+ */
729
+ async function append(positional, flags) {
730
+ const target = targetFor(positional[0], flags);
731
+ if (!target.documentId && !target.externalId) {
732
+ die("Which page?", "Usage: sumibako append <file.md | page-id> \"text\"");
733
+ }
734
+
735
+ const inline = positional.slice(1).join(" ");
736
+ const text = inline || fs.readFileSync(0, "utf8");
737
+ if (!text.trim()) {
738
+ die("Nothing to add.", "Pass the text as an argument or pipe it in.");
739
+ }
740
+
741
+ const result = await callApi("PATCH", "/v1/pages", {
742
+ body: {
743
+ ...target,
744
+ [flags.prepend === true ? "prepend" : "append"]: text,
745
+ },
746
+ });
747
+ report(result, {
748
+ verb: flags.prepend === true ? "Prepended to" : "Appended to",
749
+ });
750
+ }
751
+
752
+ /**
753
+ * Replaces one exact piece of text in a page.
754
+ *
755
+ * `--find` matches against the page's Markdown, which is what `open
756
+ * --markdown` prints, so the way to use this is to look first and paste. The
757
+ * API refuses a match that is not unique rather than guessing, so a failure
758
+ * here means "say more", not "try again".
759
+ */
760
+ async function edit(positional, flags) {
761
+ const target = targetFor(positional[0], flags);
762
+ if (!target.documentId && !target.externalId) {
763
+ die(
764
+ "Which page?",
765
+ "Usage: sumibako edit <file.md | page-id> --find <old> --replace <new>",
766
+ );
767
+ }
768
+
769
+ const rename = typeof flags.title === "string" ? flags.title : undefined;
770
+ const find = typeof flags.find === "string" ? flags.find : undefined;
771
+ if (find === undefined && rename === undefined) {
772
+ die(
773
+ "Nothing to change.",
774
+ "Pass --find with --replace, or --title to rename the page.",
775
+ );
776
+ }
777
+ // An empty --replace is a deletion, and has to survive the default below.
778
+ const replace = typeof flags.replace === "string" ? flags.replace : "";
779
+
780
+ const result = await callApi("PATCH", "/v1/pages", {
781
+ body: {
782
+ ...target,
783
+ ...(find !== undefined ? { find, replace } : {}),
784
+ ...(rename !== undefined ? { title: rename } : {}),
785
+ },
786
+ });
787
+ report(result, { verb: "Edited" });
788
+ }
789
+
790
+ async function unpublish(positional, flags) {
791
+ const target = targetFor(positional[0], flags);
792
+ if (!target.documentId && !target.externalId) {
793
+ die("Which page?", "Usage: sumibako unpublish <file.md | page-id>");
794
+ }
795
+
796
+ const result = await callApi("POST", "/v1/pages/publish", {
797
+ body: { ...target, publish: false },
798
+ });
799
+ console.log(`${green("Taken down.")} ${bold(result.title)} is private again.`);
800
+ console.log(dim(result.url));
801
+ }
802
+
803
+ async function open(positional, flags) {
804
+ const target = targetFor(positional[0], flags);
805
+ if (!target.documentId && !target.externalId) {
806
+ die("Which page?", "Usage: sumibako open <file.md | page-id>");
807
+ }
808
+
809
+ const page = await callApi("GET", "/v1/pages", {
810
+ query: { id: target.documentId, externalId: target.externalId },
811
+ });
812
+ console.log(bold(page.title));
813
+ console.log(page.url);
814
+ if (page.publicUrl) console.log(green(page.publicUrl));
815
+
816
+ // Two names for one thing. `--text` came first and is in people's scripts;
817
+ // Markdown is strictly the better answer, because it is what you edit and
818
+ // send back, so both flags print it rather than keeping a worse output alive
819
+ // for the sake of a flag name.
820
+ if (flags.markdown === true || flags.text === true) {
821
+ console.log();
822
+ console.log(page.markdown || page.text);
823
+ for (const warning of page.markdownWarnings ?? []) {
824
+ console.log(yellow(`note ${warning}`));
825
+ }
826
+ }
827
+ }
828
+
829
+ async function search(positional) {
830
+ // No words is a question too: "what is in here". It lists the newest pages
831
+ // rather than explaining the command to somebody who has just been handed a
832
+ // vault they have never seen.
833
+ const term = positional.join(" ").trim();
834
+
835
+ const { results } = await callApi("GET", "/v1/pages/search", {
836
+ query: { q: term || undefined, limit: term ? undefined : 20 },
837
+ });
838
+
839
+ if (results.length === 0) {
840
+ console.log(dim(term ? "Nothing matched." : "This vault has no pages yet."));
841
+ return;
842
+ }
843
+ for (const page of results) {
844
+ console.log(`${bold(page.title)}${page.publicUrl ? green(" public") : ""}`);
845
+ console.log(dim(` ${page.publicUrl ?? page.url}`));
846
+ }
847
+ }
848
+
849
+ async function whoami() {
850
+ const who = await callApi("GET", "/v1/whoami");
851
+ const usage = await callApi("GET", "/v1/usage");
852
+ console.log(`Plan ${bold(who.plan)} at ${who.site}`);
853
+ console.log(`Permissions: ${who.scopes.join(", ")}`);
854
+ console.log(
855
+ `Pages: ${usage.documents} of ${limit(usage.maxDocuments)} Published: ${usage.published} of ${limit(usage.maxPublished)}`,
856
+ );
857
+ }
858
+
859
+ const limit = (value) => (value === null || !Number.isFinite(value) ? "unlimited" : value);
860
+
861
+ /** Prints the outcome of a write, link last so it is the easiest thing to copy. */
862
+ function report(result, { verb }) {
863
+ console.log(`${green(verb)} ${bold(result.title)}`);
864
+ for (const warning of result.warnings ?? []) {
865
+ console.log(`${yellow("note")} ${warning}`);
866
+ }
867
+ console.log(dim(result.url));
868
+ if (result.publicUrl) {
869
+ console.log();
870
+ console.log(`${bold("Share this:")} ${result.publicUrl}`);
871
+ }
872
+ }
873
+
874
+ // ---------------------------------------------------------------------------
875
+ // Entry
876
+ // ---------------------------------------------------------------------------
877
+
878
+ const HELP = `
879
+ ${bold("sumibako")} - file what your coding agent wrote into your vault
880
+
881
+ ${bold("sumibako login")} [--token <t>] [--api <url>] connect this machine
882
+ ${bold("sumibako publish")} <file.md> [--public] file a Markdown file as a page
883
+ ${bold("sumibako unpublish")} <file.md> take a published page off the web
884
+ ${bold("sumibako append")} <file.md> <text> add to the end of a page
885
+ ${bold("sumibako edit")} <file.md> --find ... replace one piece of text
886
+ ${bold("sumibako open")} <file.md> [--markdown] print the links, or the page
887
+ ${bold("sumibako search")} [words] search your vault, or list it
888
+ ${bold("sumibako whoami")} check the token and plan
889
+ ${bold("sumibako logout")} forget the token
890
+
891
+ ${bold("Options for publish")}
892
+ --public publish it and print a shareable link
893
+ --title <title> override the title (default: the first heading)
894
+ --key <key> the identity of this artifact (default: its repo path)
895
+ --new file a new page even if this file was filed before
896
+ --parent <page-id> nest it under an existing page
897
+
898
+ ${bold("Options for append and edit")}
899
+ --prepend add to the start of the page instead of the end
900
+ --find <text> the exact text to replace, as open --markdown prints it
901
+ --replace <text> what to put there; empty deletes the matched text
902
+ --title <title> rename the page
903
+ --key <key> name the page by its key rather than a path or an id
904
+
905
+ ${bold("Editing a page you did not write")}
906
+ open --markdown prints the page as Markdown, which is the same text --find
907
+ matches against. A --find that appears twice is refused rather than guessed
908
+ at, so quote enough of the surrounding lines to be unambiguous.
909
+
910
+ ${bold("How re-running works")}
911
+ A file is filed under its path in the repo, so publishing the same file again
912
+ updates the same page instead of making a second one. Pass --new when you
913
+ genuinely want another page, or --key to choose the identity yourself.
914
+
915
+ ${bold("Connecting")}
916
+ Any command will start it: with no token, it prints a link to open and exits
917
+ with code 3. Open the link, approve it, run the same command again. Nothing
918
+ is typed and nothing waits, so an agent can do this without stalling.
919
+
920
+ Exit codes: 0 worked, 3 not connected yet, 1 everything else.
921
+
922
+ ${bold("Environment")}
923
+ SUMIBAKO_TOKEN use this token instead of the saved one
924
+ SUMIBAKO_API point at a different deployment
925
+ `;
926
+
927
+ async function main() {
928
+ const [command, ...rest] = process.argv.slice(2);
929
+ const { flags, positional } = parseFlags(rest);
930
+
931
+ if (!command || command === "help" || flags.help === true) {
932
+ console.log(HELP);
933
+ return;
934
+ }
935
+ if (command === "--version" || command === "version") {
936
+ console.log(version());
937
+ return;
938
+ }
939
+
940
+ switch (command) {
941
+ case "login":
942
+ return login(flags);
943
+ case "logout":
944
+ return logout();
945
+ case "publish":
946
+ case "push":
947
+ return publish(positional, flags);
948
+ case "unpublish":
949
+ return unpublish(positional, flags);
950
+ case "append":
951
+ return append(positional, flags);
952
+ case "edit":
953
+ return edit(positional, flags);
954
+ case "open":
955
+ case "get":
956
+ return open(positional, flags);
957
+ case "search":
958
+ case "list":
959
+ return search(positional);
960
+ case "whoami":
961
+ return whoami();
962
+ default:
963
+ die(`Unknown command: ${command}`, "Run `sumibako help` for the list.");
964
+ }
965
+ }
966
+
967
+ main().catch((error) => {
968
+ die(error instanceof Error ? error.message : String(error));
969
+ });