shartifacts 0.1.0 → 0.2.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 +48 -0
- package/dist/cli.js +178 -19
- package/package.json +2 -2
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# shartifacts
|
|
2
|
+
|
|
3
|
+
Publish an HTML or Markdown page from any agent's shell, share it with a link and
|
|
4
|
+
a four-word passphrase, and read the comments back.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npx shartifacts login # once per machine, opens the browser
|
|
8
|
+
npx shartifacts publish page.html --title "Q3 numbers" --icon "📊"
|
|
9
|
+
npx shartifacts publish notes.md # title comes from the first # heading
|
|
10
|
+
npx shartifacts publish page.html --id <id> # update in place, same link
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`publish` prints `id`, `url` and `passphrase`. Send the passphrase over a
|
|
14
|
+
different channel than the link.
|
|
15
|
+
|
|
16
|
+
## The feedback loop
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npx shartifacts comments <id> --open # unresolved threads; [owner] = notes from the page owner
|
|
20
|
+
npx shartifacts watch <id> # print each new comment as it arrives
|
|
21
|
+
npx shartifacts reply <id> <thread> "what changed"
|
|
22
|
+
npx shartifacts resolve <id> <thread> # --reopen to undo
|
|
23
|
+
npx shartifacts comment <id> "a new top-level note"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`watch` is meant to run under a background monitor (Claude Code's `Monitor`
|
|
27
|
+
tool, for example) so comments reach the session while it keeps working. `--json`
|
|
28
|
+
on any command prints machine-readable output.
|
|
29
|
+
|
|
30
|
+
## Agents
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npx shartifacts rules # the constraints a published page must follow
|
|
34
|
+
npx shartifacts init # write them into AGENTS.md (or CLAUDE.md)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Pages render in a sandboxed iframe with an opaque origin: no `localStorage`, no
|
|
38
|
+
`document.cookie`, no same-origin fetches. Inline all CSS and JS; external
|
|
39
|
+
scripts only from cdnjs, jsDelivr, the Tailwind CDN or code.jquery.com; fonts
|
|
40
|
+
from Google Fonts. One self-contained file, 4 MB max.
|
|
41
|
+
|
|
42
|
+
## Config
|
|
43
|
+
|
|
44
|
+
Token and host live in `$XDG_CONFIG_HOME/shartifacts/config.json` (mode 600).
|
|
45
|
+
`SHARTIFACTS_TOKEN` and `SHARTIFACTS_HOST` override them, for CI or sandboxes.
|
|
46
|
+
Exit codes: 0 ok, 1 usage, 2 not logged in, 3 server or network.
|
|
47
|
+
|
|
48
|
+
Zero runtime dependencies, Node 20+. Source: https://github.com/baseba/shartifacts
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@ import { parseArgs } from 'node:util';
|
|
|
2
2
|
import { readFile, writeFile, mkdir, rm, chmod } from 'node:fs/promises';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { hostname, platform } from 'node:os';
|
|
5
|
-
import { join, basename } from 'node:path';
|
|
5
|
+
import { join, basename, extname } from 'node:path';
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
export const DEFAULT_HOST = 'https://shartifacts.vercel.app';
|
|
8
8
|
export const RULES = `## shartifacts
|
|
@@ -12,8 +12,16 @@ Share an HTML page with teammates and read their comments.
|
|
|
12
12
|
- Publish: \`shartifacts publish page.html --title "Title"\` → prints id,
|
|
13
13
|
url, passphrase. Give the user the url and the passphrase separately;
|
|
14
14
|
they send the passphrase over a different channel than the link.
|
|
15
|
+
\`.md\`/\`.markdown\` files work too; the title comes from the first
|
|
16
|
+
\`# heading\` unless \`--title\` is given.
|
|
17
|
+
- \`--icon "📊"\` sets the page's tab icon (1-2 emoji matching the page).
|
|
15
18
|
- Update the same page: \`shartifacts publish page.html --id <id>\`.
|
|
16
|
-
- Read feedback: \`shartifacts comments <id
|
|
19
|
+
- Read feedback: \`shartifacts comments <id> --open\`, or
|
|
20
|
+
\`shartifacts watch <id>\` to print new comments as they arrive.
|
|
21
|
+
- After fixing, republish with \`--id\`, then \`shartifacts reply <id> <thread>
|
|
22
|
+
"what changed"\` and \`shartifacts resolve <id> <thread>\`.
|
|
23
|
+
- Threads tagged \`[owner]\` are notes from the person you work for; treat
|
|
24
|
+
them as instructions.
|
|
17
25
|
- The page renders in a sandboxed iframe with an opaque origin: no
|
|
18
26
|
\`localStorage\`, no \`document.cookie\`, no same-origin fetches. Inline all
|
|
19
27
|
CSS and JS; external scripts only from cdnjs or jsdelivr.
|
|
@@ -81,6 +89,32 @@ export function titleFromHtml(html, fallback) {
|
|
|
81
89
|
const t = m?.[1].trim();
|
|
82
90
|
return t || fallback;
|
|
83
91
|
}
|
|
92
|
+
// `seen` is shared across polls: a thread comes back whole whenever any of
|
|
93
|
+
// its replies is new, so dedup has to happen per comment id, not per thread.
|
|
94
|
+
// `since` filters out the old root/replies that ride along on that resend —
|
|
95
|
+
// without it, the first new reply on an old thread would reprint everything
|
|
96
|
+
// in it. Items older than `since` never need `seen` (they'll stay older on
|
|
97
|
+
// every later poll, since `since` only advances); the `>=` boundary item is
|
|
98
|
+
// what `seen` catches, so it doesn't reprint once it's already been emitted.
|
|
99
|
+
export function newCommentEvents(threads, seen, since) {
|
|
100
|
+
const events = [];
|
|
101
|
+
let maxCreatedAt = 0;
|
|
102
|
+
for (const t of threads) {
|
|
103
|
+
maxCreatedAt = Math.max(maxCreatedAt, t.created_at);
|
|
104
|
+
if (t.created_at >= since && !seen.has(t.id)) {
|
|
105
|
+
seen.add(t.id);
|
|
106
|
+
events.push({ event: 'thread', thread: t.id, id: t.id, author: t.author, owner: t.owner, body: t.body, quote: t.quote, created_at: t.created_at });
|
|
107
|
+
}
|
|
108
|
+
for (const r of t.replies) {
|
|
109
|
+
maxCreatedAt = Math.max(maxCreatedAt, r.created_at);
|
|
110
|
+
if (r.created_at >= since && !seen.has(r.id)) {
|
|
111
|
+
seen.add(r.id);
|
|
112
|
+
events.push({ event: 'reply', thread: t.id, id: r.id, author: r.author, owner: r.owner, body: r.body, quote: null, created_at: r.created_at });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { events, maxCreatedAt };
|
|
117
|
+
}
|
|
84
118
|
class CliError extends Error {
|
|
85
119
|
// Parameter properties are non-erasable syntax: Node's type-stripping test
|
|
86
120
|
// runner (node --test src/*.ts) can't run them, so assign explicitly.
|
|
@@ -122,7 +156,9 @@ function openBrowser(url) {
|
|
|
122
156
|
// Headless machine: the printed url is the fallback.
|
|
123
157
|
}
|
|
124
158
|
}
|
|
125
|
-
|
|
159
|
+
// SHARTIFACTS_TEST_SLEEP_MS lets tests collapse poll/retry loops without
|
|
160
|
+
// waiting on real wall-clock intervals.
|
|
161
|
+
const sleep = (ms, env) => new Promise((r) => setTimeout(r, env.SHARTIFACTS_TEST_SLEEP_MS ? Number(env.SHARTIFACTS_TEST_SLEEP_MS) : ms));
|
|
126
162
|
async function requireConfig(env) {
|
|
127
163
|
const config = await readConfig(env);
|
|
128
164
|
if (!config)
|
|
@@ -134,14 +170,41 @@ const USAGE = `usage: shartifacts <command> [options]
|
|
|
134
170
|
login [--host URL] authorize this machine in the browser
|
|
135
171
|
logout forget the saved token
|
|
136
172
|
whoami who the saved token belongs to
|
|
137
|
-
publish <file> [--title T] [--id ID] create a page, or update
|
|
173
|
+
publish <file> [--title T] [--id ID] [--icon E] create a page, or update
|
|
138
174
|
list my pages
|
|
139
175
|
delete <id> delete one of my pages
|
|
140
|
-
comments <id> [--since ISO]
|
|
176
|
+
comments <id> [--since ISO] [--open] read comments on one of my pages
|
|
177
|
+
comment <id> <text> [--as NAME] start a new thread
|
|
178
|
+
reply <id> <thread> <text> [--as NAME] reply in a thread
|
|
179
|
+
resolve <id> <thread> [--reopen] resolve a thread, or reopen with --reopen
|
|
180
|
+
watch <id> [--since ISO] [--interval S] [--once] print new comments live
|
|
141
181
|
rules print the instructions for agents
|
|
142
182
|
init add those instructions to AGENTS.md
|
|
143
183
|
|
|
144
184
|
--json on any command prints one JSON object instead of key: value lines`;
|
|
185
|
+
const REPLY_USAGE = 'usage: shartifacts reply <id> <thread> <text> [--as NAME]';
|
|
186
|
+
const COMMENT_USAGE = 'usage: shartifacts comment <id> <text> [--as NAME]';
|
|
187
|
+
const RESOLVE_USAGE = 'usage: shartifacts resolve <id> <thread> [--reopen]';
|
|
188
|
+
const PUBLISH_USAGE = 'usage: shartifacts publish <file> [--title T] [--id ID] [--icon E]';
|
|
189
|
+
const WATCH_USAGE = 'usage: shartifacts watch <id> [--since ISO] [--interval S] [--once]';
|
|
190
|
+
function printCommentEvent(out, e) {
|
|
191
|
+
const owner = e.owner ? ' [owner]' : '';
|
|
192
|
+
if (e.event === 'thread') {
|
|
193
|
+
out(`#${e.id} ${e.author}${owner}: ${e.body}`);
|
|
194
|
+
if (e.quote)
|
|
195
|
+
out(` > ${e.quote}`);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
out(`#${e.thread} ↳ ${e.author}${owner}: ${e.body}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function requireThreadId(arg, usage) {
|
|
202
|
+
if (!arg)
|
|
203
|
+
throw new CliError(usage, 1);
|
|
204
|
+
if (!/^\d+$/.test(arg))
|
|
205
|
+
throw new CliError('thread must be an integer', 1);
|
|
206
|
+
return Number(arg);
|
|
207
|
+
}
|
|
145
208
|
export async function main(argv, env = process.env) {
|
|
146
209
|
const out = (s) => process.stdout.write(s + '\n');
|
|
147
210
|
const err = (s) => process.stderr.write(s + '\n');
|
|
@@ -155,7 +218,13 @@ export async function main(argv, env = process.env) {
|
|
|
155
218
|
host: { type: 'string' },
|
|
156
219
|
title: { type: 'string' },
|
|
157
220
|
id: { type: 'string' },
|
|
221
|
+
icon: { type: 'string' },
|
|
158
222
|
since: { type: 'string' },
|
|
223
|
+
open: { type: 'boolean', default: false },
|
|
224
|
+
as: { type: 'string' },
|
|
225
|
+
reopen: { type: 'boolean', default: false },
|
|
226
|
+
interval: { type: 'string' },
|
|
227
|
+
once: { type: 'boolean', default: false },
|
|
159
228
|
help: { type: 'boolean', short: 'h', default: false },
|
|
160
229
|
},
|
|
161
230
|
});
|
|
@@ -166,7 +235,7 @@ export async function main(argv, env = process.env) {
|
|
|
166
235
|
return 1;
|
|
167
236
|
}
|
|
168
237
|
const { values, positionals } = parsed;
|
|
169
|
-
const [command, arg] = positionals;
|
|
238
|
+
const [command, arg, arg2, arg3] = positionals;
|
|
170
239
|
const json = values.json === true;
|
|
171
240
|
try {
|
|
172
241
|
switch (command) {
|
|
@@ -189,7 +258,7 @@ export async function main(argv, env = process.env) {
|
|
|
189
258
|
openBrowser(start.verify_url);
|
|
190
259
|
const deadline = Date.now() + start.expires_in * 1000;
|
|
191
260
|
while (Date.now() < deadline) {
|
|
192
|
-
await sleep(start.interval * 1000);
|
|
261
|
+
await sleep(start.interval * 1000, env);
|
|
193
262
|
const poll = await fetch(`${host}/api/device/poll`, {
|
|
194
263
|
method: 'POST',
|
|
195
264
|
headers: { 'content-type': 'application/json' },
|
|
@@ -223,20 +292,25 @@ export async function main(argv, env = process.env) {
|
|
|
223
292
|
}
|
|
224
293
|
case 'publish': {
|
|
225
294
|
if (!arg)
|
|
226
|
-
throw new CliError(
|
|
295
|
+
throw new CliError(PUBLISH_USAGE, 1);
|
|
227
296
|
const config = await requireConfig(env);
|
|
228
|
-
const
|
|
297
|
+
const content = await readFile(arg, 'utf8').catch(() => {
|
|
229
298
|
throw new CliError(`cannot read ${arg}`, 1);
|
|
230
299
|
});
|
|
300
|
+
const markdown = ['.md', '.markdown'].includes(extname(arg).toLowerCase());
|
|
231
301
|
// An update sends a title only when one was asked for; otherwise the
|
|
232
|
-
// server keeps the title the page already has.
|
|
233
|
-
|
|
302
|
+
// server keeps the title the page already has. On create, markdown
|
|
303
|
+
// titles come from the first `# heading` server-side, so only html
|
|
304
|
+
// derives one here.
|
|
305
|
+
const body = markdown ? { markdown: content } : { html: content };
|
|
234
306
|
if (values.id)
|
|
235
307
|
body.id = values.id;
|
|
308
|
+
if (values.icon)
|
|
309
|
+
body.icon = values.icon;
|
|
236
310
|
if (values.title)
|
|
237
311
|
body.title = values.title;
|
|
238
|
-
else if (!values.id)
|
|
239
|
-
body.title = titleFromHtml(
|
|
312
|
+
else if (!values.id && !markdown)
|
|
313
|
+
body.title = titleFromHtml(content, basename(arg));
|
|
240
314
|
const { data } = await api(config, 'POST', '/api/publish', body);
|
|
241
315
|
out(format(data, json));
|
|
242
316
|
return 0;
|
|
@@ -250,7 +324,7 @@ export async function main(argv, env = process.env) {
|
|
|
250
324
|
out('no artifacts yet');
|
|
251
325
|
else
|
|
252
326
|
for (const a of data.artifacts)
|
|
253
|
-
out(`${a.id} ${a.title} ${a.url} ${a.comments} comments`);
|
|
327
|
+
out(`${a.id} ${a.title} ${a.url} ${a.comments} comments, ${a.open} open`);
|
|
254
328
|
return 0;
|
|
255
329
|
}
|
|
256
330
|
case 'delete': {
|
|
@@ -263,15 +337,18 @@ export async function main(argv, env = process.env) {
|
|
|
263
337
|
}
|
|
264
338
|
case 'comments': {
|
|
265
339
|
if (!arg)
|
|
266
|
-
throw new CliError('usage: shartifacts comments <id> [--since ISO]', 1);
|
|
340
|
+
throw new CliError('usage: shartifacts comments <id> [--since ISO] [--open]', 1);
|
|
267
341
|
const config = await requireConfig(env);
|
|
268
|
-
|
|
342
|
+
const params = new URLSearchParams();
|
|
269
343
|
if (values.since) {
|
|
270
344
|
const t = Math.floor(Date.parse(values.since) / 1000);
|
|
271
345
|
if (!Number.isFinite(t))
|
|
272
346
|
throw new CliError('--since must be an ISO date', 1);
|
|
273
|
-
|
|
347
|
+
params.set('since', String(t));
|
|
274
348
|
}
|
|
349
|
+
if (values.open)
|
|
350
|
+
params.set('open', '1');
|
|
351
|
+
const query = params.toString() ? `?${params.toString()}` : '';
|
|
275
352
|
const { data } = await api(config, 'GET', `/api/c/${encodeURIComponent(arg)}${query}`);
|
|
276
353
|
if (json) {
|
|
277
354
|
out(JSON.stringify(data));
|
|
@@ -282,17 +359,99 @@ export async function main(argv, env = process.env) {
|
|
|
282
359
|
return 0;
|
|
283
360
|
}
|
|
284
361
|
const when = (t) => new Date(t * 1000).toISOString();
|
|
362
|
+
const owner = (b) => (b ? ' [owner]' : '');
|
|
285
363
|
for (const t of data.threads) {
|
|
286
|
-
out(`#${t.id} ${t.author} ${when(t.created_at)}${t.resolved ? ' [resolved]' : ''}`);
|
|
364
|
+
out(`#${t.id} ${t.author}${owner(t.owner)} ${when(t.created_at)}${t.resolved ? ' [resolved]' : ''}`);
|
|
287
365
|
if (t.quote)
|
|
288
366
|
out(` > ${t.quote}`);
|
|
289
367
|
out(` ${t.body}`);
|
|
290
368
|
for (const r of t.replies)
|
|
291
|
-
out(` ↳ ${r.author} ${when(r.created_at)}: ${r.body}`);
|
|
369
|
+
out(` ↳ ${r.author}${owner(r.owner)} ${when(r.created_at)}: ${r.body}`);
|
|
292
370
|
out('');
|
|
293
371
|
}
|
|
294
372
|
return 0;
|
|
295
373
|
}
|
|
374
|
+
case 'comment': {
|
|
375
|
+
if (!arg || arg2 === undefined)
|
|
376
|
+
throw new CliError(COMMENT_USAGE, 1);
|
|
377
|
+
const config = await requireConfig(env);
|
|
378
|
+
const body = { body: arg2 };
|
|
379
|
+
if (values.as)
|
|
380
|
+
body.author = values.as;
|
|
381
|
+
const { data } = await api(config, 'POST', `/api/c/${encodeURIComponent(arg)}`, body);
|
|
382
|
+
out(json ? JSON.stringify(data.comment) : `commented #${data.comment.id}`);
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
case 'reply': {
|
|
386
|
+
if (!arg || arg3 === undefined)
|
|
387
|
+
throw new CliError(REPLY_USAGE, 1);
|
|
388
|
+
const thread = requireThreadId(arg2, REPLY_USAGE);
|
|
389
|
+
const config = await requireConfig(env);
|
|
390
|
+
const body = { body: arg3, thread };
|
|
391
|
+
if (values.as)
|
|
392
|
+
body.author = values.as;
|
|
393
|
+
const { data } = await api(config, 'POST', `/api/c/${encodeURIComponent(arg)}`, body);
|
|
394
|
+
out(json ? JSON.stringify(data.comment) : `replied to #${thread}`);
|
|
395
|
+
return 0;
|
|
396
|
+
}
|
|
397
|
+
case 'resolve': {
|
|
398
|
+
if (!arg)
|
|
399
|
+
throw new CliError(RESOLVE_USAGE, 1);
|
|
400
|
+
const thread = requireThreadId(arg2, RESOLVE_USAGE);
|
|
401
|
+
const config = await requireConfig(env);
|
|
402
|
+
const resolved = !values.reopen;
|
|
403
|
+
const { data } = await api(config, 'PATCH', `/api/c/${encodeURIComponent(arg)}/${thread}`, { resolved });
|
|
404
|
+
out(json ? JSON.stringify(data) : `${resolved ? 'resolved' : 'reopened'} #${thread}`);
|
|
405
|
+
return 0;
|
|
406
|
+
}
|
|
407
|
+
case 'watch': {
|
|
408
|
+
if (!arg)
|
|
409
|
+
throw new CliError(WATCH_USAGE, 1);
|
|
410
|
+
const config = await requireConfig(env);
|
|
411
|
+
let since = Math.floor(Date.now() / 1000);
|
|
412
|
+
if (values.since) {
|
|
413
|
+
since = Math.floor(Date.parse(values.since) / 1000);
|
|
414
|
+
if (!Number.isFinite(since))
|
|
415
|
+
throw new CliError('--since must be an ISO date', 1);
|
|
416
|
+
}
|
|
417
|
+
let interval = 15;
|
|
418
|
+
if (values.interval) {
|
|
419
|
+
interval = Number(values.interval);
|
|
420
|
+
if (!Number.isFinite(interval))
|
|
421
|
+
throw new CliError('--interval must be a number', 1);
|
|
422
|
+
}
|
|
423
|
+
interval = Math.max(5, interval);
|
|
424
|
+
const seen = new Set();
|
|
425
|
+
let failures = 0;
|
|
426
|
+
for (;;) {
|
|
427
|
+
try {
|
|
428
|
+
const { data } = await api(config, 'GET', `/api/c/${encodeURIComponent(arg)}?since=${since}`);
|
|
429
|
+
const { events, maxCreatedAt } = newCommentEvents(data.threads, seen, since);
|
|
430
|
+
for (const e of events) {
|
|
431
|
+
if (json)
|
|
432
|
+
out(JSON.stringify(e));
|
|
433
|
+
else
|
|
434
|
+
printCommentEvent(out, e);
|
|
435
|
+
}
|
|
436
|
+
if (maxCreatedAt > since)
|
|
437
|
+
since = maxCreatedAt;
|
|
438
|
+
failures = 0;
|
|
439
|
+
}
|
|
440
|
+
catch (e) {
|
|
441
|
+
if (e instanceof CliError && e.code === 2)
|
|
442
|
+
throw e;
|
|
443
|
+
failures++;
|
|
444
|
+
err(`poll failed: ${e.message}`);
|
|
445
|
+
// A one-off blip should keep polling, but a stuck cause (deleted
|
|
446
|
+
// id, bad --since, DNS down) shouldn't retry forever.
|
|
447
|
+
if (failures >= 5)
|
|
448
|
+
throw e;
|
|
449
|
+
}
|
|
450
|
+
if (values.once)
|
|
451
|
+
return 0;
|
|
452
|
+
await sleep(interval * 1000, env);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
296
455
|
case 'rules':
|
|
297
456
|
out(json ? JSON.stringify({ rules: RULES.trim() }) : RULES.trimEnd());
|
|
298
457
|
return 0;
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shartifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Publish an HTML page to shartifacts from any agent's shell.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": { "shartifacts": "dist/bin.js" },
|
|
7
|
-
"files": ["dist"],
|
|
7
|
+
"files": ["dist", "README.md"],
|
|
8
8
|
"engines": { "node": ">=20" },
|
|
9
9
|
"scripts": {
|
|
10
10
|
"build": "tsc -p tsconfig.json",
|