bikeshed-now 0.3.0 → 0.4.1

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
@@ -3,24 +3,26 @@
3
3
  Publish a document to a [Bikeshed](https://bikeshed.now) deployment, then read
4
4
  the comments people left on it — each one with the exact sentence it is about.
5
5
 
6
- Bikeshed is self-hosted. This client talks to whichever deployment you point it
7
- at, over HTTPS, with a personal access token that carries exactly your own
8
- permissions.
6
+ It talks to [bikeshed.now](https://bikeshed.now) over HTTPS, with a personal
7
+ access token that carries exactly your own permissions. Bikeshed is also
8
+ self-hostable: point the client at your own deployment and everything below
9
+ works the same, with your address in place of `https://bikeshed.now`.
9
10
 
10
11
  ```bash
11
12
  npm i -g bikeshed-now
12
13
  bikeshed login # asks for the deployment and a token
13
14
  ```
14
15
 
15
- Create a token in the deployment's interface, under **Account → Access tokens**.
16
- It is shown once. `BIKESHED_ORIGIN` and `BIKESHED_TOKEN` work instead of
17
- `login` where an interactive command cannot run — a CI job, or an agent.
16
+ Create a token at [bikeshed.now/account](https://bikeshed.now/account) — choose
17
+ **Command line**. It is shown once. `BIKESHED_ORIGIN` and `BIKESHED_TOKEN` work
18
+ instead of `login` where an interactive command cannot run — a CI job, or an
19
+ agent.
18
20
 
19
21
  ## The loop
20
22
 
21
23
  ```bash
22
24
  bikeshed publish proposal.md --title "Storage migration"
23
- # published storage-migration v1 — https://<deployment>/d/storage-migration
25
+ # published storage-migration v1 — https://bikeshed.now/d/storage-migration
24
26
 
25
27
  # Somebody highlights a sentence and says what is wrong with it.
26
28
  bikeshed comments storage-migration
@@ -34,16 +36,28 @@ Publishing identical bytes is a no-op: the bundle is hashed, and a matching hash
34
36
  mints no new version. So `update` is safe in a loop, in CI, or after a change you
35
37
  are not sure you made.
36
38
 
39
+ A document of several files is a `.zip` with `index.html` or `index.md` at its
40
+ root. `update` with a `.zip` replaces every file; `update` with one `.md` or
41
+ `.html` replaces the entry and keeps the images and stylesheets beside it.
42
+
37
43
  The address comes from the title. To choose it, pass `--id`:
38
44
 
39
45
  ```bash
40
46
  bikeshed publish platform.md --id 01-platform
41
- # published 01-platform v1 — https://<deployment>/d/01-platform
47
+ # published 01-platform v1 — https://bikeshed.now/d/01-platform
42
48
  ```
43
49
 
44
50
  If the id is taken, the document is still published, at the id plus a few
45
51
  random characters, and the CLI says so on stderr.
46
52
 
53
+ Wherever a command takes an id, it also takes the link you were sent. An
54
+ unlisted document you are not a member of needs the link, because its key is in
55
+ it; without the key the document answers as an id that does not exist.
56
+
57
+ ```bash
58
+ bikeshed comments 'https://bikeshed.now/d/build-estimate?k=…'
59
+ ```
60
+
47
61
  `bikeshed --help` lists the rest — listing documents, folders, and installing the
48
62
  agent skills a deployment serves.
49
63
 
@@ -54,8 +68,9 @@ expiry. Those are decisions a person makes in the interface, deliberately.
54
68
 
55
69
  ## For agents
56
70
 
57
- A deployment serves its own instructions at `https://<deployment>/llms.txt`, and
58
- the skills behind them at `/skills/index.json`. `bikeshed skills install <dir>`
71
+ A deployment serves its own instructions at
72
+ [`https://bikeshed.now/llms.txt`](https://bikeshed.now/llms.txt), and the skills
73
+ behind them at `/skills/index.json`. `bikeshed skills install <dir>`
59
74
  writes them where your harness reads skills from.
60
75
 
61
76
  Everything done with a token is stamped with the channel it came through, so a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bikeshed-now",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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.3.0";
7
+ export const CLIENT_NAME = "bikeshed-cli/0.4.1";
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,13 +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, id } = {}) {
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);
88
96
  if (id) params.set("id", id);
97
+ if (update) params.set("update", update);
98
+ if (k) params.set("k", k);
89
99
 
90
100
  const res = await fetch(`${origin}/publish?${params}`, {
91
101
  method: "POST",
@@ -102,6 +112,27 @@ export async function uploadFile(origin, token, path, { title, folder, id } = {}
102
112
  return res.json();
103
113
  }
104
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
+
105
136
  /**
106
137
  * The skills a deployment publishes, and their text.
107
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,6 +57,19 @@ 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
@@ -81,6 +105,33 @@ function out(json, value, human) {
81
105
  human();
82
106
  }
83
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
+
84
135
  // --- Commands --------------------------------------------------------------
85
136
 
86
137
  async function login(flags) {
@@ -179,14 +230,20 @@ async function publish(flags, [path]) {
179
230
  }
180
231
  }
181
232
 
182
- async function update(flags, [id, path]) {
183
- 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>");
184
235
  const { origin, token } = resolveAuth(flags);
185
236
  const file = resolve(path);
237
+ const doc = docArgs(ref);
238
+ const { id } = doc;
186
239
 
187
240
  const once = async () => {
188
- const content = readFileSync(file, "utf8");
189
- 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") });
190
247
  out(flags.json, { ...result, url: result.url ?? `${origin}/d/${id}` }, () =>
191
248
  console.log(
192
249
  result.deduped
@@ -201,18 +258,35 @@ async function update(flags, [id, path]) {
201
258
  if (flags.watch) await watchFile(file, once);
202
259
  }
203
260
 
204
- async function get(flags, [id]) {
205
- if (!id) fail("usage: bikeshed get <id>");
261
+ async function get(flags, [ref]) {
262
+ if (!ref) fail("usage: bikeshed get <id>");
206
263
  const { origin, token } = resolveAuth(flags);
207
- const result = await callTool(origin, token, "get_document", { id });
208
- 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
+ );
209
283
  }
210
284
 
211
- async function comments(flags, [id]) {
212
- if (!id) fail("usage: bikeshed comments <id>");
285
+ async function comments(flags, [ref]) {
286
+ if (!ref) fail("usage: bikeshed comments <id>");
213
287
  const { origin, token } = resolveAuth(flags);
214
288
  const result = await callTool(origin, token, "get_comments", {
215
- id,
289
+ ...docArgs(ref),
216
290
  includeResolved: Boolean(flags.all),
217
291
  });
218
292
  out(flags.json, result, () => {
@@ -234,11 +308,15 @@ async function comments(flags, [id]) {
234
308
  });
235
309
  }
236
310
 
237
- async function reply(flags, [id, threadId, ...rest]) {
311
+ async function reply(flags, [ref, threadId, ...rest]) {
238
312
  const body = rest.join(" ");
239
- if (!id || !threadId || !body) fail("usage: bikeshed reply <id> <thread> <text>");
313
+ if (!ref || !threadId || !body) fail("usage: bikeshed reply <id> <thread> <text>");
240
314
  const { origin, token } = resolveAuth(flags);
241
- 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
+ });
242
320
  out(flags.json, result, () => console.log(`replied in ${threadId}`));
243
321
  }
244
322