artyfax 0.2.3 → 0.3.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 +69 -36
  2. package/dist/cli.js +1485 -205
  3. package/package.json +3 -2
package/dist/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { readFileSync as readFileSync2 } from "fs";
5
- import { resolve as resolve2 } from "path";
4
+ import { readFileSync as readFileSync5, realpathSync } from "fs";
5
+ import { resolve as resolve5 } from "path";
6
+ import { fileURLToPath } from "url";
6
7
 
7
8
  // src/config.ts
8
9
  function getConfig(flags) {
@@ -11,28 +12,58 @@ function getConfig(flags) {
11
12
  console.error("No API key. Set ARTYFAX_API_KEY or pass --api-key.");
12
13
  process.exit(1);
13
14
  }
15
+ const endpoint = flags.endpoint || process.env.ARTYFAX_ENDPOINT || "https://artyfax.io";
16
+ let parsed;
17
+ try {
18
+ parsed = new URL(endpoint);
19
+ } catch {
20
+ console.error(`Invalid endpoint: "${endpoint}". Must be an absolute http(s) URL.`);
21
+ process.exit(1);
22
+ }
23
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
24
+ console.error(`Invalid endpoint scheme: "${parsed.protocol}". Use http:// or https://.`);
25
+ process.exit(1);
26
+ }
27
+ const LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "0.0.0.0"]);
28
+ const isLocal = LOCAL_HOSTS.has(parsed.hostname);
29
+ if (parsed.protocol === "http:" && !isLocal) {
30
+ console.error(`Warning: sending your API key over plain HTTP to ${parsed.host}. Use https:// unless this is a trusted local endpoint.`);
31
+ }
14
32
  return {
15
33
  apiKey,
16
- endpoint: flags.endpoint || process.env.ARTYFAX_ENDPOINT || "https://artyfax.io",
34
+ endpoint,
17
35
  passphrase: process.env.ARTYFAX_SECURE_PASSPHRASE || null
18
36
  };
19
37
  }
20
- async function apiFetch(config, path, init) {
38
+ async function apiFetch(config, path, init2) {
39
+ const isFormData = typeof FormData !== "undefined" && init2?.body instanceof FormData;
21
40
  const res = await fetch(`${config.endpoint}/api${path}`, {
22
- ...init,
41
+ ...init2,
23
42
  headers: {
24
- "Content-Type": "application/json",
43
+ ...isFormData ? {} : { "Content-Type": "application/json" },
25
44
  "X-API-Key": config.apiKey,
26
45
  // design-34: identify the CLI on the server side so saved_via can
27
46
  // bucket CLI saves correctly. Format mirrors common CLI tools so a
28
47
  // server-side regex can stay simple.
29
48
  "User-Agent": `artyfax-cli/${VERSION}`,
30
- ...init?.headers
49
+ ...init2?.headers
31
50
  }
32
51
  });
33
52
  if (!res.ok) {
34
- const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
35
- throw new Error(body.error ?? `HTTP ${res.status}`);
53
+ const raw = await res.text().catch(() => "");
54
+ let message = `HTTP ${res.status}`;
55
+ if (raw) {
56
+ try {
57
+ const parsed = JSON.parse(raw);
58
+ if (parsed.error) message = parsed.error;
59
+ } catch {
60
+ const snippet = raw.replace(/\s+/g, " ").trim().slice(0, 200);
61
+ if (snippet) message = `HTTP ${res.status}: ${snippet}`;
62
+ }
63
+ }
64
+ const err = new Error(message);
65
+ err.status = res.status;
66
+ throw err;
36
67
  }
37
68
  return res.json();
38
69
  }
@@ -102,7 +133,7 @@ function bufferToBase64(buffer) {
102
133
 
103
134
  // src/passphrase.ts
104
135
  async function promptPassphrase(prompt = "Passphrase: ") {
105
- return new Promise((resolve3, reject) => {
136
+ return new Promise((resolve6, reject) => {
106
137
  const rl = createInterface({ input: process.stdin, output: process.stderr });
107
138
  process.stderr.write(prompt);
108
139
  const cleanup = () => {
@@ -120,7 +151,7 @@ async function promptPassphrase(prompt = "Passphrase: ") {
120
151
  if (c2 === "\n" || c2 === "\r") {
121
152
  process.stderr.write("\n");
122
153
  cleanup();
123
- resolve3(input);
154
+ resolve6(input);
124
155
  } else if (c2 === "" || c2.length === 0) {
125
156
  process.stderr.write("\n");
126
157
  cleanup();
@@ -204,11 +235,11 @@ function stripAnsi(s) {
204
235
  return s.replace(/\x1b\[[0-9;]*m/g, "");
205
236
  }
206
237
  function confirm(message) {
207
- return new Promise((resolve3) => {
238
+ return new Promise((resolve6) => {
208
239
  const rl = createInterface2({ input: process.stdin, output: process.stderr });
209
240
  rl.question(`${message} ${c.muted("[y/N]")} `, (answer) => {
210
241
  rl.close();
211
- resolve3(answer.trim().toLowerCase() === "y");
242
+ resolve6(answer.trim().toLowerCase() === "y");
212
243
  });
213
244
  });
214
245
  }
@@ -282,8 +313,9 @@ ${c.muted("Use --force to save a new copy.")}`
282
313
  // src/resolve.ts
283
314
  async function resolveSlug(config, slugOrId) {
284
315
  if (slugOrId.includes("/")) {
316
+ const encodedSlug = slugOrId.split("/").map(encodeURIComponent).join("/");
285
317
  try {
286
- const doc = await apiFetch(config, `/documents/by-slug/${slugOrId}`);
318
+ const doc = await apiFetch(config, `/documents/by-slug/${encodedSlug}`);
287
319
  return doc;
288
320
  } catch {
289
321
  }
@@ -295,6 +327,14 @@ async function resolveSlug(config, slugOrId) {
295
327
  } catch {
296
328
  }
297
329
  }
330
+ if (/^[A-Z]+-\d+$/.test(slugOrId)) {
331
+ try {
332
+ const { id } = await apiFetch(config, `/resolve/${encodeURIComponent(slugOrId)}`);
333
+ const doc = await apiFetch(config, `/documents/${id}`);
334
+ return doc;
335
+ } catch {
336
+ }
337
+ }
298
338
  const docs = await apiFetch(
299
339
  config,
300
340
  `/documents?limit=500`
@@ -313,7 +353,7 @@ async function resolveSlug(config, slugOrId) {
313
353
  ` + partial.slice(0, 5).map((d) => ` ${d.slug}`).join("\n")
314
354
  );
315
355
  }
316
- throw new Error(`Document not found: ${slugOrId}. Try \`arty search ${slugOrId}\` or \`arty list\``);
356
+ throw new Error(`Document not found: ${slugOrId}. Try \`artyfax search ${slugOrId}\` or \`artyfax list\``);
317
357
  }
318
358
 
319
359
  // src/commands/read.ts
@@ -396,6 +436,47 @@ async function unsecure(config, slugOrId) {
396
436
  spin.succeed(`${c.teal("Decrypted:")} ${doc.slug}`);
397
437
  }
398
438
 
439
+ // src/commands/workspace.ts
440
+ async function resolveWorkspaceId(config, raw) {
441
+ const [id] = await resolveWorkspaceIds(config, [raw]);
442
+ return id;
443
+ }
444
+ async function resolveWorkspaceIds(config, inputs) {
445
+ if (inputs.length === 0) return [];
446
+ const data = await apiFetch(config, "/workspaces");
447
+ return inputs.map((raw) => {
448
+ const q = raw.trim().toLowerCase();
449
+ const match = data.workspaces.find(
450
+ (w) => w.id === raw || w.slug.toLowerCase() === q || w.name.toLowerCase() === q
451
+ );
452
+ if (!match) {
453
+ const names = data.workspaces.map((w) => w.name).join(", ") || "none";
454
+ throw new Error(`Workspace not found: ${raw}. Available: ${names}`);
455
+ }
456
+ return match.id;
457
+ });
458
+ }
459
+ async function workspaceList(config, json) {
460
+ const spin = spinner("Loading workspaces\u2026");
461
+ spin.start();
462
+ const data = await apiFetch(config, "/workspaces");
463
+ spin.stop();
464
+ if (json) {
465
+ console.log(JSON.stringify(data.workspaces, null, 2));
466
+ return;
467
+ }
468
+ if (data.workspaces.length === 0) {
469
+ console.log(c.muted("No workspaces."));
470
+ return;
471
+ }
472
+ const rows = data.workspaces.map((w) => [
473
+ w.name,
474
+ c.muted(w.slug),
475
+ c.muted(w.id)
476
+ ]);
477
+ console.log(table(rows, { header: ["Name", c.muted("Slug"), c.muted("ID")] }));
478
+ }
479
+
399
480
  // src/commands/list.ts
400
481
  async function list(config, opts) {
401
482
  const spin = spinner("Loading documents\u2026");
@@ -405,6 +486,8 @@ async function list(config, opts) {
405
486
  if (opts.offset) path += `&offset=${opts.offset}`;
406
487
  if (opts.archived) path += `&archived=1`;
407
488
  if (opts.parentId) path += `&parent_id=${encodeURIComponent(opts.parentId)}`;
489
+ if (opts.tag) path += `&tag=${encodeURIComponent(opts.tag)}`;
490
+ if (opts.workspace) path += `&workspace=${encodeURIComponent(await resolveWorkspaceId(config, opts.workspace))}`;
408
491
  const data = await apiFetch(config, path);
409
492
  spin.stop();
410
493
  if (opts.json) {
@@ -431,13 +514,12 @@ async function list(config, opts) {
431
514
  }
432
515
 
433
516
  // src/commands/search.ts
434
- async function search(config, query, json) {
517
+ async function search(config, query, json, workspace) {
435
518
  const spin = spinner(`Searching for "${query}"\u2026`);
436
519
  spin.start();
437
- const data = await apiFetch(
438
- config,
439
- `/search?q=${encodeURIComponent(query)}`
440
- );
520
+ let path = `/search?q=${encodeURIComponent(query)}`;
521
+ if (workspace) path += `&workspace=${encodeURIComponent(await resolveWorkspaceId(config, workspace))}`;
522
+ const data = await apiFetch(config, path);
441
523
  spin.stop();
442
524
  if (json) {
443
525
  console.log(JSON.stringify(data.results));
@@ -448,7 +530,7 @@ async function search(config, query, json) {
448
530
  return;
449
531
  }
450
532
  const rows = data.results.map((r) => [
451
- r.title,
533
+ r.title + (r.visible_via === "project" ? c.muted(" (via project)") : ""),
452
534
  c.muted(r.slug),
453
535
  c.muted(r.updated_at.slice(0, 10))
454
536
  ]);
@@ -563,6 +645,7 @@ async function metadata(config, slugOrId, opts) {
563
645
  const doc = await resolveSlug(config, slugOrId);
564
646
  const patch = {};
565
647
  if (opts.category !== void 0) patch.category = opts.category;
648
+ if (opts.slug !== void 0) patch.slug = opts.slug;
566
649
  if (opts.title !== void 0) patch.title = opts.title;
567
650
  if (opts.theme !== void 0) patch.theme = opts.theme;
568
651
  if (opts.visibility !== void 0) patch.visibility = opts.visibility;
@@ -570,7 +653,7 @@ async function metadata(config, slugOrId, opts) {
570
653
  if (opts.tags !== void 0) patch.tags = opts.tags.split(",").map((t) => t.trim());
571
654
  if (Object.keys(patch).length === 0) {
572
655
  spin.stop();
573
- throw new Error("No metadata flags provided. Use --category, --tags, --title, --theme, --visibility, or --archived");
656
+ throw new Error("No metadata flags provided. Use --category, --slug, --tags, --title, --theme, --visibility, or --archived");
574
657
  }
575
658
  spin.text = "Updating metadata\u2026";
576
659
  await apiFetch(config, `/documents/${doc.id}/metadata`, {
@@ -586,6 +669,104 @@ async function metadata(config, slugOrId, opts) {
586
669
  }
587
670
  }
588
671
 
672
+ // src/commands/image.ts
673
+ import { readFileSync as readFileSync2, statSync } from "fs";
674
+ import { extname, basename, resolve as resolve2 } from "path";
675
+ var MIME_BY_EXT = {
676
+ ".png": "image/png",
677
+ ".jpg": "image/jpeg",
678
+ ".jpeg": "image/jpeg",
679
+ ".webp": "image/webp",
680
+ ".gif": "image/gif",
681
+ ".avif": "image/avif",
682
+ ".svg": "image/svg+xml"
683
+ };
684
+ var MAX_BYTES = 5 * 1024 * 1024;
685
+ function formatBytes(bytes) {
686
+ if (bytes < 1024) return `${bytes} B`;
687
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
688
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
689
+ }
690
+ async function resolveDocArg(config, arg) {
691
+ const looksLikeBareId = !arg.includes("/") && !/^[A-Z]+-\d+$/.test(arg);
692
+ if (looksLikeBareId) {
693
+ try {
694
+ return await apiFetch(config, `/documents/${encodeURIComponent(arg)}`);
695
+ } catch (e) {
696
+ if (e.status !== 404) throw e;
697
+ }
698
+ }
699
+ return resolveSlug(config, arg);
700
+ }
701
+ function readImageFile(file) {
702
+ const path = resolve2(file);
703
+ let stat;
704
+ try {
705
+ stat = statSync(path);
706
+ } catch {
707
+ throw new Error(`Image not found: ${path}`);
708
+ }
709
+ if (!stat.isFile()) throw new Error(`Not a file: ${path}`);
710
+ const ext = extname(path).toLowerCase();
711
+ const mime = MIME_BY_EXT[ext];
712
+ if (!mime) {
713
+ throw new Error(`Unsupported image extension: ${ext}. Allowed: ${Object.keys(MIME_BY_EXT).join(", ")}`);
714
+ }
715
+ if (stat.size > MAX_BYTES) {
716
+ throw new Error(`Image exceeds 5 MB limit (${formatBytes(stat.size)})`);
717
+ }
718
+ return { bytes: readFileSync2(path), name: basename(path), mime };
719
+ }
720
+ async function uploadImage(config, docId, file, kind) {
721
+ const form = new FormData();
722
+ form.set("file", new File([file.bytes], file.name, { type: file.mime }));
723
+ const query = kind === "cover" ? "?kind=cover" : "";
724
+ return apiFetch(config, `/documents/${docId}/images${query}`, {
725
+ method: "POST",
726
+ body: form
727
+ });
728
+ }
729
+ async function setCover(config, slugOrId, file, opts) {
730
+ const image = readImageFile(file);
731
+ const spin = spinner("Resolving document\u2026");
732
+ spin.start();
733
+ const doc = await resolveDocArg(config, slugOrId);
734
+ spin.text = `Uploading ${image.name}\u2026`;
735
+ const uploaded = await uploadImage(config, doc.id, image, "cover");
736
+ spin.text = "Setting cover\u2026";
737
+ const patch = { cover_image: uploaded.path };
738
+ if (typeof opts.alt === "string") patch.cover_alt = opts.alt;
739
+ await apiFetch(config, `/documents/${doc.id}/metadata`, {
740
+ method: "PATCH",
741
+ body: JSON.stringify(patch)
742
+ });
743
+ spin.stop();
744
+ if (opts.json) {
745
+ console.log(JSON.stringify({ ok: true, id: doc.id, slug: doc.slug, cover_image: uploaded.path }));
746
+ } else {
747
+ console.log(`${c.teal("Cover set")} on ${c.bright(doc.slug)} ${c.muted(`(${formatBytes(image.bytes.length)})`)}`);
748
+ console.log(c.muted(" Tip: covers render best at 1200\xD7630 (hero + social card shape)."));
749
+ }
750
+ }
751
+ async function addImage(config, slugOrId, file, opts) {
752
+ const image = readImageFile(file);
753
+ const spin = spinner("Resolving document\u2026");
754
+ spin.start();
755
+ const doc = await resolveDocArg(config, slugOrId);
756
+ spin.text = `Uploading ${image.name}\u2026`;
757
+ const uploaded = await uploadImage(config, doc.id, image, "content");
758
+ spin.stop();
759
+ const alt = typeof opts.alt === "string" ? opts.alt : "";
760
+ const snippet = `![${alt}](${uploaded.path})`;
761
+ if (opts.json) {
762
+ console.log(JSON.stringify({ ok: true, id: doc.id, slug: doc.slug, path: uploaded.path, markdown: snippet }));
763
+ } else {
764
+ console.log(`${c.teal("Uploaded")} to ${c.bright(doc.slug)} ${c.muted(`(${formatBytes(image.bytes.length)})`)}`);
765
+ console.log(c.muted(" Paste this into the document body:"));
766
+ console.log(` ${snippet}`);
767
+ }
768
+ }
769
+
589
770
  // src/commands/share.ts
590
771
  async function shareCreate(config, slugOrId, json) {
591
772
  const spin = spinner("Resolving document\u2026");
@@ -690,8 +871,42 @@ async function tagList(config, json) {
690
871
  console.log(c.muted("No tags found."));
691
872
  return;
692
873
  }
693
- const rows = data.tags.map((t) => [t.tag, String(t.count)]);
694
- console.log(table(rows, { header: ["Tag", "Count"] }));
874
+ const namespaces = data.namespaces ?? [];
875
+ const simple = data.simple ?? [];
876
+ for (const ns of namespaces) {
877
+ const heading = ns.intent ? `${c.bright(ns.prefix + ":")} ${c.muted(ns.intent)}` : c.bright(ns.prefix + ":");
878
+ console.log("\n" + heading);
879
+ const rows = ns.tags.map((t) => [t.tag, String(t.count)]);
880
+ console.log(table(rows, { header: ["Tag", "Count"] }));
881
+ }
882
+ if (simple.length > 0) {
883
+ console.log("\n" + c.bright("(no namespace)"));
884
+ const rows = simple.map((t) => [t.tag, String(t.count)]);
885
+ console.log(table(rows, { header: ["Tag", "Count"] }));
886
+ }
887
+ }
888
+ async function tagDelete(config, tag, json) {
889
+ const spin = spinner(`Deleting tag \u201C${tag}\u201D\u2026`);
890
+ spin.start();
891
+ const data = await apiFetch(
892
+ config,
893
+ "/tags/delete",
894
+ { method: "POST", body: JSON.stringify({ tag }) }
895
+ );
896
+ spin.stop();
897
+ if (json) {
898
+ console.log(JSON.stringify(data));
899
+ return;
900
+ }
901
+ const { documents, annotations, snippets } = data.updated;
902
+ const total = documents + annotations + snippets;
903
+ if (total === 0) {
904
+ console.log(c.muted(`No items carried \u201C${tag}\u201D.`));
905
+ return;
906
+ }
907
+ console.log(
908
+ c.teal(`Removed \u201C${tag}\u201D from ${total} item(s)`) + c.muted(` (${documents} docs, ${annotations} annotations, ${snippets} snippets)`)
909
+ );
695
910
  }
696
911
 
697
912
  // src/commands/version.ts
@@ -764,100 +979,337 @@ async function versionRestore(config, slugOrId, yes, json) {
764
979
  }
765
980
  }
766
981
 
767
- // src/commands/note.ts
768
- async function noteList(config, slugOrId, json) {
982
+ // src/commands/annotation.ts
983
+ async function annotationList(config, slugOrId, json, opts = {}) {
769
984
  const spin = spinner("Resolving document\u2026");
770
985
  spin.start();
771
986
  const doc = await resolveSlug(config, slugOrId);
772
987
  spin.text = "Loading annotations\u2026";
773
- const data = await apiFetch(
774
- config,
775
- `/annotations/doc/${doc.id}`
776
- );
988
+ const qs = new URLSearchParams();
989
+ qs.set("include_recipients", opts.includeRecipients ? "true" : "false");
990
+ if (opts.since) qs.set("since", opts.since);
991
+ const path = `/annotations/doc/${doc.id}${qs.toString() ? `?${qs.toString()}` : ""}`;
992
+ const data = await apiFetch(config, path);
777
993
  spin.stop();
994
+ const annotations = Array.isArray(data) ? data : data.annotations ?? [];
778
995
  if (json) {
779
- console.log(JSON.stringify(data.annotations));
996
+ console.log(JSON.stringify(annotations, null, 2));
780
997
  return;
781
998
  }
782
- if (data.annotations.length === 0) {
999
+ if (annotations.length === 0) {
783
1000
  console.log(c.muted("No annotations found."));
784
1001
  return;
785
1002
  }
786
- console.log(`${c.bright(doc.slug)} \u2014 ${data.annotations.length} annotation${data.annotations.length === 1 ? "" : "s"}
787
- `);
788
- for (const a of data.annotations) {
789
- const type = a.type === "highlight" ? c.amber("highlight") : a.type === "comment" ? c.blue("comment") : c.muted(a.type);
1003
+ console.log(
1004
+ `${c.bright(doc.slug)} \u2014 ${annotations.length} annotation${annotations.length === 1 ? "" : "s"}
1005
+ `
1006
+ );
1007
+ for (const a of annotations) {
1008
+ const typeLabel = a.type === "highlight" ? c.amber("highlight") : a.type === "underline" ? c.amber("underline") : a.type === "strikethrough" ? c.muted("strikethrough") : a.type === "redact" ? c.muted("redact") : a.type === "note" ? c.blue("note") : c.muted(a.type);
790
1009
  const date = c.muted(a.created_at.slice(0, 10));
791
- console.log(` ${type} ${date}`);
792
- if (a.selector_text) console.log(` ${c.muted(">")} ${a.selector_text.slice(0, 80)}${a.selector_text.length > 80 ? "\u2026" : ""}`);
793
- if (a.body) console.log(` ${a.body.slice(0, 120)}${a.body.length > 120 ? "\u2026" : ""}`);
1010
+ const authorTag = a.author === "recipient" ? c.muted(` \xB7 ${a.share_link_label ?? "recipient"}`) : "";
1011
+ console.log(` ${typeLabel} ${date}${authorTag}`);
1012
+ if (a.quoted_text) {
1013
+ console.log(` ${c.muted(">")} ${a.quoted_text.slice(0, 80)}${a.quoted_text.length > 80 ? "\u2026" : ""}`);
1014
+ }
1015
+ if (a.note) {
1016
+ console.log(` ${a.note.slice(0, 120)}${a.note.length > 120 ? "\u2026" : ""}`);
1017
+ }
794
1018
  console.log();
795
1019
  }
796
1020
  }
797
- async function noteAdd(config, slugOrId, text, json) {
798
- const spin = spinner("Resolving document\u2026");
1021
+ async function annotationSearch(config, query, json) {
1022
+ const spin = spinner(`Searching annotations for "${query}"\u2026`);
799
1023
  spin.start();
800
- const doc = await resolveSlug(config, slugOrId);
801
- let body;
802
- if (text) {
803
- body = text;
804
- } else {
805
- if (process.stdin.isTTY) {
806
- spin.stop();
807
- throw new Error("No text provided. Use --text or pipe text via stdin.");
808
- }
809
- spin.stop();
810
- const chunks = [];
811
- for await (const chunk of process.stdin) {
812
- chunks.push(chunk);
813
- }
814
- body = Buffer.concat(chunks).toString("utf-8").trim();
815
- spin.start();
816
- }
817
- if (!body) {
818
- spin.stop();
819
- throw new Error("No text provided. Use --text or pipe to stdin");
820
- }
821
- spin.text = "Adding annotation\u2026";
822
- const result = await apiFetch(
1024
+ const data = await apiFetch(
823
1025
  config,
824
- `/annotations/doc/${doc.id}`,
825
- {
826
- method: "POST",
827
- body: JSON.stringify({ type: "comment", body })
828
- }
1026
+ `/annotations/search?q=${encodeURIComponent(query)}`
829
1027
  );
830
1028
  spin.stop();
1029
+ const annotations = Array.isArray(data) ? data : data.annotations ?? [];
831
1030
  if (json) {
832
- console.log(JSON.stringify({ added: true, id: result.annotation.id, slug: doc.slug }));
833
- } else {
834
- console.log(`${c.teal("Added note to")} ${doc.slug}`);
1031
+ console.log(JSON.stringify(annotations, null, 2));
1032
+ return;
1033
+ }
1034
+ if (annotations.length === 0) {
1035
+ console.log(c.muted(`No annotation results for "${query}".`));
1036
+ return;
835
1037
  }
1038
+ const rows = annotations.map((a) => {
1039
+ const text = a.note ?? a.quoted_text ?? "";
1040
+ return [
1041
+ a.type === "highlight" ? c.amber("HL") : a.type === "note" ? c.blue("NT") : c.muted(a.type.slice(0, 2).toUpperCase()),
1042
+ text.slice(0, 60) + (text.length > 60 ? "\u2026" : ""),
1043
+ c.muted(a.created_at.slice(0, 10))
1044
+ ];
1045
+ });
1046
+ console.log(table(rows, { header: ["", "Text", c.muted("Date")] }));
1047
+ console.log(c.muted(`
1048
+ ${annotations.length} result${annotations.length === 1 ? "" : "s"}`));
836
1049
  }
837
- async function noteSearch(config, query, json) {
838
- const spin = spinner(`Searching annotations for "${query}"\u2026`);
1050
+
1051
+ // src/commands/snippet.ts
1052
+ import { readFileSync as readFileSync3, statSync as statSync2 } from "fs";
1053
+ import { extname as extname2, basename as basename2, resolve as resolve3 } from "path";
1054
+ var MIME_BY_EXT2 = {
1055
+ ".png": "image/png",
1056
+ ".jpg": "image/jpeg",
1057
+ ".jpeg": "image/jpeg",
1058
+ ".webp": "image/webp",
1059
+ ".gif": "image/gif",
1060
+ ".svg": "image/svg+xml"
1061
+ };
1062
+ function parseTags(raw) {
1063
+ if (!raw) return [];
1064
+ if (Array.isArray(raw)) return raw;
1065
+ try {
1066
+ const parsed = JSON.parse(raw);
1067
+ return Array.isArray(parsed) ? parsed : [];
1068
+ } catch {
1069
+ return [];
1070
+ }
1071
+ }
1072
+ function typeBadge(type) {
1073
+ if (type === "quote") return c.amber("quote");
1074
+ if (type === "code") return c.blue("code ");
1075
+ if (type === "image") return c.teal("image");
1076
+ return c.muted(type);
1077
+ }
1078
+ function formatBytes2(bytes) {
1079
+ if (!bytes) return "";
1080
+ if (bytes < 1024) return `${bytes} B`;
1081
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1082
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
1083
+ }
1084
+ function safeHostname(url) {
1085
+ if (!url) return "";
1086
+ try {
1087
+ return new URL(url).hostname.replace(/^www\./, "");
1088
+ } catch {
1089
+ return "";
1090
+ }
1091
+ }
1092
+ async function snippetList(config, options, json) {
1093
+ const spin = spinner("Loading snippets\u2026");
839
1094
  spin.start();
1095
+ const qs = new URLSearchParams();
1096
+ if (options.type) qs.set("type", options.type);
1097
+ if (options.starred) qs.set("starred", "1");
1098
+ if (options.tag) qs.set("tag", options.tag);
1099
+ if (options.limit) qs.set("limit", String(options.limit));
840
1100
  const data = await apiFetch(
841
1101
  config,
842
- `/annotations/search?q=${encodeURIComponent(query)}`
1102
+ `/snippets${qs.toString() ? `?${qs.toString()}` : ""}`
843
1103
  );
844
1104
  spin.stop();
845
1105
  if (json) {
846
- console.log(JSON.stringify(data.annotations));
1106
+ console.log(JSON.stringify(data));
847
1107
  return;
848
1108
  }
849
- if (data.annotations.length === 0) {
850
- console.log(c.muted(`No annotation results for "${query}".`));
1109
+ const items = Array.isArray(data) ? data : data.items ?? [];
1110
+ const total = Array.isArray(data) ? data.length : data.total ?? items.length;
1111
+ if (items.length === 0) {
1112
+ console.log(c.muted("No snippets found."));
851
1113
  return;
852
1114
  }
853
- const rows = data.annotations.map((a) => [
854
- a.type === "highlight" ? c.amber("HL") : c.blue("CM"),
855
- a.body.slice(0, 60) + (a.body.length > 60 ? "\u2026" : ""),
856
- c.muted(a.created_at.slice(0, 10))
857
- ]);
858
- console.log(table(rows, { header: ["", "Text", c.muted("Date")] }));
1115
+ const rows = items.map((s) => {
1116
+ const star = s.starred ? c.amber("\u2605") : " ";
1117
+ const preview = s.type === "image" ? s.alt || "(image)" : s.content.split("\n")[0];
1118
+ const trimmed = preview.length > 60 ? preview.slice(0, 60) + "\u2026" : preview;
1119
+ const source = s.source_site_name || safeHostname(s.source_url);
1120
+ return [
1121
+ star,
1122
+ typeBadge(s.type),
1123
+ c.bright(s.id),
1124
+ trimmed,
1125
+ c.muted(source || ""),
1126
+ c.muted(s.created_at.slice(0, 10))
1127
+ ];
1128
+ });
1129
+ console.log(table(rows, { header: ["", "", "ID", "Preview", "Source", c.muted("Date")] }));
859
1130
  console.log(c.muted(`
860
- ${data.annotations.length} result${data.annotations.length === 1 ? "" : "s"}`));
1131
+ ${total} snippet${total === 1 ? "" : "s"} total`));
1132
+ }
1133
+ async function snippetSearch(config, query, options, json) {
1134
+ const spin = spinner(`Searching snippets for "${query}"\u2026`);
1135
+ spin.start();
1136
+ const qs = new URLSearchParams({ q: query });
1137
+ if (options.type) qs.set("type", options.type);
1138
+ if (options.limit) qs.set("limit", String(options.limit));
1139
+ const data = await apiFetch(config, `/snippets/search?${qs.toString()}`);
1140
+ spin.stop();
1141
+ const items = Array.isArray(data) ? data : data.items ?? [];
1142
+ if (json) {
1143
+ console.log(JSON.stringify(items));
1144
+ return;
1145
+ }
1146
+ if (items.length === 0) {
1147
+ console.log(c.muted(`No snippet results for "${query}".`));
1148
+ return;
1149
+ }
1150
+ for (const s of items) {
1151
+ const star = s.starred ? c.amber("\u2605 ") : "";
1152
+ const tags = parseTags(s.tags);
1153
+ const tagStr = tags.length ? " " + tags.map((t) => c.muted(`#${t}`)).join(" ") : "";
1154
+ console.log(`${star}${typeBadge(s.type)} ${c.bright(s.id)}${tagStr}`);
1155
+ if (s.type === "image") {
1156
+ console.log(` ${c.muted("alt:")} ${s.alt || "(none)"} ${c.muted(formatBytes2(s.byte_size))}`);
1157
+ } else {
1158
+ const lines = s.content.split("\n").slice(0, 3);
1159
+ for (const line of lines) {
1160
+ const trimmed = line.length > 100 ? line.slice(0, 100) + "\u2026" : line;
1161
+ console.log(` ${trimmed}`);
1162
+ }
1163
+ if (s.content.split("\n").length > 3) console.log(c.muted(" \u2026"));
1164
+ }
1165
+ if (s.note) console.log(` ${c.muted("note:")} ${s.note}`);
1166
+ if (s.source_url) console.log(` ${c.muted("source:")} ${s.source_url}`);
1167
+ console.log();
1168
+ }
1169
+ console.log(c.muted(`${items.length} result${items.length === 1 ? "" : "s"}`));
1170
+ }
1171
+ async function snippetShow(config, id, json) {
1172
+ const spin = spinner("Loading snippet\u2026");
1173
+ spin.start();
1174
+ const s = await apiFetch(config, `/snippets/${id}`);
1175
+ spin.stop();
1176
+ if (json) {
1177
+ console.log(JSON.stringify(s));
1178
+ return;
1179
+ }
1180
+ const tags = parseTags(s.tags);
1181
+ console.log(`${typeBadge(s.type)} ${c.bright(s.id)}${s.starred ? " " + c.amber("\u2605") : ""}`);
1182
+ console.log(c.muted(`created ${s.created_at}`));
1183
+ if (tags.length) console.log(c.muted("tags: ") + tags.map((t) => `#${t}`).join(" "));
1184
+ console.log();
1185
+ if (s.type === "image") {
1186
+ console.log(`${c.muted("alt:")} ${s.alt || "(none)"}`);
1187
+ console.log(`${c.muted("mime:")} ${s.mime || "(unknown)"}`);
1188
+ if (s.width && s.height) console.log(`${c.muted("size:")} ${s.width}\xD7${s.height} (${formatBytes2(s.byte_size)})`);
1189
+ console.log(`${c.muted("url:")} ${config.endpoint}/api/snippets/${s.id}/raw`);
1190
+ } else {
1191
+ if (s.type === "code" && s.language) console.log(c.muted(`language: ${s.language}
1192
+ `));
1193
+ console.log(s.content);
1194
+ }
1195
+ if (s.note) {
1196
+ console.log();
1197
+ console.log(c.muted("Note:"));
1198
+ console.log(s.note);
1199
+ }
1200
+ if (s.source_url || s.source_title || s.source_site_name || s.source_author) {
1201
+ console.log();
1202
+ console.log(c.muted("Source:"));
1203
+ if (s.source_title) console.log(` ${s.source_title}`);
1204
+ if (s.source_url) console.log(` ${c.muted(s.source_url)}`);
1205
+ const meta = [s.source_site_name, s.source_author].filter(Boolean).join(" \xB7 ");
1206
+ if (meta) console.log(` ${c.muted(meta)}`);
1207
+ }
1208
+ }
1209
+ async function snippetNew(config, options, json) {
1210
+ if (options.image) {
1211
+ const path = resolve3(options.image);
1212
+ let stat;
1213
+ try {
1214
+ stat = statSync2(path);
1215
+ } catch {
1216
+ throw new Error(`Image not found: ${path}`);
1217
+ }
1218
+ if (!stat.isFile()) throw new Error(`Not a file: ${path}`);
1219
+ const ext = extname2(path).toLowerCase();
1220
+ const mime = MIME_BY_EXT2[ext];
1221
+ if (!mime) {
1222
+ throw new Error(`Unsupported image extension: ${ext}. Allowed: ${Object.keys(MIME_BY_EXT2).join(", ")}`);
1223
+ }
1224
+ if (stat.size > 25 * 1024 * 1024) {
1225
+ throw new Error(`Image exceeds 25 MB limit (${formatBytes2(stat.size)})`);
1226
+ }
1227
+ const spin2 = spinner(`Uploading ${basename2(path)}\u2026`);
1228
+ spin2.start();
1229
+ const bytes = readFileSync3(path);
1230
+ const form = new FormData();
1231
+ form.set("metadata", JSON.stringify({
1232
+ alt: options.alt ?? null,
1233
+ note: options.note ?? null,
1234
+ source_url: options.sourceUrl ?? null,
1235
+ source_title: options.sourceTitle ?? null,
1236
+ source_site_name: options.sourceSiteName ?? null,
1237
+ source_author: options.sourceAuthor ?? null,
1238
+ tags: options.tags ?? [],
1239
+ starred: options.starred ? 1 : 0
1240
+ }));
1241
+ form.set("file", new File([bytes], basename2(path), { type: mime }));
1242
+ const result2 = await apiFetch(config, "/snippets", {
1243
+ method: "POST",
1244
+ body: form
1245
+ });
1246
+ spin2.stop();
1247
+ if (json) {
1248
+ console.log(JSON.stringify(result2));
1249
+ } else {
1250
+ console.log(`${c.teal("Created image snippet")} ${c.bright(result2.id)}`);
1251
+ }
1252
+ return;
1253
+ }
1254
+ let content = options.text;
1255
+ if (!content && options.file) {
1256
+ content = readFileSync3(resolve3(options.file), "utf-8");
1257
+ }
1258
+ if (!content && !process.stdin.isTTY) {
1259
+ const chunks = [];
1260
+ for await (const chunk of process.stdin) chunks.push(chunk);
1261
+ content = Buffer.concat(chunks).toString("utf-8");
1262
+ }
1263
+ if (!content) {
1264
+ throw new Error('No content. Provide --text "...", --file <path>, or pipe content via stdin.');
1265
+ }
1266
+ const spin = spinner("Creating snippet\u2026");
1267
+ spin.start();
1268
+ const result = await apiFetch(config, "/snippets", {
1269
+ method: "POST",
1270
+ headers: { "Content-Type": "application/json" },
1271
+ body: JSON.stringify({
1272
+ type: options.type ?? "quote",
1273
+ content: content.trim(),
1274
+ language: options.language ?? null,
1275
+ note: options.note ?? null,
1276
+ source_url: options.sourceUrl ?? null,
1277
+ source_title: options.sourceTitle ?? null,
1278
+ source_site_name: options.sourceSiteName ?? null,
1279
+ source_author: options.sourceAuthor ?? null,
1280
+ tags: options.tags ?? [],
1281
+ starred: options.starred ? 1 : 0
1282
+ })
1283
+ });
1284
+ spin.stop();
1285
+ if (json) {
1286
+ console.log(JSON.stringify(result));
1287
+ } else {
1288
+ console.log(`${c.teal("Created snippet")} ${c.bright(result.id)} ${c.muted(`(${result.type})`)}`);
1289
+ }
1290
+ }
1291
+ async function snippetDelete(config, id, json) {
1292
+ const spin = spinner("Deleting snippet\u2026");
1293
+ spin.start();
1294
+ await apiFetch(config, `/snippets/${id}`, { method: "DELETE" });
1295
+ spin.stop();
1296
+ if (json) {
1297
+ console.log(JSON.stringify({ deleted: true, id }));
1298
+ } else {
1299
+ console.log(`${c.teal("Deleted snippet")} ${id}`);
1300
+ }
1301
+ }
1302
+ async function snippetStar(config, id, starred, json) {
1303
+ const result = await apiFetch(config, `/snippets/${id}`, {
1304
+ method: "PATCH",
1305
+ headers: { "Content-Type": "application/json" },
1306
+ body: JSON.stringify({ starred: starred ? 1 : 0 })
1307
+ });
1308
+ if (json) {
1309
+ console.log(JSON.stringify(result));
1310
+ } else {
1311
+ console.log(`${c.teal(starred ? "Starred" : "Unstarred")} ${id}`);
1312
+ }
861
1313
  }
862
1314
 
863
1315
  // src/commands/doctor.ts
@@ -898,7 +1350,7 @@ async function doctor(config, json) {
898
1350
  const where = hasProjectSkill ? "project" : "user";
899
1351
  checks.push({ name: "Claude skill", status: "ok", detail: `installed (${where}-level)` });
900
1352
  } else {
901
- checks.push({ name: "Claude skill", status: "warn", detail: "not installed (run `arty skill install`)" });
1353
+ checks.push({ name: "Claude skill", status: "warn", detail: "not installed (run `artyfax skill install`)" });
902
1354
  }
903
1355
  if (json) {
904
1356
  console.log(JSON.stringify(checks));
@@ -922,27 +1374,576 @@ async function doctor(config, json) {
922
1374
  }
923
1375
  }
924
1376
 
1377
+ // src/commands/work.ts
1378
+ var STATUS_SYMBOLS = {
1379
+ backlog: "\u25CB",
1380
+ up_next: "\u25CE",
1381
+ on_hold: "\u23F8",
1382
+ in_progress: "\u25CF",
1383
+ in_review: "\u25C9",
1384
+ done: "\u2713",
1385
+ cancelled: "\u2717"
1386
+ };
1387
+ var STATUS_LABELS = {
1388
+ backlog: "Backlog",
1389
+ up_next: "Up Next",
1390
+ on_hold: "On Hold",
1391
+ in_progress: "In Progress",
1392
+ in_review: "In Review",
1393
+ done: "Done",
1394
+ cancelled: "Cancelled"
1395
+ };
1396
+ function statusLabel(s) {
1397
+ const sym = STATUS_SYMBOLS[s] || "?";
1398
+ const label = STATUS_LABELS[s] || s;
1399
+ return `${sym} ${label}`;
1400
+ }
1401
+ function progress(item) {
1402
+ if (item.task_total === 0) return c.muted("-");
1403
+ return `${item.task_done}/${item.task_total}`;
1404
+ }
1405
+ async function workBoard(config, json) {
1406
+ const spin = spinner("Loading board\u2026");
1407
+ spin.start();
1408
+ const data = await apiFetch(config, "/projects/work");
1409
+ spin.stop();
1410
+ if (json) {
1411
+ console.log(JSON.stringify(data.work_items, null, 2));
1412
+ return;
1413
+ }
1414
+ if (data.work_items.length === 0) {
1415
+ console.log(c.muted("No work items. Promote a document in its settings panel."));
1416
+ return;
1417
+ }
1418
+ const byStatus = /* @__PURE__ */ new Map();
1419
+ for (const item of data.work_items) {
1420
+ const list2 = byStatus.get(item.work_status) || [];
1421
+ list2.push(item);
1422
+ byStatus.set(item.work_status, list2);
1423
+ }
1424
+ for (const status of ["up_next", "on_hold", "in_progress", "in_review", "backlog"]) {
1425
+ const items = byStatus.get(status);
1426
+ if (!items || items.length === 0) continue;
1427
+ console.log(`
1428
+ ${c.bright(statusLabel(status))} ${c.muted(`(${items.length})`)}`);
1429
+ for (const item of items) {
1430
+ const id = item.work_id ? c.amber(item.work_id.padEnd(8)) : c.muted("--------");
1431
+ const prog = progress(item);
1432
+ const agent = item.work_agent ? c.muted(`[${item.work_agent}]`) : "";
1433
+ const current = item.task_current ? c.muted(` \u2014 ${item.task_current.slice(0, 50)}`) : "";
1434
+ console.log(` ${id} ${item.title} ${prog} ${agent}${current}`);
1435
+ }
1436
+ }
1437
+ console.log();
1438
+ }
1439
+ async function workList(config, json, flags) {
1440
+ const spin = spinner("Loading work items\u2026");
1441
+ spin.start();
1442
+ const qs = new URLSearchParams();
1443
+ if (flags.project) qs.set("project_id", flags.project);
1444
+ if (flags.agent) qs.set("agent", flags.agent);
1445
+ if (flags.status) qs.set("status", flags.status);
1446
+ if (flags.type) qs.set("type", flags.type);
1447
+ const data = await apiFetch(config, `/projects/work?${qs}`);
1448
+ spin.stop();
1449
+ if (json) {
1450
+ console.log(JSON.stringify(data.work_items, null, 2));
1451
+ return;
1452
+ }
1453
+ if (data.work_items.length === 0) {
1454
+ console.log(c.muted("No matching work items."));
1455
+ return;
1456
+ }
1457
+ const rows = data.work_items.map((item) => [
1458
+ item.work_id ? c.amber(item.work_id) : c.muted("-"),
1459
+ item.title.slice(0, 50),
1460
+ statusLabel(item.work_status),
1461
+ item.work_type || c.muted("-"),
1462
+ item.work_agent || c.muted("-"),
1463
+ progress(item)
1464
+ ]);
1465
+ console.log(table(rows, { header: ["ID", "Title", "Status", "Type", "Agent", "Progress"] }));
1466
+ }
1467
+ async function workNext(config, json, agent) {
1468
+ const spin = spinner("Picking next\u2026");
1469
+ spin.start();
1470
+ const data = await apiFetch(config, "/projects/work?status=up_next");
1471
+ spin.stop();
1472
+ const items = data.work_items;
1473
+ if (items.length === 0) {
1474
+ console.log(json ? "null" : c.muted("No items in Up Next."));
1475
+ return;
1476
+ }
1477
+ let pick = items[0];
1478
+ if (agent) {
1479
+ const assigned = items.find((i) => i.work_agent === agent.toLowerCase());
1480
+ const unassigned = items.find((i) => !i.work_agent);
1481
+ pick = assigned || unassigned || items[0];
1482
+ }
1483
+ if (json) {
1484
+ console.log(JSON.stringify(pick, null, 2));
1485
+ return;
1486
+ }
1487
+ const id = pick.work_id ? c.amber(pick.work_id) : pick.id;
1488
+ console.log(`${id} ${c.bright(pick.title)}`);
1489
+ if (pick.task_current) console.log(c.muted(` Current: ${pick.task_current}`));
1490
+ console.log(c.muted(` Progress: ${progress(pick)} | Agent: ${pick.work_agent || "unassigned"}`));
1491
+ console.log(c.muted(` ID: ${pick.id}`));
1492
+ }
1493
+ async function resolveWorkId(config, idOrWorkId) {
1494
+ const data = await apiFetch(config, `/resolve/${encodeURIComponent(idOrWorkId)}`);
1495
+ return data.id;
1496
+ }
1497
+ async function workSetStatus(config, docId, status, json) {
1498
+ const demote = ["none", "off", "remove"].includes(status.toLowerCase());
1499
+ const spin = spinner(demote ? "Removing from board\u2026" : `Setting status to ${status}\u2026`);
1500
+ spin.start();
1501
+ const resolvedId = await resolveWorkId(config, docId);
1502
+ const data = await apiFetch(config, `/documents/${resolvedId}/metadata`, {
1503
+ method: "PATCH",
1504
+ headers: { "Content-Type": "application/json" },
1505
+ body: JSON.stringify({ work_status: demote ? null : status })
1506
+ });
1507
+ spin.stop();
1508
+ if (json) {
1509
+ console.log(JSON.stringify(data));
1510
+ return;
1511
+ }
1512
+ console.log(c.bright(`${data.work_id || docId} \u2192 ${demote ? "removed from board" : statusLabel(status)}`));
1513
+ }
1514
+ function escapeHtml(s) {
1515
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1516
+ }
1517
+ async function workCreate(config, title, flags, json) {
1518
+ const projectId = flags.project;
1519
+ if (!projectId) {
1520
+ console.error(c.rose("Error: --project is required. Work items must belong to a project."));
1521
+ process.exitCode = 1;
1522
+ return;
1523
+ }
1524
+ const spin = spinner("Creating work item\u2026");
1525
+ spin.start();
1526
+ const id = crypto.randomUUID();
1527
+ const slugBody = title.toLowerCase().replace(/[\s_]+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
1528
+ const slug = `inbox/${slugBody || `item-${id.slice(0, 8)}`}`;
1529
+ const saveRes = await apiFetch(config, "/documents", {
1530
+ method: "POST",
1531
+ headers: { "Content-Type": "application/json" },
1532
+ body: JSON.stringify({
1533
+ id,
1534
+ slug,
1535
+ title,
1536
+ category: "inbox",
1537
+ md_content: `# ${title}
1538
+ `,
1539
+ format: "md",
1540
+ html_content: `<h1>${escapeHtml(title)}</h1>`
1541
+ })
1542
+ });
1543
+ if (saveRes.ok) {
1544
+ const status = flags.status || "up_next";
1545
+ await apiFetch(config, `/documents/${id}/metadata`, {
1546
+ method: "PATCH",
1547
+ headers: { "Content-Type": "application/json" },
1548
+ body: JSON.stringify({
1549
+ project_id: projectId,
1550
+ work_status: status,
1551
+ work_type: flags.type || null,
1552
+ work_agent: flags.agent || null
1553
+ })
1554
+ });
1555
+ }
1556
+ spin.stop();
1557
+ if (json) {
1558
+ console.log(JSON.stringify({ ok: true, id }));
1559
+ return;
1560
+ }
1561
+ console.log(c.bright(`Created: ${title}`));
1562
+ console.log(c.muted(`ID: ${id}`));
1563
+ }
1564
+
1565
+ // src/commands/project.ts
1566
+ import { readFileSync as readFileSync4, writeFileSync, existsSync as existsSync2 } from "node:fs";
1567
+ import { resolve as resolve4 } from "node:path";
1568
+ var ARTYFAX_FILE = "ARTYFAX.md";
1569
+ var USER_MARKER = "<!-- arty:user -->";
1570
+ async function projectList(config, json) {
1571
+ const spin = spinner("Loading projects\u2026");
1572
+ spin.start();
1573
+ const data = await apiFetch(config, "/projects");
1574
+ spin.stop();
1575
+ if (json) {
1576
+ console.log(JSON.stringify(data.projects, null, 2));
1577
+ return;
1578
+ }
1579
+ if (data.projects.length === 0) {
1580
+ console.log(c.muted('No projects. Create one with `artyfax project create "Name" --prefix XX`.'));
1581
+ return;
1582
+ }
1583
+ const rows = data.projects.map((p) => [
1584
+ c.amber(p.prefix),
1585
+ p.name,
1586
+ c.muted(String(p.next_work_id - 1) + " items"),
1587
+ p.description ? c.muted(p.description.slice(0, 40)) : ""
1588
+ ]);
1589
+ console.log(table(rows, { header: ["Prefix", "Name", "Items", "Description"] }));
1590
+ }
1591
+ async function projectCreate(config, name, prefix, description, json, workspaces) {
1592
+ const spin = spinner("Creating project\u2026");
1593
+ spin.start();
1594
+ const workspace_ids = workspaces?.length ? await resolveWorkspaceIds(config, workspaces) : [];
1595
+ const data = await apiFetch(config, "/projects", {
1596
+ method: "POST",
1597
+ headers: { "Content-Type": "application/json" },
1598
+ body: JSON.stringify({
1599
+ name,
1600
+ prefix,
1601
+ description,
1602
+ workspace_scope: workspace_ids.length ? "scoped" : "global",
1603
+ workspace_ids
1604
+ })
1605
+ });
1606
+ spin.stop();
1607
+ if (json) {
1608
+ console.log(JSON.stringify(data, null, 2));
1609
+ return;
1610
+ }
1611
+ console.log(c.bright(`Project created: ${name} (${c.amber(data.prefix)})`));
1612
+ }
1613
+ var STATUS_ORDER = ["in_progress", "in_review", "up_next", "on_hold", "backlog", "done", "cancelled"];
1614
+ var STATUS_HEADINGS = {
1615
+ in_progress: "In Progress",
1616
+ in_review: "In Review",
1617
+ up_next: "Up Next",
1618
+ on_hold: "On Hold",
1619
+ backlog: "Backlog",
1620
+ done: "Done",
1621
+ cancelled: "Cancelled"
1622
+ };
1623
+ function formatProgress(item) {
1624
+ if (item.task_total === 0) return "";
1625
+ return ` [${item.task_done}/${item.task_total}]`;
1626
+ }
1627
+ function resolveProject(projects, hint) {
1628
+ if (hint) {
1629
+ return projects.find(
1630
+ (p) => p.slug === hint || p.prefix.toLowerCase() === hint.toLowerCase() || p.name.toLowerCase() === hint.toLowerCase()
1631
+ ) || null;
1632
+ }
1633
+ if (projects.length === 1) return projects[0];
1634
+ return null;
1635
+ }
1636
+ function parseExistingPrefix(filePath) {
1637
+ if (!existsSync2(filePath)) return null;
1638
+ const content = readFileSync4(filePath, "utf-8");
1639
+ const match = content.match(/^# .+\(([A-Z]+)\)/m);
1640
+ return match ? match[1] : null;
1641
+ }
1642
+ function extractUserContent(filePath) {
1643
+ if (!existsSync2(filePath)) return "";
1644
+ const content = readFileSync4(filePath, "utf-8");
1645
+ const matches = [...content.matchAll(new RegExp(`^${USER_MARKER}$`, "gm"))];
1646
+ const match = matches[matches.length - 1];
1647
+ if (!match || match.index == null) return "";
1648
+ return content.substring(match.index + USER_MARKER.length);
1649
+ }
1650
+ function padTable(rows) {
1651
+ const cols = rows[0].length;
1652
+ const widths = Array.from(
1653
+ { length: cols },
1654
+ (_, i) => Math.max(...rows.map((r) => r[i].length))
1655
+ );
1656
+ const lines = [];
1657
+ for (let r = 0; r < rows.length; r++) {
1658
+ const cells = rows[r].map((cell, i) => ` ${cell.padEnd(widths[i])} `);
1659
+ lines.push(`|${cells.join("|")}|`);
1660
+ if (r === 0) {
1661
+ const sep = widths.map((w) => "-".repeat(w + 2));
1662
+ lines.push(`|${sep.join("|")}|`);
1663
+ }
1664
+ }
1665
+ return lines.join("\n");
1666
+ }
1667
+ function generateWorkflow(project) {
1668
+ const p = project.prefix;
1669
+ const tbl = padTable([
1670
+ ["Action", "CLI", "MCP"],
1671
+ ["Board overview", "`artyfax work list`", `\`list_work(project: '${p}')\``],
1672
+ ["Read a work item", "`artyfax read <slug>`", `\`get_document('${p}-2', include_annotations: true)\``],
1673
+ ["Update content", "`artyfax update <slug> <file>`", `\`patch_document('${p}-2', patches)\``],
1674
+ ["Tick a task", '`artyfax task <id> "text" <state>`', `\`update_task('${p}-2', task, state)\``],
1675
+ ["Claim and start", "`artyfax work status <id> in_progress`", `\`start_work('${p}-2', agent: '<agent>')\``],
1676
+ ["Change status", "`artyfax work status <id> <status>`", `\`update_work_status('${p}-2', status)\``],
1677
+ ["Refresh this file", "`artyfax init`", "--"]
1678
+ ]);
1679
+ return `## Workflow
1680
+
1681
+ This project tracks work in [Artyfax](https://artyfax.io). Reference this file for the landscape, then use the MCP tools or CLI for live data.
1682
+
1683
+ ${tbl}
1684
+
1685
+ The CLI command is \`artyfax\`. The examples above spell it out in full; if you prefer, shorten it to \`arty\` or \`ax\` - they are the same command.
1686
+
1687
+ All MCP tools that take a document ID also accept work IDs (e.g. \`${p}-2\`) directly.
1688
+
1689
+ ### Picking Up Agent Work
1690
+
1691
+ When asked for the next task for a specific agent (for example "next Codex task"), use live Artyfax data and check: assigned \`in_progress\`, assigned \`up_next\`, assigned \`backlog\`, top unassigned \`up_next\`, and any work ID implied by the worktree name. If an assigned \`up_next\` item exists, use it. If the plausible choices differ (for example assigned backlog vs unassigned \`up_next\`), confirm before claiming. Never claim \`in_review\`, \`done\`, or another agent's item without explicit direction.
1692
+
1693
+ ### Lifecycle
1694
+
1695
+ 1. **Pick up work** - read this file for the landscape, then use live MCP/CLI data to select the correct item. Use the agent-specific pickup flow above when the requester names an agent.
1696
+ 2. **Claim it before writing code** - \`start_work('${p}-15', agent: '<agent>')\` moves to In Progress, promotes to top of column, and shows your name on the board. Always do this before implementing anything so the board reflects active work. Claiming signals ownership; it is not approval to start coding when the work item still needs clarification.
1697
+ 3. **Work, updating the board per task** - read the doc with \`get_document('${p}-15')\`, then for each task: mark it \`active\` when you start it and \`done\` when you finish it via \`update_task('${p}-15', '<task substring>', 'active'|'done')\`. \`active\` surfaces the live current task on the board so other sessions (and the user) can see what is being worked right now, not just a count.
1698
+ 4. **Blocked?** - \`update_work_status('${p}-15', 'on_hold')\` and note why
1699
+ 5. **Resuming work** - if you pick an item back up after it has left In Progress (it sits in In Review, on_hold, or up_next), call \`update_work_status('${p}-15', 'in_progress')\` *first*, so the board shows it active again while you work. Move it back to In Review only once the new work is finished.
1700
+ 6. **Complete** - after pushing the final commit, call \`complete_work('${p}-15')\` to move to In Review. "In Review" means implementation is done and waiting for the user to verify - not that it's perfect. Before completing, every open question and every unticked task must be put to the user - you do not decide their fate yourself. A "stretch", "consider", or "optional" task is still an open question. Surface each one with a structured question/approval tool (\`AskUserQuestion\` where the runtime provides one) so the user chooses what happens: do it now, split it into a new follow-up work item, or drop it. You do not get to judge something "quick and safe" and skip the question. Never move an item to In Review with a dangling task or unanswered question left hanging.
1701
+ 7. **Done** - only the user moves parent items from In Review to Done after personal review
1702
+
1703
+ ### Thin work items
1704
+
1705
+ When a work item only has a short title/description and no task checklist, don't jump straight into implementation.
1706
+
1707
+ 1. Read the title and description.
1708
+ 2. Write an \`## Understanding\` section in the Artyfax document with the inferred scope, assumptions, and non-goals.
1709
+ 3. Add an \`## Tasks\` checklist before editing code.
1710
+ 4. Ask the requester to approve the understanding and task list before starting implementation. Use a structured question/approval tool when the runtime provides one. If not, say so and wait for typed approval.
1711
+ 5. After approval, mark the first task \`active\`, then update checkbox progress as the work proceeds.
1712
+
1713
+ If the requester corrects workflow sequencing mid-session, pause implementation, repair the Artyfax document/checklist first, and resume from the checklist rather than local memory.
1714
+
1715
+ Open questions are a completion blocker, not just a planning step. Any unresolved question or unticked task - including ones you uncover while building - must go back to the user through a structured question (\`AskUserQuestion\` where available) before the item can move to In Review. You surface and the user decides; you never resolve an open question on your own authority.
1716
+
1717
+ ### Board update cadence
1718
+
1719
+ The board is the cross-session signal - other agents and the user read live progress from it, so it must reflect reality *as you work*, not after the fact.
1720
+
1721
+ - **Target: per task.** Mark each task \`active\` on start and \`done\` on finish via \`update_task\`. It is a single lightweight call (substring match, server reparses progress automatically) - no full-content rewrite, so the cost is negligible. Prefer it over \`patch_document\` for ticking; reserve \`patch_document\` for editing the doc body.
1722
+ - **Floor: per logical section.** If per-task is genuinely too chatty for a fast run of trivial steps, batch a section's ticks in one \`update_task\` sequence the moment that section is done and verified - never later.
1723
+ - **Never batch only at the end.** A board that jumps from 0 to done in one shot defeats the purpose and hides active work from concurrent sessions.
1724
+ - **Cost note:** \`update_task\` is cheap, but each call is a synchronous round trip (MCP calls can't be backgrounded). For a long run of one-line tasks, the per-section floor is the sensible trade; for substantial tasks, always go per task.
1725
+
1726
+ ### Keeping this file current
1727
+
1728
+ This file is generated by \`artyfax init\`. Do not hand-edit generated workflow or board sections for durable changes; update the CLI generator/template, then regenerate. Custom notes in the user section are preserved.
1729
+
1730
+ Use Artyfax MCP/CLI for live work updates (status changes, task ticks, assignments). Run \`artyfax init\` to refresh this snapshot when the generated board view is stale or after meaningful board changes.
1731
+
1732
+ ### Conventions
1733
+
1734
+ - **Work IDs** (e.g. ${p}-15) are stable. Reference them in commits and changelogs. All MCP tools accept them directly.
1735
+ - **Descriptions**: concise, state what it delivers, not how it works - the MCP tool guidance carries the exact length.
1736
+ - **Statuses**: backlog > up_next > in_progress > in_review > done (or on_hold, cancelled).
1737
+ `;
1738
+ }
1739
+ function generateSaving() {
1740
+ return `## Saving documents
1741
+
1742
+ Artyfax is the default home for documents. When you produce a document for the user, or they hand you one (markdown, HTML, a report, a generated artifact, a story), save it into Artyfax with the MCP tools or the \`artyfax\` CLI - not the local filesystem. Reach for a loose local file only when the user explicitly asks. A standalone HTML page is a first-class document: save it as-is, don't restyle it to match other documents and don't check whether the reader can render it first.
1743
+
1744
+ ### Images on documents
1745
+
1746
+ To put a local or freshly generated image on a document, as a cover or inline, use the CLI - not the MCP \`set_cover_image\` tool, whose base64 path truncates large images over the transport and degrades them (its URL option only helps when the image already lives at a public URL).
1747
+
1748
+ - \`artyfax cover <doc> <file>\` - set a document's cover image (renders best at 1200x630).
1749
+ - \`artyfax image <doc> <file>\` - upload an inline image and print the \`![alt](images/<hash>.<ext>)\` markdown to paste into the body.
1750
+
1751
+ \`<doc>\` is a doc id, slug, or work id (any document). Accepts png/jpg/webp/gif/avif/svg up to 5 MB. A public image URL in \`![](...)\` is re-hosted automatically on save, so only local files need these commands; \`set_cover_image\` with \`image_url\` is still right for a cover already at a public URL.
1752
+ `;
1753
+ }
1754
+ function generateBoard(items) {
1755
+ const byStatus = /* @__PURE__ */ new Map();
1756
+ for (const item of items) {
1757
+ const list2 = byStatus.get(item.work_status) || [];
1758
+ list2.push(item);
1759
+ byStatus.set(item.work_status, list2);
1760
+ }
1761
+ const lines = [];
1762
+ for (const status of STATUS_ORDER) {
1763
+ const group = byStatus.get(status);
1764
+ if (!group || group.length === 0) continue;
1765
+ const heading = STATUS_HEADINGS[status] || status;
1766
+ if (status === "done" || status === "cancelled") {
1767
+ lines.push(`## ${heading} (${group.length})`);
1768
+ lines.push("");
1769
+ lines.push(`${group.length} ${status === "done" ? "completed" : "cancelled"} items. Use \`artyfax work list --status ${status}\` or \`list_work(status: '${status}')\` for details.`);
1770
+ } else {
1771
+ lines.push(`## ${heading}`);
1772
+ lines.push("");
1773
+ for (const item of group) {
1774
+ const id = item.work_id || item.id.slice(0, 8);
1775
+ const prog = formatProgress(item);
1776
+ const agent = item.work_agent ? ` @${item.work_agent}` : "";
1777
+ lines.push(`- ${id} ${item.title}${prog}${agent}`);
1778
+ }
1779
+ }
1780
+ lines.push("");
1781
+ }
1782
+ return lines.join("\n");
1783
+ }
1784
+ async function init(config, projectHint) {
1785
+ const filePath = resolve4(process.cwd(), ARTYFAX_FILE);
1786
+ const isUpdate = existsSync2(filePath);
1787
+ const spin = spinner(isUpdate ? "Refreshing ARTYFAX.md\u2026" : "Initialising ARTYFAX.md\u2026");
1788
+ spin.start();
1789
+ const existingPrefix = isUpdate ? parseExistingPrefix(filePath) : null;
1790
+ const hint = projectHint || existingPrefix || void 0;
1791
+ const projects = await apiFetch(config, "/projects");
1792
+ const project = resolveProject(projects.projects, hint);
1793
+ if (!project) {
1794
+ spin.stop();
1795
+ if (projects.projects.length === 0) {
1796
+ console.error(c.rose('No projects found. Create one first: artyfax project create "Name" --prefix XX'));
1797
+ } else {
1798
+ const prefixes = projects.projects.map((p) => c.amber(p.prefix)).join(", ");
1799
+ console.error(c.rose(`Specify a project: artyfax init <prefix>`));
1800
+ console.error(c.muted(`Available: ${prefixes}`));
1801
+ }
1802
+ process.exitCode = 1;
1803
+ return;
1804
+ }
1805
+ const qs = new URLSearchParams({ project_id: project.id });
1806
+ const data = await apiFetch(config, `/projects/work?${qs}`);
1807
+ spin.stop();
1808
+ const userContent = extractUserContent(filePath);
1809
+ const d = /* @__PURE__ */ new Date();
1810
+ const now = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
1811
+ const generated = [
1812
+ `# ${project.name} (${project.prefix})`,
1813
+ "",
1814
+ `Last updated: ${now} \xB7 Live data: \`artyfax work list\` or \`list_work(project: '${project.prefix}')\``,
1815
+ "",
1816
+ generateSaving(),
1817
+ generateWorkflow(project),
1818
+ generateBoard(data.work_items),
1819
+ USER_MARKER
1820
+ ].join("\n");
1821
+ const content = generated + userContent;
1822
+ writeFileSync(filePath, content);
1823
+ const action = isUpdate ? "Updated" : "Created";
1824
+ console.log(c.bright(`${action} ${ARTYFAX_FILE}`));
1825
+ console.log(c.muted(`${project.name} (${project.prefix}) \xB7 ${data.work_items.length} work items`));
1826
+ if (!isUpdate) {
1827
+ const claudeMd = resolve4(process.cwd(), "CLAUDE.md");
1828
+ if (existsSync2(claudeMd)) {
1829
+ const claudeContent = readFileSync4(claudeMd, "utf-8");
1830
+ if (!claudeContent.includes("@ARTYFAX.md")) {
1831
+ console.log("");
1832
+ console.log(c.muted("Add to CLAUDE.md to give agents the project board:"));
1833
+ console.log(c.amber(" @ARTYFAX.md"));
1834
+ }
1835
+ }
1836
+ }
1837
+ }
1838
+
925
1839
  // src/commands/skill.ts
926
1840
  async function skillInstall(_project) {
927
1841
  console.log(c.amber("Skill installation is not available yet."));
928
1842
  console.log(c.muted("The Artyfax skill content is being designed in design-28."));
929
- console.log(c.muted("Once ready, `arty skill install` will write skill files to ~/.claude/skills/artyfax"));
1843
+ console.log(c.muted("Once ready, `artyfax skill install` will write skill files to ~/.claude/skills/artyfax"));
930
1844
  }
931
1845
  async function skillStatus() {
932
1846
  console.log(c.amber("Skill status is not available yet."));
933
- console.log(c.muted("Run `arty doctor` to check basic setup."));
1847
+ console.log(c.muted("Run `artyfax doctor` to check basic setup."));
934
1848
  }
935
1849
  async function skillUpdate() {
936
1850
  console.log(c.amber("Skill update is not available yet."));
937
1851
  }
938
1852
 
1853
+ // src/commands/task.ts
1854
+ async function resolveId(config, identifier) {
1855
+ const data = await apiFetch(config, `/resolve/${encodeURIComponent(identifier)}`);
1856
+ return data.id;
1857
+ }
1858
+ async function taskList(config, identifier, json) {
1859
+ const spin = spinner("Loading tasks\u2026");
1860
+ spin.start();
1861
+ const id = await resolveId(config, identifier);
1862
+ const doc = await apiFetch(config, `/documents/${id}/content`);
1863
+ spin.stop();
1864
+ if (doc.format !== "md" || !doc.md_source) {
1865
+ console.error(c.rose("Task listing only supported for markdown documents"));
1866
+ process.exit(1);
1867
+ }
1868
+ const tasks = parseTasks(doc.md_source);
1869
+ if (json) {
1870
+ console.log(JSON.stringify(tasks, null, 2));
1871
+ return;
1872
+ }
1873
+ if (tasks.length === 0) {
1874
+ console.log(c.muted("No tasks found"));
1875
+ return;
1876
+ }
1877
+ const stateIcon = { done: c.teal("\u2713"), active: c.amber("~"), pending: c.muted("\u25CB") };
1878
+ for (const t of tasks) {
1879
+ console.log(`${stateIcon[t.state]} ${c.muted(`[${t.index}]`)} ${t.text}`);
1880
+ }
1881
+ const done = tasks.filter((t) => t.state === "done").length;
1882
+ const active = tasks.filter((t) => t.state === "active").length;
1883
+ console.log(c.muted(`
1884
+ ${done}/${tasks.length} done${active ? `, ${active} active` : ""}`));
1885
+ }
1886
+ async function taskUpdate(config, identifier, taskText, state, json) {
1887
+ if (!["active", "done", "pending"].includes(state)) {
1888
+ console.error(c.rose(`Invalid state "${state}". Must be: active, done, pending`));
1889
+ process.exit(1);
1890
+ }
1891
+ const spin = spinner(`Setting task to ${state}\u2026`);
1892
+ spin.start();
1893
+ const id = await resolveId(config, identifier);
1894
+ const doc = await apiFetch(config, `/documents/${id}/content`);
1895
+ const tasks = parseTasks(doc.md_source);
1896
+ const needle = taskText.toLowerCase();
1897
+ const match = tasks.find((t) => t.text.toLowerCase().includes(needle));
1898
+ if (!match) {
1899
+ spin.stop();
1900
+ console.error(c.rose(`No task matching "${taskText}" found`));
1901
+ process.exit(1);
1902
+ }
1903
+ await apiFetch(config, `/documents/${id}/task/${match.index}`, {
1904
+ method: "PATCH",
1905
+ headers: { "Content-Type": "application/json" },
1906
+ body: JSON.stringify({ state })
1907
+ });
1908
+ spin.stop();
1909
+ if (json) {
1910
+ console.log(JSON.stringify({ ok: true, task_index: match.index, task_text: match.text, state }));
1911
+ return;
1912
+ }
1913
+ const stateLabel = { done: c.teal("done"), active: c.amber("active"), pending: c.muted("pending") };
1914
+ console.log(`${stateLabel[state]} ${match.text}`);
1915
+ }
1916
+ function parseTasks(content) {
1917
+ const lines = content.split("\n");
1918
+ const taskRe = /^[\t ]*[-*+]\s+\[([xX~ ])\]\s*(.*)/;
1919
+ const fenceRe = /^\s*(`{3,}|~{3,})/;
1920
+ let inFence = false;
1921
+ const tasks = [];
1922
+ let count = 0;
1923
+ const charToState = { x: "done", X: "done", "~": "active", " ": "pending" };
1924
+ for (const line of lines) {
1925
+ if (fenceRe.test(line)) {
1926
+ inFence = !inFence;
1927
+ continue;
1928
+ }
1929
+ if (inFence) continue;
1930
+ const m = line.match(taskRe);
1931
+ if (m) {
1932
+ tasks.push({ index: count, state: charToState[m[1]] || "pending", text: m[2].trim() });
1933
+ count++;
1934
+ }
1935
+ }
1936
+ return tasks;
1937
+ }
1938
+
939
1939
  // src/cli.ts
940
- var VERSION = true ? "0.2.3" : "0.0.0-dev";
1940
+ var VERSION = true ? "0.3.0" : "0.0.0-dev";
941
1941
  function brandedHelp() {
942
1942
  return `
943
1943
  ${c.amber("artyfax")} ${c.muted(`v${VERSION}`)} \u2014 your personal document library
944
1944
 
945
- ${c.bright("Usage:")} arty <command> [options]
1945
+ ${c.bright("Usage:")} artyfax <command> [options]
1946
+ ${c.muted("aliases:")} arty <command> \xB7 ax <command>
946
1947
 
947
1948
  ${c.bright("Documents")}
948
1949
  save <file> Save a document (markdown or HTML)
@@ -956,20 +1957,46 @@ ${c.bright("Documents")}
956
1957
  open <slug> Open document in browser
957
1958
  secure <slug> Encrypt a document
958
1959
  unsecure <slug> Remove encryption
1960
+ cover <doc> <file> Set a document's cover image from a local file
1961
+ image <doc> <file> Upload a local image, print the markdown to embed
959
1962
 
960
1963
  ${c.bright("Sub-resources")}
961
1964
  share create <slug> Create a share link
962
1965
  share list [slug] List shares
963
1966
  share revoke <hash> Revoke a share link
964
1967
  cat list List categories
965
- tag list List tags
1968
+ tag list List tags (grouped by namespace)
1969
+ tag delete <tag> Remove a tag from all docs, annotations, snippets
966
1970
  version list <slug> Version history
967
1971
  version restore <slug> Restore a previous version
968
1972
 
969
1973
  ${c.bright("Annotations")}
970
- note list <slug> List annotations on a document
971
- note add <slug> Add an annotation
972
- note search <query> Search annotations
1974
+ annotation list <slug> List a document's annotations (--include-recipients, --since)
1975
+ annotation search <q> Search annotations
1976
+
1977
+ ${c.bright("Snippets")}
1978
+ snippets list List snippets (filter --type --tag --starred)
1979
+ snippets search <query> Full-text search across snippets
1980
+ snippets show <id> Show a snippet's full content
1981
+ snippets new Create a snippet (--text, --file, --image)
1982
+ snippets delete <id> Delete a snippet
1983
+ snippets star <id> Star a snippet
1984
+ snippets unstar <id> Unstar a snippet
1985
+
1986
+ ${c.bright("Work")}
1987
+ work Board summary (kanban overview)
1988
+ work list List work items (--project, --agent, --status, --type)
1989
+ work next Pick next Up Next item (--agent)
1990
+ work start <id> Mark In Progress
1991
+ work done <id> Mark Done
1992
+ work status <id> <s> Set arbitrary status ("none" removes from board)
1993
+ work create "title" Quick-create a work item (--project, --type, --agent)
1994
+
1995
+ ${c.bright("Projects")}
1996
+ project list List projects
1997
+ project create "name" Create a project (--prefix XX)
1998
+ init [prefix] Generate or refresh ARTYFAX.md for a project board
1999
+ workspace list List workspaces
973
2000
 
974
2001
  ${c.bright("Tools")}
975
2002
  doctor Verify CLI setup
@@ -992,10 +2019,10 @@ ${c.bright("Environment")}
992
2019
  `.trim();
993
2020
  }
994
2021
  var COMMAND_HELP = {
995
- save: `${c.amber("arty save")} \u2014 save a document
2022
+ save: `${c.amber("artyfax save")} \u2014 save a document
996
2023
 
997
- ${c.bright("Usage:")} arty save <file> [options]
998
- arty save --url <url> [options]
2024
+ ${c.bright("Usage:")} artyfax save <file> [options]
2025
+ artyfax save --url <url> [options]
999
2026
 
1000
2027
  ${c.bright("Options:")}
1001
2028
  --url <url> Save from URL (server-side extraction)
@@ -1011,23 +2038,23 @@ ${c.bright("Options:")}
1011
2038
  --json JSON output
1012
2039
 
1013
2040
  ${c.bright("Examples:")}
1014
- arty save notes.md --category inbox
1015
- arty save --url https://example.com/article --category articles
1016
- arty save report.md --secure --category reports`,
1017
- read: `${c.amber("arty read")} \u2014 read document content
2041
+ artyfax save notes.md --category inbox
2042
+ artyfax save --url https://example.com/article --category articles
2043
+ artyfax save report.md --secure --category reports`,
2044
+ read: `${c.amber("artyfax read")} \u2014 read document content
1018
2045
 
1019
- ${c.bright("Usage:")} arty read <slug>
2046
+ ${c.bright("Usage:")} artyfax read <slug>
1020
2047
 
1021
2048
  Outputs the document's markdown source to stdout. Handles E2EE
1022
2049
  decryption automatically (prompts for passphrase if needed).
1023
2050
 
1024
2051
  ${c.bright("Examples:")}
1025
- arty read inbox/my-doc
1026
- arty read my-doc # partial slug match
1027
- arty read inbox/my-doc > out.md # pipe to file`,
1028
- list: `${c.amber("arty list")} \u2014 list documents
2052
+ artyfax read inbox/my-doc
2053
+ artyfax read my-doc # partial slug match
2054
+ artyfax read inbox/my-doc > out.md # pipe to file`,
2055
+ list: `${c.amber("artyfax list")} \u2014 list documents
1029
2056
 
1030
- ${c.bright("Usage:")} arty list [options]
2057
+ ${c.bright("Usage:")} artyfax list [options]
1031
2058
 
1032
2059
  ${c.bright("Options:")}
1033
2060
  --category <name> Filter by category
@@ -1035,43 +2062,46 @@ ${c.bright("Options:")}
1035
2062
  --offset <n> Skip first N results
1036
2063
  --archived Include archived documents
1037
2064
  --parent-id <id> Filter by parent document
2065
+ --tag <tag> Filter to documents carrying this exact tag
1038
2066
  --json JSON output
1039
2067
 
1040
2068
  ${c.bright("Examples:")}
1041
- arty list --category inbox --limit 10
1042
- arty list --archived --json`,
1043
- get: `${c.amber("arty get")} \u2014 document metadata
2069
+ artyfax list --category inbox --limit 10
2070
+ artyfax list --tag role:design
2071
+ artyfax list --archived --json`,
2072
+ get: `${c.amber("artyfax get")} \u2014 document metadata
1044
2073
 
1045
- ${c.bright("Usage:")} arty get <slug>
2074
+ ${c.bright("Usage:")} artyfax get <slug>
1046
2075
 
1047
2076
  Outputs full document metadata as JSON. For scripting and inspection.
1048
2077
 
1049
2078
  ${c.bright("Examples:")}
1050
- arty get inbox/my-doc
1051
- arty get my-doc | jq .category`,
1052
- search: `${c.amber("arty search")} \u2014 full-text search
2079
+ artyfax get inbox/my-doc
2080
+ artyfax get my-doc | jq .category`,
2081
+ search: `${c.amber("artyfax search")} \u2014 full-text search
1053
2082
 
1054
- ${c.bright("Usage:")} arty search <query> [--json]
2083
+ ${c.bright("Usage:")} artyfax search <query> [--json]
1055
2084
 
1056
2085
  ${c.bright("Examples:")}
1057
- arty search "deployment guide"
1058
- arty search cloudflare --json`,
1059
- update: `${c.amber("arty update")} \u2014 push updated content
2086
+ artyfax search "deployment guide"
2087
+ artyfax search cloudflare --json`,
2088
+ update: `${c.amber("artyfax update")} \u2014 push updated content
1060
2089
 
1061
- ${c.bright("Usage:")} arty update <slug> [file]
1062
- cat file.md | arty update <slug>
2090
+ ${c.bright("Usage:")} artyfax update <slug> [file]
2091
+ cat file.md | artyfax update <slug>
1063
2092
 
1064
2093
  Reads from file or stdin. Auto-encrypts if the target is a secure document.
1065
2094
 
1066
2095
  ${c.bright("Examples:")}
1067
- arty update inbox/my-doc updated.md
1068
- echo "# New content" | arty update inbox/my-doc`,
1069
- metadata: `${c.amber("arty metadata")} \u2014 update document metadata
2096
+ artyfax update inbox/my-doc updated.md
2097
+ echo "# New content" | artyfax update inbox/my-doc`,
2098
+ metadata: `${c.amber("artyfax metadata")} \u2014 update document metadata
1070
2099
 
1071
- ${c.bright("Usage:")} arty metadata <slug> [options]
2100
+ ${c.bright("Usage:")} artyfax metadata <slug> [options]
1072
2101
 
1073
2102
  ${c.bright("Options:")}
1074
2103
  --category <name> Move to category
2104
+ --slug <cat/slug> Rename the URL slug (cosmetic; URLs resolve by id)
1075
2105
  --tags <t1,t2> Set tags (comma-separated)
1076
2106
  --title <name> Update title
1077
2107
  --theme <name> Set theme
@@ -1080,61 +2110,95 @@ ${c.bright("Options:")}
1080
2110
  --json JSON output
1081
2111
 
1082
2112
  ${c.bright("Examples:")}
1083
- arty metadata inbox/my-doc --category reports --tags "q1,finance"
1084
- arty metadata my-doc --archived`,
1085
- delete: `${c.amber("arty delete")} \u2014 delete a document
2113
+ artyfax metadata inbox/my-doc --category reports --tags "q1,finance"
2114
+ artyfax metadata my-doc --slug plans/rename-safe-urls
2115
+ artyfax metadata my-doc --archived`,
2116
+ cover: `${c.amber("artyfax cover")} \u2014 set a document's cover image
2117
+
2118
+ ${c.bright("Usage:")} artyfax cover <doc> <image-file> [options]
2119
+
2120
+ Uploads a local image and sets it as the document's cover. Works for any
2121
+ document (pass a doc id, slug, or work id). Unlike the MCP set_cover_image
2122
+ tool, this carries a full-quality local image with no transport truncation.
1086
2123
 
1087
- ${c.bright("Usage:")} arty delete <slug> [--yes] [--json]
2124
+ ${c.bright("Options:")}
2125
+ --alt "..." Alt text for the cover image
2126
+ --json JSON output
2127
+
2128
+ ${c.bright("Notes:")}
2129
+ Accepts png/jpg/webp/gif/avif/svg up to 5 MB. Covers render best at
2130
+ 1200\xD7630 (the hero + social card shape).
2131
+
2132
+ ${c.bright("Examples:")}
2133
+ artyfax cover p8KV4dvrgJTA ./hero.png
2134
+ artyfax cover inbox/my-doc ./hero.png --alt "Release banner"`,
2135
+ image: `${c.amber("artyfax image")} \u2014 upload an inline image to a document
2136
+
2137
+ ${c.bright("Usage:")} artyfax image <doc> <image-file> [options]
2138
+
2139
+ Uploads a local image into the document's image store and prints the
2140
+ markdown snippet to paste into the body. Works for any document (doc id,
2141
+ slug, or work id).
2142
+
2143
+ ${c.bright("Options:")}
2144
+ --alt "..." Alt text for the image
2145
+ --json JSON output
2146
+
2147
+ ${c.bright("Examples:")}
2148
+ artyfax image p8KV4dvrgJTA ./screenshot.png
2149
+ artyfax image inbox/my-doc ./diagram.png --alt "Architecture"`,
2150
+ delete: `${c.amber("artyfax delete")} \u2014 delete a document
2151
+
2152
+ ${c.bright("Usage:")} artyfax delete <slug> [--yes] [--json]
1088
2153
 
1089
2154
  Prompts for confirmation unless --yes is passed.
1090
2155
 
1091
2156
  ${c.bright("Examples:")}
1092
- arty delete inbox/old-doc
1093
- arty delete inbox/old-doc --yes # skip confirmation`,
1094
- open: `${c.amber("arty open")} \u2014 open in browser
2157
+ artyfax delete inbox/old-doc
2158
+ artyfax delete inbox/old-doc --yes # skip confirmation`,
2159
+ open: `${c.amber("artyfax open")} \u2014 open in browser
1095
2160
 
1096
- ${c.bright("Usage:")} arty open <slug>
2161
+ ${c.bright("Usage:")} artyfax open <slug>
1097
2162
 
1098
2163
  Opens the document URL in your default browser.`,
1099
- share: `${c.amber("arty share")} \u2014 manage share links
2164
+ share: `${c.amber("artyfax share")} \u2014 manage share links
1100
2165
 
1101
- ${c.bright("Usage:")} arty share create <slug> Create a share link
1102
- arty share list [slug] List all shares (or per-document)
1103
- arty share revoke <hash> Revoke a share link
2166
+ ${c.bright("Usage:")} artyfax share create <slug> Create a share link
2167
+ artyfax share list [slug] List all shares (or per-document)
2168
+ artyfax share revoke <hash> Revoke a share link
1104
2169
 
1105
2170
  ${c.bright("Examples:")}
1106
- arty share create inbox/my-doc
1107
- arty share list --json
1108
- arty share revoke SnP5UJUhP6 --yes`,
1109
- version: `${c.amber("arty version")} \u2014 version history
2171
+ artyfax share create inbox/my-doc
2172
+ artyfax share list --json
2173
+ artyfax share revoke SnP5UJUhP6 --yes`,
2174
+ version: `${c.amber("artyfax version")} \u2014 version history
1110
2175
 
1111
- ${c.bright("Usage:")} arty version list <slug> Show version history
1112
- arty version restore <slug> Restore a previous version
2176
+ ${c.bright("Usage:")} artyfax version list <slug> Show version history
2177
+ artyfax version restore <slug> Restore a previous version
1113
2178
 
1114
2179
  ${c.bright("Examples:")}
1115
- arty version list inbox/my-doc
1116
- arty version restore inbox/my-doc --yes`,
1117
- note: `${c.amber("arty note")} \u2014 annotations
2180
+ artyfax version list inbox/my-doc
2181
+ artyfax version restore inbox/my-doc --yes`,
2182
+ annotation: `${c.amber("artyfax annotation")} \u2014 read a document's annotations (highlights, underlines, strikethroughs, notes, comments, redactions)
1118
2183
 
1119
- ${c.bright("Usage:")} arty note list <slug> List annotations
1120
- arty note add <slug> Add a note (--text or stdin)
1121
- arty note search <query> Search annotations
2184
+ ${c.bright("Usage:")} artyfax annotation list <slug> List annotations (--include-recipients, --since <iso>)
2185
+ artyfax annotation search <query> Search annotations
1122
2186
 
1123
2187
  ${c.bright("Examples:")}
1124
- arty note list inbox/my-doc
1125
- arty note add inbox/my-doc --text "Needs review"
1126
- echo "Reviewed and approved" | arty note add inbox/my-doc
1127
- arty note search "review"`,
1128
- doctor: `${c.amber("arty doctor")} \u2014 verify CLI setup
2188
+ artyfax annotation list inbox/my-doc
2189
+ artyfax annotation list inbox/my-doc --include-recipients
2190
+ artyfax annotation list inbox/my-doc --since 2026-05-01T00:00:00Z
2191
+ artyfax annotation search "review"`,
2192
+ doctor: `${c.amber("artyfax doctor")} \u2014 verify CLI setup
1129
2193
 
1130
2194
  Checks API key, endpoint, connectivity, E2EE status, and skill installation.
1131
2195
 
1132
- ${c.bright("Usage:")} arty doctor [--json]`,
1133
- skill: `${c.amber("arty skill")} \u2014 Claude Code skill management
2196
+ ${c.bright("Usage:")} artyfax doctor [--json]`,
2197
+ skill: `${c.amber("artyfax skill")} \u2014 Claude Code skill management
1134
2198
 
1135
- ${c.bright("Usage:")} arty skill install [--project] Install Artyfax skill
1136
- arty skill status Check installation
1137
- arty skill update Update to latest version`
2199
+ ${c.bright("Usage:")} artyfax skill install [--project] Install Artyfax skill
2200
+ artyfax skill status Check installation
2201
+ artyfax skill update Update to latest version`
1138
2202
  };
1139
2203
  function parseArgs(args) {
1140
2204
  const flags = {};
@@ -1144,13 +2208,22 @@ function parseArgs(args) {
1144
2208
  for (let i = 0; i < args.length; i++) {
1145
2209
  const arg = args[i];
1146
2210
  if (arg.startsWith("--")) {
1147
- const key = arg.slice(2);
1148
- const next = args[i + 1];
1149
- if (next && !next.startsWith("-")) {
1150
- flags[key] = next;
1151
- i++;
2211
+ const body = arg.slice(2);
2212
+ const eq = body.indexOf("=");
2213
+ const key = eq === -1 ? body : body.slice(0, eq);
2214
+ if (key === "") {
2215
+ continue;
2216
+ }
2217
+ if (eq !== -1) {
2218
+ flags[key] = body.slice(eq + 1);
1152
2219
  } else {
1153
- flags[key] = true;
2220
+ const next = args[i + 1];
2221
+ if (next !== void 0 && !next.startsWith("-")) {
2222
+ flags[key] = next;
2223
+ i++;
2224
+ } else {
2225
+ flags[key] = true;
2226
+ }
1154
2227
  }
1155
2228
  } else if (arg.startsWith("-")) {
1156
2229
  flags[arg.slice(1)] = true;
@@ -1164,7 +2237,7 @@ function parseArgs(args) {
1164
2237
  }
1165
2238
  return { command, subcommand, positional, flags };
1166
2239
  }
1167
- var SUB_RESOURCES = /* @__PURE__ */ new Set(["share", "cat", "tag", "version", "note", "skill"]);
2240
+ var SUB_RESOURCES = /* @__PURE__ */ new Set(["share", "cat", "tag", "version", "annotation", "skill", "snippets", "work", "project", "workspace"]);
1168
2241
  function isSubResource(cmd) {
1169
2242
  return SUB_RESOURCES.has(cmd);
1170
2243
  }
@@ -1190,6 +2263,7 @@ async function main() {
1190
2263
  process.exit(0);
1191
2264
  }
1192
2265
  const KNOWN_COMMANDS = /* @__PURE__ */ new Set([
2266
+ "init",
1193
2267
  "save",
1194
2268
  "read",
1195
2269
  "list",
@@ -1201,16 +2275,24 @@ async function main() {
1201
2275
  "unsecure",
1202
2276
  "update",
1203
2277
  "metadata",
2278
+ "cover",
2279
+ "image",
1204
2280
  "share",
1205
2281
  "cat",
1206
2282
  "tag",
1207
2283
  "version",
1208
- "note",
2284
+ "annotation",
1209
2285
  "skill",
1210
- "doctor"
2286
+ "snippets",
2287
+ "doctor",
2288
+ "work",
2289
+ "project",
2290
+ "workspace",
2291
+ "task",
2292
+ "tasks"
1211
2293
  ]);
1212
2294
  if (!KNOWN_COMMANDS.has(command)) {
1213
- error(`Unknown command: ${command}`, `Run \`arty --help\` for available commands`);
2295
+ error(`Unknown command: ${command}`, `Run \`artyfax--help\` for available commands`);
1214
2296
  }
1215
2297
  if (command === "skill") {
1216
2298
  switch (subcommand) {
@@ -1224,7 +2306,7 @@ async function main() {
1224
2306
  await skillUpdate();
1225
2307
  break;
1226
2308
  default:
1227
- error(`Unknown skill subcommand: ${subcommand || "(none)"}`, "Usage: arty skill <install|status|update>");
2309
+ error(`Unknown skill subcommand: ${subcommand || "(none)"}`, "Usage: artyfax skill <install|status|update>");
1228
2310
  }
1229
2311
  process.exit(0);
1230
2312
  }
@@ -1234,11 +2316,15 @@ async function main() {
1234
2316
  process.exit(0);
1235
2317
  }
1236
2318
  switch (command) {
2319
+ case "init": {
2320
+ await init(config, positional[0]);
2321
+ break;
2322
+ }
1237
2323
  case "save": {
1238
2324
  const fileOrUrl = positional[0];
1239
2325
  const url = flags.url;
1240
2326
  if (!fileOrUrl && !url) {
1241
- error("Missing file argument", "Usage: arty save <file> [--secure] [--category <name>]");
2327
+ error("Missing file argument", "Usage: artyfax save <file> [--secure] [--category <name>]");
1242
2328
  }
1243
2329
  if (url) {
1244
2330
  await save(config, {
@@ -1263,7 +2349,7 @@ async function main() {
1263
2349
  }
1264
2350
  let content;
1265
2351
  try {
1266
- content = readFileSync2(resolve2(fileOrUrl), "utf-8");
2352
+ content = readFileSync5(resolve5(fileOrUrl), "utf-8");
1267
2353
  } catch {
1268
2354
  error(`File not found: ${fileOrUrl}`);
1269
2355
  }
@@ -1285,7 +2371,7 @@ async function main() {
1285
2371
  }
1286
2372
  case "read": {
1287
2373
  const slug = positional[0];
1288
- if (!slug) error("Missing slug argument", "Usage: arty read <slug>");
2374
+ if (!slug) error("Missing slug argument", "Usage: artyfax read <slug>");
1289
2375
  await read(config, slug, !!flags.secure);
1290
2376
  break;
1291
2377
  }
@@ -1306,57 +2392,60 @@ async function main() {
1306
2392
  offset,
1307
2393
  archived: !!flags.archived,
1308
2394
  parentId: flags["parent-id"],
2395
+ tag: flags.tag,
2396
+ workspace: flags.workspace,
1309
2397
  json
1310
2398
  });
1311
2399
  break;
1312
2400
  }
1313
2401
  case "get": {
1314
2402
  const slug = positional[0];
1315
- if (!slug) error("Missing slug argument", "Usage: arty get <slug>");
2403
+ if (!slug) error("Missing slug argument", "Usage: artyfax get <slug>");
1316
2404
  await get(config, slug);
1317
2405
  break;
1318
2406
  }
1319
2407
  case "search": {
1320
2408
  const query = positional.join(" ");
1321
- if (!query) error("Missing search query", "Usage: arty search <query>");
1322
- await search(config, query, json);
2409
+ if (!query) error("Missing search query", "Usage: artyfax search <query>");
2410
+ await search(config, query, json, flags.workspace);
1323
2411
  break;
1324
2412
  }
1325
2413
  case "delete": {
1326
2414
  const slug = positional[0];
1327
- if (!slug) error("Missing slug argument", "Usage: arty delete <slug>");
2415
+ if (!slug) error("Missing slug argument", "Usage: artyfax delete <slug>");
1328
2416
  await del(config, slug, yes, json);
1329
2417
  break;
1330
2418
  }
1331
2419
  case "open": {
1332
2420
  const slug = positional[0];
1333
- if (!slug) error("Missing slug argument", "Usage: arty open <slug>");
2421
+ if (!slug) error("Missing slug argument", "Usage: artyfax open <slug>");
1334
2422
  await open(config, slug);
1335
2423
  break;
1336
2424
  }
1337
2425
  case "secure": {
1338
2426
  const slug = positional[0];
1339
- if (!slug) error("Missing slug argument", "Usage: arty secure <slug>");
2427
+ if (!slug) error("Missing slug argument", "Usage: artyfax secure <slug>");
1340
2428
  await secure(config, slug);
1341
2429
  break;
1342
2430
  }
1343
2431
  case "unsecure": {
1344
2432
  const slug = positional[0];
1345
- if (!slug) error("Missing slug argument", "Usage: arty unsecure <slug>");
2433
+ if (!slug) error("Missing slug argument", "Usage: artyfax unsecure <slug>");
1346
2434
  await unsecure(config, slug);
1347
2435
  break;
1348
2436
  }
1349
2437
  case "update": {
1350
2438
  const slug = positional[0];
1351
- if (!slug) error("Missing slug argument", "Usage: arty update <slug> [file] (reads from stdin if no file)");
2439
+ if (!slug) error("Missing slug argument", "Usage: artyfax update <slug> [file] (reads from stdin if no file)");
1352
2440
  await update(config, slug, positional[1], json);
1353
2441
  break;
1354
2442
  }
1355
2443
  case "metadata": {
1356
2444
  const slug = positional[0];
1357
- if (!slug) error("Missing slug argument", "Usage: arty metadata <slug> --category <name> --tags <t1,t2>");
2445
+ if (!slug) error("Missing slug argument", "Usage: artyfax metadata <slug> --category <name> --tags <t1,t2>");
1358
2446
  await metadata(config, slug, {
1359
2447
  category: flags.category,
2448
+ slug: flags.slug,
1360
2449
  tags: flags.tags,
1361
2450
  title: flags.title,
1362
2451
  theme: flags.theme,
@@ -1366,11 +2455,25 @@ async function main() {
1366
2455
  });
1367
2456
  break;
1368
2457
  }
2458
+ case "cover": {
2459
+ const slug = positional[0];
2460
+ const file = positional[1];
2461
+ if (!slug || !file) error("Missing arguments", 'Usage: artyfax cover <doc> <image-file> [--alt "..."]');
2462
+ await setCover(config, slug, file, { alt: flags.alt, json });
2463
+ break;
2464
+ }
2465
+ case "image": {
2466
+ const slug = positional[0];
2467
+ const file = positional[1];
2468
+ if (!slug || !file) error("Missing arguments", 'Usage: artyfax image <doc> <image-file> [--alt "..."]');
2469
+ await addImage(config, slug, file, { alt: flags.alt, json });
2470
+ break;
2471
+ }
1369
2472
  case "share": {
1370
2473
  switch (subcommand) {
1371
2474
  case "create": {
1372
2475
  const slug = positional[0];
1373
- if (!slug) error("Missing slug argument", "Usage: arty share create <slug>");
2476
+ if (!slug) error("Missing slug argument", "Usage: artyfax share create <slug>");
1374
2477
  await shareCreate(config, slug, json);
1375
2478
  break;
1376
2479
  }
@@ -1379,77 +2482,254 @@ async function main() {
1379
2482
  break;
1380
2483
  case "revoke": {
1381
2484
  const hash = positional[0];
1382
- if (!hash) error("Missing share hash", "Usage: arty share revoke <hash>");
2485
+ if (!hash) error("Missing share hash", "Usage: artyfax share revoke <hash>");
1383
2486
  await shareRevoke(config, hash, yes, json);
1384
2487
  break;
1385
2488
  }
1386
2489
  default:
1387
- error(`Unknown share subcommand: ${subcommand || "(none)"}`, "Usage: arty share <create|list|revoke>");
2490
+ error(`Unknown share subcommand: ${subcommand || "(none)"}`, "Usage: artyfax share <create|list|revoke>");
1388
2491
  }
1389
2492
  break;
1390
2493
  }
1391
2494
  case "cat": {
1392
- if (subcommand !== "list") error(`Unknown cat subcommand: ${subcommand || "(none)"}`, "Usage: arty cat list");
2495
+ if (subcommand !== "list") error(`Unknown cat subcommand: ${subcommand || "(none)"}`, "Usage: artyfax cat list");
1393
2496
  await catList(config, json);
1394
2497
  break;
1395
2498
  }
1396
2499
  case "tag": {
1397
- if (subcommand !== "list") error(`Unknown tag subcommand: ${subcommand || "(none)"}`, "Usage: arty tag list");
1398
- await tagList(config, json);
2500
+ switch (subcommand) {
2501
+ case "list":
2502
+ await tagList(config, json);
2503
+ break;
2504
+ case "delete": {
2505
+ const tag = positional[0];
2506
+ if (!tag) error("Missing tag argument", "Usage: artyfax tag delete <tag>");
2507
+ await tagDelete(config, tag, json);
2508
+ break;
2509
+ }
2510
+ default:
2511
+ error(`Unknown tag subcommand: ${subcommand || "(none)"}`, "Usage: artyfax tag list | artyfax tag delete <tag>");
2512
+ }
1399
2513
  break;
1400
2514
  }
1401
2515
  case "version": {
1402
2516
  switch (subcommand) {
1403
2517
  case "list": {
1404
2518
  const slug = positional[0];
1405
- if (!slug) error("Missing slug argument", "Usage: arty version list <slug>");
2519
+ if (!slug) error("Missing slug argument", "Usage: artyfax version list <slug>");
1406
2520
  await versionList(config, slug, json);
1407
2521
  break;
1408
2522
  }
1409
2523
  case "restore": {
1410
2524
  const slug = positional[0];
1411
- if (!slug) error("Missing slug argument", "Usage: arty version restore <slug>");
2525
+ if (!slug) error("Missing slug argument", "Usage: artyfax version restore <slug>");
1412
2526
  await versionRestore(config, slug, yes, json);
1413
2527
  break;
1414
2528
  }
1415
2529
  default:
1416
- error(`Unknown version subcommand: ${subcommand || "(none)"}`, "Usage: arty version <list|restore>");
2530
+ error(`Unknown version subcommand: ${subcommand || "(none)"}`, "Usage: artyfax version <list|restore>");
1417
2531
  }
1418
2532
  break;
1419
2533
  }
1420
- case "note": {
2534
+ case "annotation": {
1421
2535
  switch (subcommand) {
1422
2536
  case "list": {
1423
2537
  const slug = positional[0];
1424
- if (!slug) error("Missing slug argument", "Usage: arty note list <slug>");
1425
- await noteList(config, slug, json);
2538
+ if (!slug) error("Missing slug argument", "Usage: artyfax annotation list <slug> [--include-recipients] [--since <iso>]");
2539
+ await annotationList(config, slug, json, {
2540
+ includeRecipients: Boolean(flags["include-recipients"]),
2541
+ since: typeof flags.since === "string" ? flags.since : void 0
2542
+ });
1426
2543
  break;
1427
2544
  }
1428
- case "add": {
1429
- const slug = positional[0];
1430
- if (!slug) error("Missing slug argument", 'Usage: arty note add <slug> --text "..."');
1431
- await noteAdd(config, slug, flags.text, json);
2545
+ case "search": {
2546
+ const query = positional.join(" ");
2547
+ if (!query) error("Missing search query", "Usage: artyfax annotation search <query>");
2548
+ await annotationSearch(config, query, json);
2549
+ break;
2550
+ }
2551
+ default:
2552
+ error(`Unknown annotation subcommand: ${subcommand || "(none)"}`, "Usage: artyfax annotation <list|search>");
2553
+ }
2554
+ break;
2555
+ }
2556
+ case "snippets": {
2557
+ const tagsCsv = flags.tags;
2558
+ const parsedTags = tagsCsv ? tagsCsv.split(",").map((t) => t.trim()).filter(Boolean) : void 0;
2559
+ switch (subcommand) {
2560
+ case "list":
2561
+ case void 0: {
2562
+ await snippetList(config, {
2563
+ type: flags.type,
2564
+ starred: !!flags.starred,
2565
+ tag: flags.tag,
2566
+ limit: flags.limit ? Number(flags.limit) : void 0
2567
+ }, json);
1432
2568
  break;
1433
2569
  }
1434
2570
  case "search": {
1435
2571
  const query = positional.join(" ");
1436
- if (!query) error("Missing search query", "Usage: arty note search <query>");
1437
- await noteSearch(config, query, json);
2572
+ if (!query) error("Missing search query", "Usage: artyfax snippets search <query>");
2573
+ await snippetSearch(config, query, {
2574
+ type: flags.type,
2575
+ limit: flags.limit ? Number(flags.limit) : void 0
2576
+ }, json);
2577
+ break;
2578
+ }
2579
+ case "show": {
2580
+ const id = positional[0];
2581
+ if (!id) error("Missing snippet id", "Usage: artyfax snippets show <id>");
2582
+ await snippetShow(config, id, json);
2583
+ break;
2584
+ }
2585
+ case "new": {
2586
+ await snippetNew(config, {
2587
+ image: flags.image,
2588
+ type: flags.type ?? void 0,
2589
+ text: flags.text,
2590
+ file: flags.file,
2591
+ language: flags.language,
2592
+ note: flags.note,
2593
+ sourceUrl: flags["source-url"],
2594
+ sourceTitle: flags["source-title"],
2595
+ sourceSiteName: flags["source-site-name"],
2596
+ sourceAuthor: flags["source-author"],
2597
+ alt: flags.alt,
2598
+ tags: parsedTags,
2599
+ starred: !!flags.starred
2600
+ }, json);
2601
+ break;
2602
+ }
2603
+ case "delete": {
2604
+ const id = positional[0];
2605
+ if (!id) error("Missing snippet id", "Usage: artyfax snippets delete <id>");
2606
+ await snippetDelete(config, id, json);
2607
+ break;
2608
+ }
2609
+ case "star": {
2610
+ const id = positional[0];
2611
+ if (!id) error("Missing snippet id", "Usage: artyfax snippets star <id>");
2612
+ await snippetStar(config, id, true, json);
2613
+ break;
2614
+ }
2615
+ case "unstar": {
2616
+ const id = positional[0];
2617
+ if (!id) error("Missing snippet id", "Usage: artyfax snippets unstar <id>");
2618
+ await snippetStar(config, id, false, json);
2619
+ break;
2620
+ }
2621
+ default:
2622
+ error(`Unknown snippets subcommand: ${subcommand}`, "Usage: artyfax snippets <list|search|show|new|delete|star|unstar>");
2623
+ }
2624
+ break;
2625
+ }
2626
+ case "task":
2627
+ case "tasks": {
2628
+ const id = positional[0];
2629
+ if (!id) error("Missing document or work ID", 'Usage: artyfax tasks <id>\n artyfax task <id> "task text" <active|done|pending>');
2630
+ if (positional.length <= 1) {
2631
+ await taskList(config, id, json);
2632
+ } else {
2633
+ const state = positional[positional.length - 1];
2634
+ const taskText = positional.slice(1, -1).join(" ");
2635
+ if (!taskText) error("Missing task text", 'Usage: artyfax task <id> "task text" <active|done|pending>');
2636
+ await taskUpdate(config, id, taskText, state, json);
2637
+ }
2638
+ break;
2639
+ }
2640
+ case "work": {
2641
+ switch (subcommand) {
2642
+ case "":
2643
+ await workBoard(config, json);
2644
+ break;
2645
+ case "list":
2646
+ await workList(config, json, flags);
2647
+ break;
2648
+ case "next":
2649
+ await workNext(config, json, flags.agent);
2650
+ break;
2651
+ case "start": {
2652
+ const id = positional[0];
2653
+ if (!id) error("Missing work item ID", "Usage: artyfax work start <id>");
2654
+ await workSetStatus(config, id, "in_progress", json);
2655
+ break;
2656
+ }
2657
+ case "done": {
2658
+ const id = positional[0];
2659
+ if (!id) error("Missing work item ID", "Usage: artyfax work done <id>");
2660
+ await workSetStatus(config, id, "done", json);
2661
+ break;
2662
+ }
2663
+ case "status": {
2664
+ const id = positional[0];
2665
+ const status = positional[1];
2666
+ if (!id || !status) error("Missing arguments", "Usage: artyfax work status <id> <status>");
2667
+ await workSetStatus(config, id, status, json);
2668
+ break;
2669
+ }
2670
+ case "create": {
2671
+ const title = positional.join(" ");
2672
+ if (!title) error("Missing title", 'Usage: artyfax work create "Title"');
2673
+ await workCreate(config, title, flags, json);
1438
2674
  break;
1439
2675
  }
1440
2676
  default:
1441
- error(`Unknown note subcommand: ${subcommand || "(none)"}`, "Usage: arty note <list|add|search>");
2677
+ error(`Unknown work subcommand: ${subcommand}`, "Usage: artyfax work <list|next|start|done|status|create>");
2678
+ }
2679
+ break;
2680
+ }
2681
+ case "project": {
2682
+ switch (subcommand) {
2683
+ case "list":
2684
+ case "":
2685
+ await projectList(config, json);
2686
+ break;
2687
+ case "create": {
2688
+ const name = positional.join(" ");
2689
+ if (!name) error("Missing project name", 'Usage: artyfax project create "Name" --prefix XX');
2690
+ const prefix = flags.prefix;
2691
+ if (!prefix) error("Missing --prefix", 'Usage: artyfax project create "Name" --prefix XX');
2692
+ const wsFlag = flags.workspaces ?? flags.workspace;
2693
+ const workspaces = wsFlag ? wsFlag.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
2694
+ await projectCreate(config, name, prefix, flags.description, json, workspaces);
2695
+ break;
2696
+ }
2697
+ default:
2698
+ error(`Unknown project subcommand: ${subcommand}`, "Usage: artyfax project <list|create>");
2699
+ }
2700
+ break;
2701
+ }
2702
+ case "workspace": {
2703
+ switch (subcommand) {
2704
+ case "list":
2705
+ case "":
2706
+ await workspaceList(config, json);
2707
+ break;
2708
+ default:
2709
+ error(`Unknown workspace subcommand: ${subcommand}`, "Usage: artyfax workspace <list>");
1442
2710
  }
1443
2711
  break;
1444
2712
  }
1445
2713
  default:
1446
- error(`Unknown command: ${command}`, `Run \`arty --help\` for available commands`);
2714
+ error(`Unknown command: ${command}`, `Run \`artyfax--help\` for available commands`);
1447
2715
  }
1448
2716
  }
1449
- main().catch((e) => {
1450
- console.error(c.rose(e.message || String(e)));
1451
- process.exit(1);
1452
- });
2717
+ function isDirectRun() {
2718
+ const entry = process.argv[1];
2719
+ if (!entry) return false;
2720
+ const self = fileURLToPath(import.meta.url);
2721
+ try {
2722
+ return realpathSync(entry) === realpathSync(self);
2723
+ } catch {
2724
+ return resolve5(entry) === self;
2725
+ }
2726
+ }
2727
+ if (isDirectRun()) {
2728
+ main().catch((e) => {
2729
+ console.error(c.rose(e.message || String(e)));
2730
+ process.exit(1);
2731
+ });
2732
+ }
1453
2733
  export {
1454
2734
  VERSION,
1455
2735
  parseArgs