bikeshed-now 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,28 @@ Publishing identical bytes is a no-op: the bundle is hashed, and a matching hash
34
34
  mints no new version. So `update` is safe in a loop, in CI, or after a change you
35
35
  are not sure you made.
36
36
 
37
+ A document of several files is a `.zip` with `index.html` or `index.md` at its
38
+ root. `update` with a `.zip` replaces every file; `update` with one `.md` or
39
+ `.html` replaces the entry and keeps the images and stylesheets beside it.
40
+
41
+ The address comes from the title. To choose it, pass `--id`:
42
+
43
+ ```bash
44
+ bikeshed publish platform.md --id 01-platform
45
+ # published 01-platform v1 — https://<deployment>/d/01-platform
46
+ ```
47
+
48
+ If the id is taken, the document is still published, at the id plus a few
49
+ random characters, and the CLI says so on stderr.
50
+
51
+ Wherever a command takes an id, it also takes the link you were sent. An
52
+ unlisted document you are not a member of needs the link, because its key is in
53
+ it; without the key the document answers as an id that does not exist.
54
+
55
+ ```bash
56
+ bikeshed comments 'https://<deployment>/d/build-estimate?k=…'
57
+ ```
58
+
37
59
  `bikeshed --help` lists the rest — listing documents, folders, and installing the
38
60
  agent skills a deployment serves.
39
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bikeshed-now",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "description": "Publish and revise documents on a Bikeshed deployment from the terminal.",
package/src/client.mjs CHANGED
@@ -4,23 +4,27 @@
4
4
  import { readFileSync } from "node:fs";
5
5
  import { basename } from "node:path";
6
6
 
7
- export const CLIENT_NAME = "bikeshed-cli/0.2.0";
7
+ export const CLIENT_NAME = "bikeshed-cli/0.4.0";
8
8
 
9
9
  /**
10
- * Two surfaces, and the split is deliberate rather than untidy.
10
+ * Three surfaces, and the split is deliberate rather than untidy.
11
11
  *
12
12
  * POST /publish takes RAW BYTES, so it is the only way to send a .zip
13
13
  * bundle without base64-inflating it through JSON. It is
14
- * also the exact endpoint the browser uploader calls.
14
+ * also the exact endpoint the browser uploader calls. With
15
+ * `update=<id>` it revises that document instead, which is
16
+ * how `update` sends a .zip.
15
17
  * POST /mcp is the API for everything else — list, read, update,
16
18
  * comments, reply — because those tools already exist and
17
19
  * adding a parallel REST surface would mean two
18
20
  * implementations of the same six operations.
21
+ * GET /content the byte path a browser reads a document through. Only
22
+ * `get` uses it, and only for a source get_document cut.
19
23
  *
20
- * Both carry the same Bearer token, and both stamp provenance server-side. The
21
- * `X-Bikeshed-Client` and `X-Bikeshed-Agent` headers are self-reported and are
22
- * recorded as such: they are for a human reading an activity feed, not for
23
- * anything that makes a decision.
24
+ * All three carry the same Bearer token, and provenance is stamped
25
+ * server-side. The `X-Bikeshed-Client` and `X-Bikeshed-Agent` headers are
26
+ * self-reported and are recorded as such: they are for a human reading an
27
+ * activity feed, not for anything that makes a decision.
24
28
  */
25
29
 
26
30
  function headers(token, extra = {}) {
@@ -79,12 +83,19 @@ export async function callTool(origin, token, name, args = {}) {
79
83
  return result?.structuredContent ?? result;
80
84
  }
81
85
 
82
- /** Upload a file as a NEW document, exactly as the browser uploader does. */
83
- export async function uploadFile(origin, token, path, { title, folder } = {}) {
86
+ /**
87
+ * Upload a file as a NEW document, exactly as the browser uploader does — or,
88
+ * with `update`, as a new version of that document, with `k` for an unlisted
89
+ * one.
90
+ */
91
+ export async function uploadFile(origin, token, path, { title, folder, id, update, k } = {}) {
84
92
  const bytes = readFileSync(path);
85
93
  const params = new URLSearchParams({ filename: basename(path) });
86
94
  if (title) params.set("title", title);
87
95
  if (folder) params.set("folderId", folder);
96
+ if (id) params.set("id", id);
97
+ if (update) params.set("update", update);
98
+ if (k) params.set("k", k);
88
99
 
89
100
  const res = await fetch(`${origin}/publish?${params}`, {
90
101
  method: "POST",
@@ -101,6 +112,27 @@ export async function uploadFile(origin, token, path, { title, folder } = {}) {
101
112
  return res.json();
102
113
  }
103
114
 
115
+ /**
116
+ * A document's whole source file, as bytes.
117
+ *
118
+ * get_document cuts a long source, because its result lands in an agent's
119
+ * context. A command's output lands in a file or a pipe, and a `get` that
120
+ * prints part of a file is a broken `cat`. So the cut source is read again
121
+ * here, through the path a browser uses: the same object, behind the same
122
+ * read, key and expiry checks.
123
+ */
124
+ export async function fetchSource(origin, token, { id, version, sourceFile, k }) {
125
+ const path = [id, version, ...String(sourceFile).split("/")].map(encodeURIComponent).join("/");
126
+ const query = k ? `?${new URLSearchParams({ k })}` : "";
127
+ const res = await fetch(`${origin}/content/${path}${query}`, { headers: headers(token) });
128
+ if (!res.ok) {
129
+ throw new CliError(
130
+ `${id} is longer than one response, and reading the rest failed (${res.status}). Nothing was printed.`,
131
+ );
132
+ }
133
+ return Buffer.from(await res.arrayBuffer());
134
+ }
135
+
104
136
  /**
105
137
  * The skills a deployment publishes, and their text.
106
138
  *
package/src/index.mjs CHANGED
@@ -8,7 +8,15 @@ import { basename, join, resolve } from "node:path";
8
8
  import { createInterface } from "node:readline/promises";
9
9
 
10
10
  import { CONFIG_PATH, readConfig, resolveAuth, writeConfig } from "./config.mjs";
11
- import { CliError, callTool, fetchSkill, listSkills, uploadFile, whoami } from "./client.mjs";
11
+ import {
12
+ CliError,
13
+ callTool,
14
+ fetchSkill,
15
+ fetchSource,
16
+ listSkills,
17
+ uploadFile,
18
+ whoami,
19
+ } from "./client.mjs";
12
20
 
13
21
  /**
14
22
  * This is the END-USER tool, and it is deliberately not the same thing as the
@@ -36,6 +44,9 @@ const USAGE = `bikeshed — publish and revise documents
36
44
  bikeshed reply <id> <thread> <text> reply in a thread
37
45
  bikeshed open <id> open it in a browser
38
46
 
47
+ Where a command takes <id> it also takes the link you were sent. An unlisted
48
+ document you are not a member of needs the link: its key is in it.
49
+
39
50
  Organising
40
51
  bikeshed folders the folder tree, with ids
41
52
  bikeshed mkdir <name> [--parent <id>] create a folder
@@ -46,9 +57,23 @@ Skills
46
57
  bikeshed skills install <dir> write them into an agent's skills directory
47
58
  (Claude Code: ~/.claude/skills)
48
59
 
60
+ What you can publish
61
+ .md .markdown rendered to HTML on the server; get returns the Markdown
62
+ .html .htm stored as written
63
+ .zip several files, index.html or index.md at the root
64
+ Up to 25 MB. An update keeps the format: a .zip replaces every file, one .md
65
+ or .html replaces the entry and keeps the files beside it.
66
+ Scripts run once a person trusts the document in the app: its owner for
67
+ everyone, any reader for themselves. Each update sandboxes it again. Images,
68
+ stylesheets and fonts from other sites load either way. Markdown is
69
+ CommonMark with tables, strikethrough and task lists: Mermaid, math,
70
+ footnotes and front matter show as typed. The deployment's /llms.txt has the
71
+ full list.
72
+
49
73
  Options
50
74
  --json machine-readable output
51
75
  --title <text> title for publish
76
+ --id <id> with publish: the address, /d/<id> (default: from the title)
52
77
  --folder <id> destination folder for publish
53
78
  --parent <id> parent folder for mkdir
54
79
  --limit <n> with list: rows per page (default 20, max 50)
@@ -80,6 +105,33 @@ function out(json, value, human) {
80
105
  human();
81
106
  }
82
107
 
108
+ /**
109
+ * A document as a person holds it: its id, or the link they were sent. The link
110
+ * is the only place an unlisted document's key exists, so a pasted one is read
111
+ * for both — `…/d/<id>[/<version>]?k=<key>` — and the key goes along as the
112
+ * tools' `k` argument (SPEC §17).
113
+ */
114
+ function docArgs(ref) {
115
+ if (!/^https?:\/\//i.test(ref)) return { id: ref };
116
+ let url;
117
+ try {
118
+ url = new URL(ref);
119
+ } catch {
120
+ fail(`not a document link: ${ref}`);
121
+ }
122
+ const match = url.pathname.match(/^\/d\/([^/]+)(?:\/[^/]+)?\/?$/);
123
+ if (!match) fail(`not a document link: ${ref}`);
124
+ const k = url.searchParams.get("k");
125
+ return { id: decodeURIComponent(match[1]), ...(k ? { k } : {}) };
126
+ }
127
+
128
+ const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
129
+
130
+ /** A .zip by name, or by the bytes the deployment itself sniffs for. */
131
+ function isArchive(path, bytes) {
132
+ return /\.zip$/i.test(path) || bytes.subarray(0, 4).equals(ZIP_MAGIC);
133
+ }
134
+
83
135
  // --- Commands --------------------------------------------------------------
84
136
 
85
137
  async function login(flags) {
@@ -144,6 +196,7 @@ async function list(flags) {
144
196
 
145
197
  async function publish(flags, [path]) {
146
198
  if (!path) fail("usage: bikeshed publish <file>");
199
+ if (flags.id === true) fail("usage: bikeshed publish <file> --id <id>");
147
200
  const { origin, token } = resolveAuth(flags);
148
201
  const file = resolve(path);
149
202
 
@@ -151,7 +204,14 @@ async function publish(flags, [path]) {
151
204
  const result = await uploadFile(origin, token, file, {
152
205
  title: flags.title,
153
206
  folder: flags.folder,
207
+ id: flags.id,
154
208
  });
209
+ // A taken id still publishes, under a suffix. Said on stderr so `--json`
210
+ // stays parseable; "not available" also covers a deployment too old to
211
+ // know the parameter, which ignores it.
212
+ if (flags.id && result.docId !== flags.id) {
213
+ console.error(`${flags.id} was not available; published at ${result.docId}`);
214
+ }
155
215
  // The address is in the human line already; `--json` carried only the id, so
156
216
  // anything reading it had to know how to build a URL out of a docId.
157
217
  out(flags.json, { ...result, url: result.url ?? `${origin}/d/${result.docId}` }, () =>
@@ -170,14 +230,20 @@ async function publish(flags, [path]) {
170
230
  }
171
231
  }
172
232
 
173
- async function update(flags, [id, path]) {
174
- if (!id || !path) fail("usage: bikeshed update <id> <file>");
233
+ async function update(flags, [ref, path]) {
234
+ if (!ref || !path) fail("usage: bikeshed update <id> <file>");
175
235
  const { origin, token } = resolveAuth(flags);
176
236
  const file = resolve(path);
237
+ const doc = docArgs(ref);
238
+ const { id } = doc;
177
239
 
178
240
  const once = async () => {
179
- const content = readFileSync(file, "utf8");
180
- const result = await callTool(origin, token, "update_document", { id, content });
241
+ const bytes = readFileSync(file);
242
+ // An archive goes up as bytes, to the one endpoint that takes them. Read as
243
+ // text, every byte above 0x7f in it would become U+FFFD on the way.
244
+ const result = isArchive(file, bytes)
245
+ ? await uploadFile(origin, token, file, { update: id, k: doc.k })
246
+ : await callTool(origin, token, "update_document", { ...doc, content: bytes.toString("utf8") });
181
247
  out(flags.json, { ...result, url: result.url ?? `${origin}/d/${id}` }, () =>
182
248
  console.log(
183
249
  result.deduped
@@ -192,18 +258,35 @@ async function update(flags, [id, path]) {
192
258
  if (flags.watch) await watchFile(file, once);
193
259
  }
194
260
 
195
- async function get(flags, [id]) {
196
- if (!id) fail("usage: bikeshed get <id>");
261
+ async function get(flags, [ref]) {
262
+ if (!ref) fail("usage: bikeshed get <id>");
197
263
  const { origin, token } = resolveAuth(flags);
198
- const result = await callTool(origin, token, "get_document", { id });
199
- out(flags.json, result, () => process.stdout.write(result.source));
264
+ const doc = docArgs(ref);
265
+ const result = await callTool(origin, token, "get_document", doc);
266
+ // The tool cuts a long source at a fixed length. Printed as it came, the cut
267
+ // copy exited 0 and looked complete, and `update` with an edit of it
268
+ // published a version missing everything past the cut. So a cut source is
269
+ // read whole, or nothing is printed.
270
+ const whole = result.truncated
271
+ ? await fetchSource(origin, token, {
272
+ id: doc.id,
273
+ version: result.version,
274
+ sourceFile: result.sourceFile,
275
+ k: doc.k,
276
+ })
277
+ : null;
278
+ out(
279
+ flags.json,
280
+ whole ? { ...result, truncated: false, source: whole.toString("utf8") } : result,
281
+ () => process.stdout.write(whole ?? result.source),
282
+ );
200
283
  }
201
284
 
202
- async function comments(flags, [id]) {
203
- if (!id) fail("usage: bikeshed comments <id>");
285
+ async function comments(flags, [ref]) {
286
+ if (!ref) fail("usage: bikeshed comments <id>");
204
287
  const { origin, token } = resolveAuth(flags);
205
288
  const result = await callTool(origin, token, "get_comments", {
206
- id,
289
+ ...docArgs(ref),
207
290
  includeResolved: Boolean(flags.all),
208
291
  });
209
292
  out(flags.json, result, () => {
@@ -225,11 +308,15 @@ async function comments(flags, [id]) {
225
308
  });
226
309
  }
227
310
 
228
- async function reply(flags, [id, threadId, ...rest]) {
311
+ async function reply(flags, [ref, threadId, ...rest]) {
229
312
  const body = rest.join(" ");
230
- if (!id || !threadId || !body) fail("usage: bikeshed reply <id> <thread> <text>");
313
+ if (!ref || !threadId || !body) fail("usage: bikeshed reply <id> <thread> <text>");
231
314
  const { origin, token } = resolveAuth(flags);
232
- const result = await callTool(origin, token, "reply_to_comment", { id, threadId, body });
315
+ const result = await callTool(origin, token, "reply_to_comment", {
316
+ ...docArgs(ref),
317
+ threadId,
318
+ body,
319
+ });
233
320
  out(flags.json, result, () => console.log(`replied in ${threadId}`));
234
321
  }
235
322
 
@@ -391,6 +478,7 @@ async function main() {
391
478
  all: { type: "boolean", default: false },
392
479
  help: { type: "boolean", default: false },
393
480
  title: { type: "string" },
481
+ id: { type: "string" },
394
482
  folder: { type: "string" },
395
483
  parent: { type: "string" },
396
484
  origin: { type: "string" },