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 +22 -0
- package/package.json +1 -1
- package/src/client.mjs +41 -9
- package/src/index.mjs +103 -15
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
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.
|
|
7
|
+
export const CLIENT_NAME = "bikeshed-cli/0.4.0";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
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
|
-
*
|
|
21
|
-
* `X-Bikeshed-Client` and `X-Bikeshed-Agent` headers are
|
|
22
|
-
* recorded as such: they are for a human reading an
|
|
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
|
-
/**
|
|
83
|
-
|
|
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 {
|
|
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, [
|
|
174
|
-
if (!
|
|
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
|
|
180
|
-
|
|
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, [
|
|
196
|
-
if (!
|
|
261
|
+
async function get(flags, [ref]) {
|
|
262
|
+
if (!ref) fail("usage: bikeshed get <id>");
|
|
197
263
|
const { origin, token } = resolveAuth(flags);
|
|
198
|
-
const
|
|
199
|
-
|
|
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, [
|
|
203
|
-
if (!
|
|
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
|
-
|
|
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, [
|
|
311
|
+
async function reply(flags, [ref, threadId, ...rest]) {
|
|
229
312
|
const body = rest.join(" ");
|
|
230
|
-
if (!
|
|
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", {
|
|
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" },
|