sumibako 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +147 -0
  2. package/SKILL.md +150 -0
  3. package/index.mjs +613 -0
  4. package/package.json +42 -0
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # sumibako
2
+
3
+ File the plans, specs and notes your coding agent writes into your
4
+ [Sumibako](https://sumibako.com) vault, and get a link you can send to someone.
5
+
6
+ ```bash
7
+ npx sumibako login
8
+ npx sumibako publish docs/plans/auth-rewrite.md --public
9
+ ```
10
+
11
+ ```
12
+ Created Auth rewrite plan
13
+ https://sumibako.com/vault/Auth-rewrite-plan-jd76...
14
+
15
+ Share this: https://sumibako.com/p/Auth-rewrite-plan-jd76...
16
+ ```
17
+
18
+ ## Why
19
+
20
+ Coding agents write a lot of prose that is worth keeping: implementation plans,
21
+ specs, architecture decisions, migration checklists, handoff notes. It ends up
22
+ as untracked Markdown in a working directory, or pasted into a chat, and then
23
+ somebody asks "can you send me that plan" and there is nothing to send.
24
+
25
+ Each agent has its own answer to this, and each answer only covers that agent's
26
+ own output and lives on that vendor's domain. This is one place for all of them,
27
+ in a vault you already own, where the document is a page you can keep editing.
28
+
29
+ ## Setup
30
+
31
+ Create a token in Sumibako under **Settings, Coding agents**, then:
32
+
33
+ ```bash
34
+ npx sumibako login
35
+ ```
36
+
37
+ The token is stored in `~/.sumibako/config.json` with owner-only permissions.
38
+ `SUMIBAKO_TOKEN` in the environment overrides it, which is what CI should use.
39
+
40
+ ## Commands
41
+
42
+ | Command | What it does |
43
+ | --- | --- |
44
+ | `sumibako login` | Save a token on this machine |
45
+ | `sumibako publish <file.md>` | File a Markdown file as a page |
46
+ | `sumibako publish <file.md> --public` | And put it on the web, printing the link |
47
+ | `sumibako unpublish <file.md>` | Take the page off the web |
48
+ | `sumibako append <file.md> "text"` | Add to the end of a page |
49
+ | `sumibako edit <file.md> --find ... --replace ...` | Replace one exact piece of text |
50
+ | `sumibako open <file.md>` | Print the links for a page (`--markdown` for the page) |
51
+ | `sumibako search [words]` | Search your vault, or list it with no words |
52
+ | `sumibako whoami` | Check the token, plan and usage |
53
+ | `sumibako logout` | Forget the token on this machine |
54
+
55
+ ### Options for `publish`
56
+
57
+ | Option | Meaning |
58
+ | --- | --- |
59
+ | `--public` | Publish it and print a shareable link |
60
+ | `--title <title>` | Override the title, which otherwise comes from the first heading |
61
+ | `--key <key>` | Set the artifact's identity yourself |
62
+ | `--new` | File a new page even if this file was filed before |
63
+ | `--parent <page-id>` | Nest it under an existing page |
64
+
65
+ ### Options for `append` and `edit`
66
+
67
+ | Option | Meaning |
68
+ | --- | --- |
69
+ | `--prepend` | Add to the start of the page instead of the end |
70
+ | `--find <text>` | The exact text to replace, as `open --markdown` prints it |
71
+ | `--replace <text>` | What to put there; an empty string deletes the matched text |
72
+ | `--title <title>` | Rename the page |
73
+ | `--key <key>` | Name the page by its key rather than by a path or an id |
74
+
75
+ ## Changing a page without re-sending it
76
+
77
+ `publish` replaces the whole page. When you only want to add a line, or fix a
78
+ sentence in a page you no longer have on disk, read it and change that part:
79
+
80
+ ```bash
81
+ sumibako open docs/plans/auth.md --markdown
82
+ sumibako append docs/decisions.md "- chose Postgres over Dynamo"
83
+ sumibako edit docs/plans/auth.md --find "ship in Q3" --replace "ship in Q4"
84
+ ```
85
+
86
+ `--find` matches against the Markdown `open --markdown` prints, and has to
87
+ match exactly once. If it appears twice the command refuses rather than picking
88
+ one, because picking one is how the wrong paragraph gets rewritten and nobody
89
+ finds out. Quote more of the surrounding lines.
90
+
91
+ The page's previous version is kept for fourteen days either way, and **Version
92
+ history** in the document menu puts it back.
93
+
94
+ ## Publishing the same file twice
95
+
96
+ A file is filed under its path in the repository, so running `publish` again
97
+ updates the same page rather than making another one. That is the behaviour you
98
+ want when an agent revises a plan five times in a session: one page, one link,
99
+ always current.
100
+
101
+ Pass `--new` when you really do want a second page, or `--key` to choose the
102
+ identity yourself (useful when the file moves but the artifact does not).
103
+
104
+ ## What survives the conversion
105
+
106
+ Headings, paragraphs, bold, italic, strikethrough, inline code, links, bullet
107
+ lists, numbered lists, task lists, nested lists, blockquotes, GitHub alerts
108
+ (`> [!NOTE]`), tables, fenced code with syntax highlighting, images on their own
109
+ line, and horizontal rules.
110
+
111
+ Two things degrade, and the command says so when they do:
112
+
113
+ - **Mermaid** renders as a plain code block. There is no diagram renderer.
114
+ - **Raw HTML** is kept as text.
115
+
116
+ ## Privacy
117
+
118
+ Pages are private until you pass `--public`. A published page has a link anyone
119
+ can open, and carries `noindex` so search engines do not list it. Making a page
120
+ findable in search is a separate opt-in that only you can give, per page, in the
121
+ app - a token cannot do it.
122
+
123
+ ## For agents
124
+
125
+ `SKILL.md` in this package is written for a coding agent to read. Point Claude
126
+ Code, Codex or Cursor at it and they will use the CLI correctly, including the
127
+ part about not creating duplicates.
128
+
129
+ ## If your agent has no shell
130
+
131
+ Claude.ai and ChatGPT cannot run this. They can reach an MCP server, so there is
132
+ one at `https://sumibako.com/api/mcp` offering the same things as tools:
133
+ `write_page`, `read_page`, `edit_page`, `search_pages`, `publish_page` and
134
+ `get_account`. Add it as an MCP server and send the same token as an
135
+ `Authorization: Bearer` header.
136
+
137
+ Prefer the CLI where a shell exists. An MCP tool call carries the whole document
138
+ through the model's context to get there, so a 30KB plan costs 30KB of tokens;
139
+ this reads the file off disk and the model never holds it.
140
+
141
+ ## Environment
142
+
143
+ | Variable | Meaning |
144
+ | --- | --- |
145
+ | `SUMIBAKO_TOKEN` | Use this token instead of the saved one |
146
+ | `SUMIBAKO_API` | Point at a different deployment |
147
+ | `NO_COLOR` | Turn off colour |
package/SKILL.md ADDED
@@ -0,0 +1,150 @@
1
+ ---
2
+ name: sumibako
3
+ description: File a plan, spec, ADR, handoff note or research report into the user's Sumibako vault and get back a link they can send to someone. Use when the user asks to save, file, publish or share a document you wrote, or asks for "a link to this", or says a teammate needs to read it. Also use to read, amend or take down a page already in the vault, and to search what is there before writing something new.
4
+ ---
5
+
6
+ # Filing documents in Sumibako
7
+
8
+ Sumibako is the user's notes vault. This skill puts a Markdown document you
9
+ wrote into it and, on request, gives you a public link to hand back.
10
+
11
+ ## When to use this
12
+
13
+ Reach for it when a document you produced needs to outlive the session or be
14
+ read by somebody who is not in it:
15
+
16
+ - The user says "save this", "file this", "put this in my notes", "publish
17
+ this", "give me a link", "send this to my team", "I need to share this".
18
+ - You finished a plan, spec, design, ADR, migration checklist, incident
19
+ write-up, research summary or handoff note, and the user wants to keep it.
20
+ - You are picking up work and want to know what is already written down:
21
+ `sumibako search`, or `sumibako search` with no words to see the vault.
22
+ - The user wants something already filed changed: a section added to a running
23
+ log, a decision corrected, a page taken back off the web.
24
+
25
+ Do **not** use it for things that belong in the repository. Code, tests,
26
+ configuration and specs that are reviewed alongside a diff go in the repo and
27
+ through a pull request. This is for the documents whose audience is a person,
28
+ not a compiler, and often a person who will never open the repo.
29
+
30
+ ## Setup
31
+
32
+ The user needs a token once, from Settings then Coding agents in Sumibako.
33
+
34
+ ```bash
35
+ npx sumibako login # prompts for the token
36
+ npx sumibako whoami # confirms it works
37
+ ```
38
+
39
+ If a command reports no token, stop and ask the user to create one rather than
40
+ guessing at credentials.
41
+
42
+ ## Filing a document
43
+
44
+ Write the Markdown to a file first, then publish the file. Do not pipe a
45
+ document through the command line.
46
+
47
+ ```bash
48
+ npx sumibako publish docs/plans/auth-rewrite.md
49
+ ```
50
+
51
+ To get a link the user can send to someone:
52
+
53
+ ```bash
54
+ npx sumibako publish docs/plans/auth-rewrite.md --public
55
+ ```
56
+
57
+ The command prints the page's link, and the public link when there is one.
58
+ Give the user the public link verbatim. Do not paraphrase or shorten it.
59
+
60
+ ## The one rule that matters
61
+
62
+ **A file is filed under its path in the repository, so publishing the same file
63
+ again updates the same page.** Run it after every revision. You will not create
64
+ duplicates, and the user's link keeps working and keeps showing the current
65
+ version.
66
+
67
+ Only pass `--new` when the user genuinely wants a second, separate page - a new
68
+ incident, a different feature. Reaching for it out of caution is how a vault
69
+ fills up with eleven copies of one plan.
70
+
71
+ ## Changing a page instead of replacing it
72
+
73
+ `publish` replaces the whole page, which is right when you still hold the file.
74
+ When you do not - the page was written in the app, or in a session that has
75
+ ended - read it and change the part that is wrong:
76
+
77
+ ```bash
78
+ npx sumibako open docs/plans/auth.md --markdown # the page, as Markdown
79
+ ```
80
+
81
+ That Markdown is the page. Add to it, or replace an exact piece of it:
82
+
83
+ ```bash
84
+ # add a line to the end, without sending the rest back
85
+ npx sumibako append docs/decisions.md "- 2026-09-06: chose Postgres over Dynamo"
86
+
87
+ # correct one sentence
88
+ npx sumibako edit docs/plans/auth.md \
89
+ --find "We will ship this in Q3." \
90
+ --replace "We will ship this in Q4, after the migration."
91
+
92
+ # rename
93
+ npx sumibako edit docs/plans/auth.md --title "Auth rewrite, revised"
94
+ ```
95
+
96
+ **`--find` must match exactly once.** It is matched against the Markdown that
97
+ `open --markdown` prints, not against the words as they look in the app, so
98
+ read the page first and paste from what you read. If it appears twice the
99
+ command refuses rather than guessing - quote more of the surrounding lines and
100
+ try again. Do not work around this by replacing the whole page unless the user
101
+ asked for a rewrite.
102
+
103
+ Prefer `append` over reading and re-publishing a page you are only adding to.
104
+ It sends one line instead of the whole document and cannot disturb the rest.
105
+
106
+ ## The rest of the commands
107
+
108
+ ```bash
109
+ npx sumibako search "auth rewrite" # what is already written down
110
+ npx sumibako search # or list the newest pages
111
+ npx sumibako open docs/plans/auth.md # the links for a page
112
+ npx sumibako open docs/plans/auth.md --markdown # and the page itself
113
+ npx sumibako unpublish docs/plans/auth.md # take the link off the web
114
+ ```
115
+
116
+ ## Writing for it
117
+
118
+ The document is rendered as a real page, so ordinary Markdown pays off:
119
+
120
+ - **The first heading becomes the page title.** Start with one `#` line saying
121
+ what the document is. Or put `title:` in YAML frontmatter.
122
+ - Headings, lists, task lists, tables, blockquotes, links and fenced code all
123
+ render properly. Tables are worth using for options and trade-offs.
124
+ - Code fences keep their language and get syntax highlighting.
125
+ - `> [!NOTE]` and `> [!WARNING]` become a bold label on the quote.
126
+
127
+ Two things degrade, and the command tells you when they do:
128
+
129
+ - **Mermaid diagrams** render as plain code, not pictures. A short prose
130
+ description alongside the diagram is worth adding.
131
+ - **Raw HTML** is kept as text.
132
+
133
+ ## What it will not do
134
+
135
+ - It cannot delete a page. The user does that in the app. `unpublish` takes the
136
+ link off the web and leaves the writing alone.
137
+ - It cannot make a page findable by search engines. Published pages carry
138
+ `noindex`; the user turns that on themselves if they want it, per page.
139
+ - It cannot read or write anyone else's vault.
140
+
141
+ ## Errors worth reading
142
+
143
+ - `400` - the request was wrong, and the message says how. From an edit this
144
+ is usually a `--find` that matched nothing or matched twice. Read the page
145
+ with `open --markdown` and quote it exactly.
146
+ - `401` - the token is missing, wrong or revoked. Ask the user for a new one.
147
+ - `403` - the token is not allowed to publish. The user can create one that is.
148
+ - `409` - the plan's page limit is reached. Report the message; it says which.
149
+ - `413` - the document is too long. Split it.
150
+ - `429` - too many requests. Wait the number of seconds it names.
package/index.mjs ADDED
@@ -0,0 +1,613 @@
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 fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import process from "node:process";
23
+ import readline from "node:readline";
24
+
25
+ const VERSION = "0.1.0";
26
+
27
+ /** Where the token lives when it is not in the environment. */
28
+ const CONFIG_DIR = path.join(os.homedir(), ".sumibako");
29
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
30
+
31
+ /**
32
+ * Where the API lives.
33
+ *
34
+ * The app's own domain rather than the Convex deployment behind it. This string
35
+ * ends up in config files, CI secrets and other people's shell history, so it
36
+ * has to be one that stays true - `next.config.mjs` rewrites it onto whichever
37
+ * deployment is current.
38
+ */
39
+ const DEFAULT_API = "https://sumibako.com/api/agent";
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Output
43
+ // ---------------------------------------------------------------------------
44
+
45
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
46
+ /**
47
+ * ANSI colour, with the escape byte written as an escape rather than typed.
48
+ *
49
+ * A literal escape character in source is invisible in every diff and every
50
+ * review, and survives exactly until something normalises the file.
51
+ */
52
+ const ESC = "\u001b";
53
+ const paint = (code, text) =>
54
+ useColor ? `${ESC}[${code}m${text}${ESC}[0m` : text;
55
+ const dim = (text) => paint("2", text);
56
+ const bold = (text) => paint("1", text);
57
+ const green = (text) => paint("32", text);
58
+ const red = (text) => paint("31", text);
59
+ const yellow = (text) => paint("33", text);
60
+
61
+ function die(message, hint) {
62
+ console.error(`${red("error")} ${message}`);
63
+ if (hint) console.error(dim(` ${hint}`));
64
+ process.exit(1);
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Config
69
+ // ---------------------------------------------------------------------------
70
+
71
+ function readConfig() {
72
+ try {
73
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
74
+ } catch {
75
+ return {};
76
+ }
77
+ }
78
+
79
+ function writeConfig(config) {
80
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
81
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", {
82
+ // The file holds a live credential. 0600 is the difference between "only
83
+ // me" and "every process running as any user on this machine", and it
84
+ // costs one argument. Windows ignores the mode, which is why the token is
85
+ // also accepted from the environment.
86
+ mode: 0o600,
87
+ });
88
+ try {
89
+ fs.chmodSync(CONFIG_FILE, 0o600);
90
+ } catch {
91
+ // Best effort: some filesystems do not support it.
92
+ }
93
+ }
94
+
95
+ /**
96
+ * The token, from the environment first and the config file second.
97
+ *
98
+ * Environment first so CI can inject one without writing to a home directory
99
+ * that may not persist, and so a person can override a stale saved token for
100
+ * one command without editing a file.
101
+ */
102
+ function resolveToken() {
103
+ const fromEnv = process.env.SUMIBAKO_TOKEN?.trim();
104
+ if (fromEnv) return fromEnv;
105
+ return readConfig().token ?? null;
106
+ }
107
+
108
+ function resolveApi() {
109
+ return (
110
+ process.env.SUMIBAKO_API?.trim().replace(/\/+$/, "") ||
111
+ readConfig().api ||
112
+ DEFAULT_API
113
+ );
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // HTTP
118
+ // ---------------------------------------------------------------------------
119
+
120
+ async function callApi(method, endpoint, { body, token, query, api } = {}) {
121
+ const base = api ?? resolveApi();
122
+ const url = new URL(base + endpoint);
123
+ for (const [key, value] of Object.entries(query ?? {})) {
124
+ if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
125
+ }
126
+
127
+ const authToken = token ?? resolveToken();
128
+ if (!authToken) {
129
+ die(
130
+ "No token. Run `npx sumibako login` first.",
131
+ "Create one in Sumibako under Settings, Coding agents.",
132
+ );
133
+ }
134
+
135
+ let response;
136
+ try {
137
+ response = await fetch(url, {
138
+ method,
139
+ headers: {
140
+ Authorization: `Bearer ${authToken}`,
141
+ ...(body ? { "Content-Type": "application/json" } : {}),
142
+ },
143
+ body: body ? JSON.stringify(body) : undefined,
144
+ });
145
+ } catch (error) {
146
+ die(
147
+ `Could not reach ${url.origin}.`,
148
+ error instanceof Error ? error.message : undefined,
149
+ );
150
+ }
151
+
152
+ const text = await response.text();
153
+ const contentType = response.headers.get("content-type") ?? "";
154
+
155
+ let payload = null;
156
+ if (text && contentType.includes("json")) {
157
+ try {
158
+ payload = JSON.parse(text);
159
+ } catch {
160
+ // Handled below, along with every other not-our-API response.
161
+ }
162
+ }
163
+
164
+ /*
165
+ Reached a server, but not this API.
166
+
167
+ Overwhelmingly this means the base URL points at the web app rather than the
168
+ API, and the app answered with an HTML page. Printing that page is how a
169
+ one-line configuration mistake turns into a screenful of markup with the
170
+ actual problem nowhere in it, so the body is deliberately not shown.
171
+ */
172
+ if (payload === null) {
173
+ die(
174
+ `${base} is not answering as the Sumibako API (HTTP ${response.status}, ${contentType || "no content type"}).`,
175
+ "Check the address: pass --api <url>, or set SUMIBAKO_API.",
176
+ );
177
+ }
178
+
179
+ if (!response.ok) {
180
+ const message = payload?.error?.message ?? text.slice(0, 300) ?? "Request failed.";
181
+ const code = payload?.error?.code;
182
+
183
+ // The three failures worth explaining rather than just reporting, because
184
+ // each has a next step the person cannot guess from the message alone.
185
+ if (response.status === 401) {
186
+ die(message, "Run `npx sumibako login` with a fresh token.");
187
+ }
188
+ if (response.status === 403) {
189
+ die(message, "Create a token with publishing allowed, in Settings.");
190
+ }
191
+ if (response.status === 429) {
192
+ const retry = response.headers.get("Retry-After");
193
+ die(message, retry ? `Wait ${retry} seconds and try again.` : undefined);
194
+ }
195
+ die(`${message}${code ? dim(` (${code})`) : ""}`);
196
+ }
197
+
198
+ return payload;
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Arguments
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /** Parses `--flag value`, `--flag=value` and `--boolean` into an object. */
206
+ function parseFlags(argv) {
207
+ const flags = {};
208
+ const positional = [];
209
+
210
+ for (let index = 0; index < argv.length; index += 1) {
211
+ const item = argv[index];
212
+ if (!item.startsWith("--")) {
213
+ positional.push(item);
214
+ continue;
215
+ }
216
+ const equals = item.indexOf("=");
217
+ if (equals !== -1) {
218
+ flags[item.slice(2, equals)] = item.slice(equals + 1);
219
+ continue;
220
+ }
221
+ const name = item.slice(2);
222
+ const next = argv[index + 1];
223
+ if (next === undefined || next.startsWith("--")) {
224
+ flags[name] = true;
225
+ } else {
226
+ flags[name] = next;
227
+ index += 1;
228
+ }
229
+ }
230
+
231
+ return { flags, positional };
232
+ }
233
+
234
+ /**
235
+ * The key a file is filed under, so re-running lands on one page.
236
+ *
237
+ * The repo-relative path, because that is the identity of an artifact that a
238
+ * person and an agent will both agree on: `docs/plans/auth.md` is the plan for
239
+ * auth, this week and next. Falls back to the path as given when the file sits
240
+ * outside a git repository.
241
+ *
242
+ * Normalised to forward slashes so the same file keyed from Windows and from
243
+ * CI resolves to the same page.
244
+ */
245
+ function defaultKey(filePath) {
246
+ const absolute = path.resolve(filePath);
247
+ let directory = path.dirname(absolute);
248
+
249
+ for (let depth = 0; depth < 40; depth += 1) {
250
+ if (fs.existsSync(path.join(directory, ".git"))) {
251
+ return path.relative(directory, absolute).split(path.sep).join("/");
252
+ }
253
+ const parent = path.dirname(directory);
254
+ if (parent === directory) break;
255
+ directory = parent;
256
+ }
257
+
258
+ return absolute.split(path.sep).join("/");
259
+ }
260
+
261
+ function readMarkdown(filePath) {
262
+ if (filePath === "-") {
263
+ return fs.readFileSync(0, "utf8");
264
+ }
265
+ if (!fs.existsSync(filePath)) {
266
+ die(`No such file: ${filePath}`);
267
+ }
268
+ const stats = fs.statSync(filePath);
269
+ if (stats.isDirectory()) {
270
+ die(`${filePath} is a directory. Point at one Markdown file.`);
271
+ }
272
+ return fs.readFileSync(filePath, "utf8");
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Commands
277
+ // ---------------------------------------------------------------------------
278
+
279
+ async function login(flags) {
280
+ let token = typeof flags.token === "string" ? flags.token.trim() : "";
281
+
282
+ if (!token) {
283
+ if (!process.stdin.isTTY) {
284
+ die(
285
+ "No token given.",
286
+ "Pass --token <token>, or set SUMIBAKO_TOKEN in the environment.",
287
+ );
288
+ }
289
+ console.log(
290
+ `Create a token in Sumibako under ${bold("Settings, Coding agents")}, then paste it here.`,
291
+ );
292
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
293
+ token = (await new Promise((resolve) => rl.question("Token: ", resolve))).trim();
294
+ rl.close();
295
+ }
296
+
297
+ if (!token) die("No token given.");
298
+
299
+ const api = typeof flags.api === "string" ? flags.api.replace(/\/+$/, "") : undefined;
300
+
301
+ // Checked before anything is saved, so a mistyped token or a wrong address
302
+ // fails here rather than halfway through the next session - and a failed
303
+ // login leaves whatever was configured before it untouched.
304
+ const who = await callApi("GET", "/v1/whoami", { token, api });
305
+
306
+ writeConfig({ ...readConfig(), token, ...(api ? { api } : {}) });
307
+
308
+ console.log(
309
+ `${green("Signed in.")} Plan ${bold(who.plan)}, can ${who.scopes
310
+ .map((scope) => scope.replace("pages:", ""))
311
+ .join(", ")}.`,
312
+ );
313
+ console.log(dim(`Token saved to ${CONFIG_FILE}`));
314
+ }
315
+
316
+ function logout() {
317
+ const config = readConfig();
318
+ delete config.token;
319
+ writeConfig(config);
320
+ console.log(`${green("Signed out.")} The token was removed from this machine.`);
321
+ console.log(dim("Revoke it in Settings if it may have leaked."));
322
+ }
323
+
324
+ async function publish(positional, flags) {
325
+ const file = positional[0];
326
+ if (!file) {
327
+ die("Which file?", "Usage: sumibako publish <file.md> [--public]");
328
+ }
329
+
330
+ const markdown = readMarkdown(file);
331
+ if (!markdown.trim()) die(`${file} is empty.`);
332
+
333
+ const key =
334
+ typeof flags.key === "string"
335
+ ? flags.key
336
+ : flags.new === true || file === "-"
337
+ ? undefined
338
+ : defaultKey(file);
339
+
340
+ const wantsPublic = flags.public === true || flags.publish === true;
341
+
342
+ const result = await callApi("POST", "/v1/pages", {
343
+ body: {
344
+ markdown,
345
+ externalId: key,
346
+ title: typeof flags.title === "string" ? flags.title : undefined,
347
+ parentDocument: typeof flags.parent === "string" ? flags.parent : undefined,
348
+ publish: wantsPublic ? true : undefined,
349
+ },
350
+ });
351
+
352
+ report(result, { verb: result.created ? "Created" : "Updated" });
353
+ }
354
+
355
+ /**
356
+ * Works out whether an argument names a file on disk or a page id.
357
+ *
358
+ * A file that exists is keyed by its repo path, the same way `publish` filed
359
+ * it. Anything else is taken to be a page id, which is what someone pastes
360
+ * after copying it out of a URL.
361
+ */
362
+ function targetFor(argument, flags) {
363
+ if (typeof flags.key === "string") return { externalId: flags.key };
364
+ if (!argument) return {};
365
+ if (fs.existsSync(argument)) return { externalId: defaultKey(argument) };
366
+ return { documentId: argument };
367
+ }
368
+
369
+ /**
370
+ * Adds to a page without sending it back.
371
+ *
372
+ * The running-log case, which `publish` handles badly: filing a note at the
373
+ * end of a session with `publish` means reading the whole page, adding a line
374
+ * and writing all of it again. Here the text to add is the only thing that
375
+ * crosses the wire.
376
+ *
377
+ * Words after the target are the text. With none, it is read from stdin, so
378
+ * `git log -1 --format=%s | sumibako append CHANGELOG.md` works.
379
+ */
380
+ async function append(positional, flags) {
381
+ const target = targetFor(positional[0], flags);
382
+ if (!target.documentId && !target.externalId) {
383
+ die("Which page?", "Usage: sumibako append <file.md | page-id> \"text\"");
384
+ }
385
+
386
+ const inline = positional.slice(1).join(" ");
387
+ const text = inline || fs.readFileSync(0, "utf8");
388
+ if (!text.trim()) {
389
+ die("Nothing to add.", "Pass the text as an argument or pipe it in.");
390
+ }
391
+
392
+ const result = await callApi("PATCH", "/v1/pages", {
393
+ body: {
394
+ ...target,
395
+ [flags.prepend === true ? "prepend" : "append"]: text,
396
+ },
397
+ });
398
+ report(result, {
399
+ verb: flags.prepend === true ? "Prepended to" : "Appended to",
400
+ });
401
+ }
402
+
403
+ /**
404
+ * Replaces one exact piece of text in a page.
405
+ *
406
+ * `--find` matches against the page's Markdown, which is what `open
407
+ * --markdown` prints, so the way to use this is to look first and paste. The
408
+ * API refuses a match that is not unique rather than guessing, so a failure
409
+ * here means "say more", not "try again".
410
+ */
411
+ async function edit(positional, flags) {
412
+ const target = targetFor(positional[0], flags);
413
+ if (!target.documentId && !target.externalId) {
414
+ die(
415
+ "Which page?",
416
+ "Usage: sumibako edit <file.md | page-id> --find <old> --replace <new>",
417
+ );
418
+ }
419
+
420
+ const rename = typeof flags.title === "string" ? flags.title : undefined;
421
+ const find = typeof flags.find === "string" ? flags.find : undefined;
422
+ if (find === undefined && rename === undefined) {
423
+ die(
424
+ "Nothing to change.",
425
+ "Pass --find with --replace, or --title to rename the page.",
426
+ );
427
+ }
428
+ // An empty --replace is a deletion, and has to survive the default below.
429
+ const replace = typeof flags.replace === "string" ? flags.replace : "";
430
+
431
+ const result = await callApi("PATCH", "/v1/pages", {
432
+ body: {
433
+ ...target,
434
+ ...(find !== undefined ? { find, replace } : {}),
435
+ ...(rename !== undefined ? { title: rename } : {}),
436
+ },
437
+ });
438
+ report(result, { verb: "Edited" });
439
+ }
440
+
441
+ async function unpublish(positional, flags) {
442
+ const target = targetFor(positional[0], flags);
443
+ if (!target.documentId && !target.externalId) {
444
+ die("Which page?", "Usage: sumibako unpublish <file.md | page-id>");
445
+ }
446
+
447
+ const result = await callApi("POST", "/v1/pages/publish", {
448
+ body: { ...target, publish: false },
449
+ });
450
+ console.log(`${green("Taken down.")} ${bold(result.title)} is private again.`);
451
+ console.log(dim(result.url));
452
+ }
453
+
454
+ async function open(positional, flags) {
455
+ const target = targetFor(positional[0], flags);
456
+ if (!target.documentId && !target.externalId) {
457
+ die("Which page?", "Usage: sumibako open <file.md | page-id>");
458
+ }
459
+
460
+ const page = await callApi("GET", "/v1/pages", {
461
+ query: { id: target.documentId, externalId: target.externalId },
462
+ });
463
+ console.log(bold(page.title));
464
+ console.log(page.url);
465
+ if (page.publicUrl) console.log(green(page.publicUrl));
466
+
467
+ // Two names for one thing. `--text` came first and is in people's scripts;
468
+ // Markdown is strictly the better answer, because it is what you edit and
469
+ // send back, so both flags print it rather than keeping a worse output alive
470
+ // for the sake of a flag name.
471
+ if (flags.markdown === true || flags.text === true) {
472
+ console.log();
473
+ console.log(page.markdown || page.text);
474
+ for (const warning of page.markdownWarnings ?? []) {
475
+ console.log(yellow(`note ${warning}`));
476
+ }
477
+ }
478
+ }
479
+
480
+ async function search(positional) {
481
+ // No words is a question too: "what is in here". It lists the newest pages
482
+ // rather than explaining the command to somebody who has just been handed a
483
+ // vault they have never seen.
484
+ const term = positional.join(" ").trim();
485
+
486
+ const { results } = await callApi("GET", "/v1/pages/search", {
487
+ query: { q: term || undefined, limit: term ? undefined : 20 },
488
+ });
489
+
490
+ if (results.length === 0) {
491
+ console.log(dim(term ? "Nothing matched." : "This vault has no pages yet."));
492
+ return;
493
+ }
494
+ for (const page of results) {
495
+ console.log(`${bold(page.title)}${page.publicUrl ? green(" public") : ""}`);
496
+ console.log(dim(` ${page.publicUrl ?? page.url}`));
497
+ }
498
+ }
499
+
500
+ async function whoami() {
501
+ const who = await callApi("GET", "/v1/whoami");
502
+ const usage = await callApi("GET", "/v1/usage");
503
+ console.log(`Plan ${bold(who.plan)} at ${who.site}`);
504
+ console.log(`Permissions: ${who.scopes.join(", ")}`);
505
+ console.log(
506
+ `Pages: ${usage.documents} of ${limit(usage.maxDocuments)} Published: ${usage.published} of ${limit(usage.maxPublished)}`,
507
+ );
508
+ }
509
+
510
+ const limit = (value) => (value === null || !Number.isFinite(value) ? "unlimited" : value);
511
+
512
+ /** Prints the outcome of a write, link last so it is the easiest thing to copy. */
513
+ function report(result, { verb }) {
514
+ console.log(`${green(verb)} ${bold(result.title)}`);
515
+ for (const warning of result.warnings ?? []) {
516
+ console.log(`${yellow("note")} ${warning}`);
517
+ }
518
+ console.log(dim(result.url));
519
+ if (result.publicUrl) {
520
+ console.log();
521
+ console.log(`${bold("Share this:")} ${result.publicUrl}`);
522
+ }
523
+ }
524
+
525
+ // ---------------------------------------------------------------------------
526
+ // Entry
527
+ // ---------------------------------------------------------------------------
528
+
529
+ const HELP = `
530
+ ${bold("sumibako")} - file what your coding agent wrote into your vault
531
+
532
+ ${bold("sumibako login")} [--token <t>] [--api <url>] save a token on this machine
533
+ ${bold("sumibako publish")} <file.md> [--public] file a Markdown file as a page
534
+ ${bold("sumibako unpublish")} <file.md> take a published page off the web
535
+ ${bold("sumibako append")} <file.md> <text> add to the end of a page
536
+ ${bold("sumibako edit")} <file.md> --find ... replace one piece of text
537
+ ${bold("sumibako open")} <file.md> [--markdown] print the links, or the page
538
+ ${bold("sumibako search")} [words] search your vault, or list it
539
+ ${bold("sumibako whoami")} check the token and plan
540
+ ${bold("sumibako logout")} forget the token
541
+
542
+ ${bold("Options for publish")}
543
+ --public publish it and print a shareable link
544
+ --title <title> override the title (default: the first heading)
545
+ --key <key> the identity of this artifact (default: its repo path)
546
+ --new file a new page even if this file was filed before
547
+ --parent <page-id> nest it under an existing page
548
+
549
+ ${bold("Options for append and edit")}
550
+ --prepend add to the start of the page instead of the end
551
+ --find <text> the exact text to replace, as open --markdown prints it
552
+ --replace <text> what to put there; empty deletes the matched text
553
+ --title <title> rename the page
554
+ --key <key> name the page by its key rather than a path or an id
555
+
556
+ ${bold("Editing a page you did not write")}
557
+ open --markdown prints the page as Markdown, which is the same text --find
558
+ matches against. A --find that appears twice is refused rather than guessed
559
+ at, so quote enough of the surrounding lines to be unambiguous.
560
+
561
+ ${bold("How re-running works")}
562
+ A file is filed under its path in the repo, so publishing the same file again
563
+ updates the same page instead of making a second one. Pass --new when you
564
+ genuinely want another page, or --key to choose the identity yourself.
565
+
566
+ ${bold("Environment")}
567
+ SUMIBAKO_TOKEN use this token instead of the saved one
568
+ SUMIBAKO_API point at a different deployment
569
+ `;
570
+
571
+ async function main() {
572
+ const [command, ...rest] = process.argv.slice(2);
573
+ const { flags, positional } = parseFlags(rest);
574
+
575
+ if (!command || command === "help" || flags.help === true) {
576
+ console.log(HELP);
577
+ return;
578
+ }
579
+ if (command === "--version" || command === "version") {
580
+ console.log(VERSION);
581
+ return;
582
+ }
583
+
584
+ switch (command) {
585
+ case "login":
586
+ return login(flags);
587
+ case "logout":
588
+ return logout();
589
+ case "publish":
590
+ case "push":
591
+ return publish(positional, flags);
592
+ case "unpublish":
593
+ return unpublish(positional, flags);
594
+ case "append":
595
+ return append(positional, flags);
596
+ case "edit":
597
+ return edit(positional, flags);
598
+ case "open":
599
+ case "get":
600
+ return open(positional, flags);
601
+ case "search":
602
+ case "list":
603
+ return search(positional);
604
+ case "whoami":
605
+ return whoami();
606
+ default:
607
+ die(`Unknown command: ${command}`, "Run `sumibako help` for the list.");
608
+ }
609
+ }
610
+
611
+ main().catch((error) => {
612
+ die(error instanceof Error ? error.message : String(error));
613
+ });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "sumibako",
3
+ "version": "0.1.0",
4
+ "description": "File the plans, specs and notes your coding agent writes into your Sumibako vault, and get a shareable link back.",
5
+ "type": "module",
6
+ "bin": {
7
+ "sumibako": "./index.mjs"
8
+ },
9
+ "main": "./index.mjs",
10
+ "files": [
11
+ "index.mjs",
12
+ "README.md",
13
+ "SKILL.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "keywords": [
19
+ "sumibako",
20
+ "notes",
21
+ "markdown",
22
+ "publish",
23
+ "agent",
24
+ "agents",
25
+ "claude-code",
26
+ "codex",
27
+ "cursor",
28
+ "cli"
29
+ ],
30
+ "homepage": "https://sumibako.com",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/PrashantShaw/Taskly.git",
34
+ "directory": "cli"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/PrashantShaw/Taskly/issues"
38
+ },
39
+ "author": "Prashant Shaw",
40
+ "license": "MIT",
41
+ "dependencies": {}
42
+ }