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.
Files changed (3) hide show
  1. package/README.md +48 -0
  2. package/dist/cli.js +178 -19
  3. 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
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
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 one by id
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] read comments on one of my pages
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('usage: shartifacts publish <file.html> [--title T] [--id ID]', 1);
295
+ throw new CliError(PUBLISH_USAGE, 1);
227
296
  const config = await requireConfig(env);
228
- const html = await readFile(arg, 'utf8').catch(() => {
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
- const body = { html };
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(html, basename(arg));
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
- let query = '';
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
- query = `?since=${t}`;
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.1.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",