bikeshed-now 0.3.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 +12 -0
- package/package.json +1 -1
- package/src/client.mjs +40 -9
- package/src/index.mjs +93 -15
package/README.md
CHANGED
|
@@ -34,6 +34,10 @@ 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
|
+
|
|
37
41
|
The address comes from the title. To choose it, pass `--id`:
|
|
38
42
|
|
|
39
43
|
```bash
|
|
@@ -44,6 +48,14 @@ bikeshed publish platform.md --id 01-platform
|
|
|
44
48
|
If the id is taken, the document is still published, at the id plus a few
|
|
45
49
|
random characters, and the CLI says so on stderr.
|
|
46
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
|
+
|
|
47
59
|
`bikeshed --help` lists the rest — listing documents, folders, and installing the
|
|
48
60
|
agent skills a deployment serves.
|
|
49
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,13 +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);
|
|
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 {
|
|
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, [
|
|
183
|
-
if (!
|
|
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
|
|
189
|
-
|
|
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, [
|
|
205
|
-
if (!
|
|
261
|
+
async function get(flags, [ref]) {
|
|
262
|
+
if (!ref) fail("usage: bikeshed get <id>");
|
|
206
263
|
const { origin, token } = resolveAuth(flags);
|
|
207
|
-
const
|
|
208
|
-
|
|
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, [
|
|
212
|
-
if (!
|
|
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
|
-
|
|
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, [
|
|
311
|
+
async function reply(flags, [ref, threadId, ...rest]) {
|
|
238
312
|
const body = rest.join(" ");
|
|
239
|
-
if (!
|
|
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", {
|
|
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
|
|