artyfax 0.3.0 → 0.3.2

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 (2) hide show
  1. package/dist/cli.js +585 -136
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -55,7 +55,8 @@ async function apiFetch(config, path, init2) {
55
55
  if (raw) {
56
56
  try {
57
57
  const parsed = JSON.parse(raw);
58
- if (parsed.error) message = parsed.error;
58
+ if (parsed.message) message = parsed.message;
59
+ else if (parsed.error) message = parsed.error;
59
60
  } catch {
60
61
  const snippet = raw.replace(/\s+/g, " ").trim().slice(0, 200);
61
62
  if (snippet) message = `HTTP ${res.status}: ${snippet}`;
@@ -320,13 +321,6 @@ async function resolveSlug(config, slugOrId) {
320
321
  } catch {
321
322
  }
322
323
  }
323
- if (slugOrId.match(/^[0-9a-f-]{36}$/i)) {
324
- try {
325
- const doc = await apiFetch(config, `/documents/${slugOrId}`);
326
- return doc;
327
- } catch {
328
- }
329
- }
330
324
  if (/^[A-Z]+-\d+$/.test(slugOrId)) {
331
325
  try {
332
326
  const { id } = await apiFetch(config, `/resolve/${encodeURIComponent(slugOrId)}`);
@@ -335,6 +329,13 @@ async function resolveSlug(config, slugOrId) {
335
329
  } catch {
336
330
  }
337
331
  }
332
+ if (!slugOrId.includes("/") && !/^[A-Z]+-\d+$/.test(slugOrId)) {
333
+ try {
334
+ return await apiFetch(config, `/documents/${encodeURIComponent(slugOrId)}`);
335
+ } catch (e) {
336
+ if (e.status !== 404) throw e;
337
+ }
338
+ }
338
339
  const docs = await apiFetch(
339
340
  config,
340
341
  `/documents?limit=500`
@@ -375,6 +376,11 @@ async function read(config, slugOrId, forceSecure) {
375
376
  } else {
376
377
  process.stdout.write(data.md_source ?? data.content);
377
378
  }
379
+ if (data.content_hash) {
380
+ process.stderr.write(`
381
+ content_hash: ${data.content_hash}
382
+ `);
383
+ }
378
384
  }
379
385
 
380
386
  // src/commands/secure.ts
@@ -399,7 +405,7 @@ async function secure(config, slugOrId) {
399
405
  spin.text = "Uploading\u2026";
400
406
  await apiFetch(config, `/documents/${doc.id}/content`, {
401
407
  method: "PATCH",
402
- body: JSON.stringify({ content: encrypted })
408
+ body: JSON.stringify({ content: encrypted, force: true })
403
409
  });
404
410
  await apiFetch(config, `/documents/${doc.id}`, {
405
411
  method: "PATCH",
@@ -427,7 +433,7 @@ async function unsecure(config, slugOrId) {
427
433
  spin.text = "Uploading\u2026";
428
434
  await apiFetch(config, `/documents/${doc.id}/content`, {
429
435
  method: "PATCH",
430
- body: JSON.stringify({ content: plaintext })
436
+ body: JSON.stringify({ content: plaintext, force: true })
431
437
  });
432
438
  await apiFetch(config, `/documents/${doc.id}`, {
433
439
  method: "PATCH",
@@ -595,7 +601,7 @@ async function open(config, slugOrId) {
595
601
  // src/commands/update.ts
596
602
  import { readFileSync } from "fs";
597
603
  import { resolve } from "path";
598
- async function update(config, slugOrId, file, json) {
604
+ async function update(config, slugOrId, file, json, force = false) {
599
605
  const spin = spinner("Resolving document\u2026");
600
606
  spin.start();
601
607
  const doc = await resolveSlug(config, slugOrId);
@@ -624,11 +630,35 @@ async function update(config, slugOrId, file, json) {
624
630
  const sdk = await getSDK(config);
625
631
  content = await encryptSecure(content, sdk);
626
632
  }
633
+ let ifMatch = null;
634
+ if (!force) {
635
+ spin.text = "Checking current version\u2026";
636
+ const cur = await apiFetch(config, `/documents/${doc.id}/content`);
637
+ ifMatch = cur.content_hash ?? null;
638
+ }
627
639
  spin.text = "Uploading\u2026";
628
- await apiFetch(config, `/documents/${doc.id}/content`, {
629
- method: "PATCH",
630
- body: JSON.stringify({ content, source: "cli" })
631
- });
640
+ try {
641
+ await apiFetch(config, `/documents/${doc.id}/content`, {
642
+ method: "PATCH",
643
+ body: JSON.stringify(
644
+ force ? { content, source: "cli", force: true } : { content, source: "cli", if_match: ifMatch ?? void 0 }
645
+ )
646
+ });
647
+ } catch (e) {
648
+ spin.stop();
649
+ const status = e.status;
650
+ if (status === 409) {
651
+ throw new Error(
652
+ "This document changed since you read it. Re-pull it with `artyfax read` and re-apply your edit, or pass --force to overwrite."
653
+ );
654
+ }
655
+ if (status === 428) {
656
+ throw new Error(
657
+ "Could not read the current version to guard this write. Re-run, or pass --force to overwrite."
658
+ );
659
+ }
660
+ throw e;
661
+ }
632
662
  spin.stop();
633
663
  if (json) {
634
664
  console.log(JSON.stringify({ updated: true, slug: doc.slug, id: doc.id, secure: doc.secure === 1 }));
@@ -687,17 +717,6 @@ function formatBytes(bytes) {
687
717
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
688
718
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
689
719
  }
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
720
  function readImageFile(file) {
702
721
  const path = resolve2(file);
703
722
  let stat;
@@ -730,7 +749,7 @@ async function setCover(config, slugOrId, file, opts) {
730
749
  const image = readImageFile(file);
731
750
  const spin = spinner("Resolving document\u2026");
732
751
  spin.start();
733
- const doc = await resolveDocArg(config, slugOrId);
752
+ const doc = await resolveSlug(config, slugOrId);
734
753
  spin.text = `Uploading ${image.name}\u2026`;
735
754
  const uploaded = await uploadImage(config, doc.id, image, "cover");
736
755
  spin.text = "Setting cover\u2026";
@@ -752,7 +771,7 @@ async function addImage(config, slugOrId, file, opts) {
752
771
  const image = readImageFile(file);
753
772
  const spin = spinner("Resolving document\u2026");
754
773
  spin.start();
755
- const doc = await resolveDocArg(config, slugOrId);
774
+ const doc = await resolveSlug(config, slugOrId);
756
775
  spin.text = `Uploading ${image.name}\u2026`;
757
776
  const uploaded = await uploadImage(config, doc.id, image, "content");
758
777
  spin.stop();
@@ -767,6 +786,106 @@ async function addImage(config, slugOrId, file, opts) {
767
786
  }
768
787
  }
769
788
 
789
+ // ../shared/src/limits.ts
790
+ var SERVER_SIDE_BYTE_CAP = 10 * 1024 * 1024;
791
+ var CLIENT_UPLOAD_BYTE_CAP_BY_TIER = {
792
+ free: 25 * 1024 * 1024,
793
+ personal: 50 * 1024 * 1024,
794
+ pro: 100 * 1024 * 1024
795
+ };
796
+ var TOTAL_STORAGE_BYTES_BY_TIER = {
797
+ free: 100 * 1024 * 1024,
798
+ personal: 10 * 1024 * 1024 * 1024,
799
+ pro: 50 * 1024 * 1024 * 1024
800
+ };
801
+
802
+ // ../shared/src/unsplash.ts
803
+ function sanitizeMarkdownLabel(text) {
804
+ if (!text) return "";
805
+ return text.replace(/[[\]]/g, "").replace(/\s+/g, " ").trim();
806
+ }
807
+
808
+ // src/commands/unsplash.ts
809
+ async function search2(config, query, page) {
810
+ const qs = new URLSearchParams({ q: query, page: String(page) });
811
+ return apiFetch(config, `/unsplash/search?${qs.toString()}`);
812
+ }
813
+ async function unsplashSearch(config, query, opts) {
814
+ const spin = spinner("Searching Unsplash\u2026");
815
+ spin.start();
816
+ const res = await search2(config, query, opts.page ?? 1);
817
+ spin.stop();
818
+ if (opts.json) {
819
+ console.log(JSON.stringify(res));
820
+ return;
821
+ }
822
+ if (res.results.length === 0) {
823
+ console.log(c.muted(`No Unsplash photos found for "${query}".`));
824
+ return;
825
+ }
826
+ res.results.forEach((r, i) => {
827
+ const n = c.bright(String(i + 1).padStart(2));
828
+ const desc = r.description ? c.bright(r.description) : c.muted("(no description)");
829
+ console.log(`${n}. ${desc}`);
830
+ console.log(` ${c.muted(`${r.photographer.name} \xB7 ${r.width}\xD7${r.height} \xB7 ${r.photo_id}`)}`);
831
+ });
832
+ console.log(
833
+ c.muted(
834
+ `
835
+ ${res.total} results. Use: artyfax cover <doc> --photo <id> | artyfax image <doc> --photo <id>`
836
+ )
837
+ );
838
+ }
839
+ async function resolvePhotoId(config, opts) {
840
+ if (opts.photo) return opts.photo;
841
+ if (!opts.unsplash) throw new Error('Provide --photo <id> or --unsplash "<query>"');
842
+ const res = await search2(config, opts.unsplash, 1);
843
+ if (res.results.length === 0) throw new Error(`No Unsplash photos found for "${opts.unsplash}"`);
844
+ return res.results[0].photo_id;
845
+ }
846
+ async function coverFromUnsplash(config, slugOrId, opts) {
847
+ const spin = spinner("Resolving document\u2026");
848
+ spin.start();
849
+ const doc = await resolveSlug(config, slugOrId);
850
+ spin.text = "Finding photo\u2026";
851
+ const photoId = await resolvePhotoId(config, opts);
852
+ spin.text = "Importing cover from Unsplash\u2026";
853
+ const result = await apiFetch(
854
+ config,
855
+ `/documents/${doc.id}/cover/from-unsplash`,
856
+ { method: "POST", body: JSON.stringify({ photo_id: photoId }) }
857
+ );
858
+ spin.stop();
859
+ if (opts.json) {
860
+ console.log(JSON.stringify({ ok: true, id: doc.id, slug: doc.slug, cover_image: result.path }));
861
+ } else {
862
+ console.log(`${c.teal("Cover set")} on ${c.bright(doc.slug)} ${c.muted(`(Unsplash ${photoId})`)}`);
863
+ }
864
+ }
865
+ async function imageFromUnsplash(config, slugOrId, opts) {
866
+ const spin = spinner("Resolving document\u2026");
867
+ spin.start();
868
+ const doc = await resolveSlug(config, slugOrId);
869
+ spin.text = "Finding photo\u2026";
870
+ const photoId = await resolvePhotoId(config, opts);
871
+ spin.text = "Importing image from Unsplash\u2026";
872
+ const result = await apiFetch(
873
+ config,
874
+ `/documents/${doc.id}/images/from-unsplash`,
875
+ { method: "POST", body: JSON.stringify({ photo_id: photoId }) }
876
+ );
877
+ spin.stop();
878
+ const markdown = `![${sanitizeMarkdownLabel(result.alt)}](${result.path})
879
+ : ${result.credit_markdown}`;
880
+ if (opts.json) {
881
+ console.log(JSON.stringify({ ok: true, id: doc.id, slug: doc.slug, path: result.path, markdown }));
882
+ } else {
883
+ console.log(`${c.teal("Imported")} to ${c.bright(doc.slug)} ${c.muted(`(Unsplash ${photoId})`)}`);
884
+ console.log(c.muted(" Paste this into the document body:"));
885
+ console.log(` ${markdown.replace("\n", "\n ")}`);
886
+ }
887
+ }
888
+
770
889
  // src/commands/share.ts
771
890
  async function shareCreate(config, slugOrId, json) {
772
891
  const spin = spinner("Resolving document\u2026");
@@ -782,9 +901,13 @@ async function shareCreate(config, slugOrId, json) {
782
901
  }
783
902
  );
784
903
  spin.stop();
785
- const url = `${config.endpoint}/s/${result.hash}`;
904
+ const hash = result.share?.hash;
905
+ if (!hash) {
906
+ throw new Error("Share created but the server response contained no hash.");
907
+ }
908
+ const url = `${config.endpoint}/d/${hash}`;
786
909
  if (json) {
787
- console.log(JSON.stringify({ hash: result.hash, url, slug: doc.slug }));
910
+ console.log(JSON.stringify({ hash, url, slug: doc.slug }));
788
911
  } else {
789
912
  console.log(`${c.teal("Share created:")} ${url}`);
790
913
  }
@@ -910,6 +1033,10 @@ async function tagDelete(config, tag, json) {
910
1033
  }
911
1034
 
912
1035
  // src/commands/version.ts
1036
+ function fmtTs(ts) {
1037
+ if (!ts) return "\u2014";
1038
+ return ts.replace("T", " ").replace(/\.\d+Z$/, "");
1039
+ }
913
1040
  async function versionList(config, slugOrId, json) {
914
1041
  const spin = spinner("Resolving document\u2026");
915
1042
  spin.start();
@@ -921,18 +1048,24 @@ async function versionList(config, slugOrId, json) {
921
1048
  console.log(JSON.stringify(data.versions));
922
1049
  return;
923
1050
  }
924
- if (data.versions.length === 0) {
925
- console.log(c.muted("No versions found."));
1051
+ const versions = data.versions ?? [];
1052
+ const currentSaved = fmtTs(data.current?.updated_at);
1053
+ if (versions.length === 0) {
1054
+ console.log(`${c.bright(doc.slug)} \u2014 ${c.muted(`no previous versions (current saved ${currentSaved}).`)}`);
926
1055
  return;
927
1056
  }
928
- console.log(`${c.bright(doc.slug)} \u2014 ${data.versions.length} version${data.versions.length === 1 ? "" : "s"}
929
- `);
930
- const rows = data.versions.map((v, i) => [
931
- i === 0 ? c.teal("current") : c.muted(`v${data.versions.length - i}`),
932
- v.timestamp.replace("T", " ").replace(/\.\d+Z$/, ""),
933
- v.word_count != null ? `${v.word_count} words` : c.muted("\u2014")
1057
+ console.log(
1058
+ `${c.bright(doc.slug)} \u2014 current saved ${c.muted(currentSaved)}, ${versions.length} previous version${versions.length === 1 ? "" : "s"}
1059
+ `
1060
+ );
1061
+ const n = versions.length;
1062
+ const rows = versions.map((v, i) => [
1063
+ c.muted(`v${n - i}`),
1064
+ fmtTs(v.created_at),
1065
+ v.source || c.muted("\u2014"),
1066
+ v.published ? c.teal("\u25CF published") : ""
934
1067
  ]);
935
- console.log(table(rows, { header: ["", "Timestamp", "Words"] }));
1068
+ console.log(table(rows, { header: ["", "Saved", "Source", ""] }));
936
1069
  }
937
1070
  async function versionRestore(config, slugOrId, yes, json) {
938
1071
  const spin = spinner("Resolving document\u2026");
@@ -941,25 +1074,26 @@ async function versionRestore(config, slugOrId, yes, json) {
941
1074
  spin.text = "Loading versions\u2026";
942
1075
  const data = await apiFetch(config, `/documents/${doc.id}/versions`);
943
1076
  spin.stop();
944
- if (data.versions.length < 2) {
1077
+ const versions = data.versions ?? [];
1078
+ if (versions.length === 0) {
945
1079
  console.log(c.muted("No previous versions to restore."));
946
1080
  return;
947
1081
  }
948
- const previous = data.versions.slice(1);
949
- console.log(`${c.bright(doc.slug)} \u2014 ${previous.length} previous version${previous.length === 1 ? "" : "s"}:
1082
+ console.log(`${c.bright(doc.slug)} \u2014 ${versions.length} previous version${versions.length === 1 ? "" : "s"}:
950
1083
  `);
951
- for (let i = 0; i < previous.length; i++) {
952
- const v = previous[i];
953
- const label = `v${data.versions.length - 1 - i}`;
954
- const ts = v.timestamp.replace("T", " ").replace(/\.\d+Z$/, "");
955
- const words = v.word_count != null ? ` (${v.word_count} words)` : "";
956
- console.log(` ${c.amber(label)} ${ts}${c.muted(words)}`);
957
- }
958
- const target = previous[0];
1084
+ const n = versions.length;
1085
+ versions.forEach((v, i) => {
1086
+ const label = `v${n - i}`;
1087
+ const source = v.source ? c.muted(` (${v.source})`) : "";
1088
+ const pin = v.published ? c.teal(" \u25CF published") : "";
1089
+ console.log(` ${c.amber(label)} ${fmtTs(v.created_at)}${source}${pin}`);
1090
+ });
959
1091
  console.log();
1092
+ const target = versions[0];
960
1093
  if (!yes) {
961
- const ts = target.timestamp.replace("T", " ").replace(/\.\d+Z$/, "");
962
- const confirmed = await confirm(`Restore to ${ts}?`);
1094
+ const confirmed = await confirm(
1095
+ `Restore to v${n} (${fmtTs(target.created_at)})? The current draft is snapshotted first, so this is reversible.`
1096
+ );
963
1097
  if (!confirmed) {
964
1098
  console.log(c.muted("Cancelled."));
965
1099
  return;
@@ -969,14 +1103,60 @@ async function versionRestore(config, slugOrId, yes, json) {
969
1103
  restoreSpin.start();
970
1104
  await apiFetch(config, `/documents/${doc.id}/versions/restore`, {
971
1105
  method: "POST",
972
- body: JSON.stringify({ timestamp: target.timestamp })
1106
+ // The restore endpoint keys on the numeric version id, not a timestamp.
1107
+ body: JSON.stringify({ version_id: target.id })
973
1108
  });
974
1109
  restoreSpin.stop();
975
1110
  if (json) {
976
- console.log(JSON.stringify({ restored: true, slug: doc.slug, timestamp: target.timestamp }));
1111
+ console.log(JSON.stringify({ restored: true, slug: doc.slug, version_id: target.id, created_at: target.created_at }));
977
1112
  } else {
978
- console.log(`${c.teal("Restored:")} ${doc.slug} to ${target.timestamp.replace("T", " ").replace(/\.\d+Z$/, "")}`);
1113
+ console.log(`${c.teal("Restored:")} ${doc.slug} to v${n} (${fmtTs(target.created_at)})`);
1114
+ }
1115
+ }
1116
+
1117
+ // src/commands/publish.ts
1118
+ async function publish(config, slugOrId, opts) {
1119
+ const spin = spinner("Resolving document\u2026");
1120
+ spin.start();
1121
+ const doc = await resolveSlug(config, slugOrId);
1122
+ spin.text = opts.version != null ? `Publishing version ${opts.version}\u2026` : "Publishing\u2026";
1123
+ const body = {};
1124
+ if (opts.version != null) body.version_id = opts.version;
1125
+ const data = await apiFetch(
1126
+ config,
1127
+ `/documents/${doc.id}/publish`,
1128
+ { method: "POST", body: JSON.stringify(body) }
1129
+ );
1130
+ spin.stop();
1131
+ if (opts.json) {
1132
+ console.log(JSON.stringify({ published: true, slug: doc.slug, ...data }));
1133
+ return;
1134
+ }
1135
+ const versionNote = data.published_version_id != null ? c.muted(` (version ${data.published_version_id})`) : "";
1136
+ console.log(`${c.teal("Published:")} ${doc.slug}${versionNote}`);
1137
+ console.log(c.muted("Public and shared views now serve this version. Edits stay draft until you publish again."));
1138
+ }
1139
+ async function unpublish(config, slugOrId, json) {
1140
+ const spin = spinner("Resolving document\u2026");
1141
+ spin.start();
1142
+ const doc = await resolveSlug(config, slugOrId);
1143
+ spin.text = "Unpublishing\u2026";
1144
+ const data = await apiFetch(
1145
+ config,
1146
+ `/documents/${doc.id}/unpublish`,
1147
+ { method: "POST" }
1148
+ );
1149
+ spin.stop();
1150
+ if (json) {
1151
+ console.log(JSON.stringify({ unpublished: true, slug: doc.slug, ...data }));
1152
+ return;
1153
+ }
1154
+ if (data.was_published === false) {
1155
+ console.log(c.muted(`${doc.slug} was not published - nothing to do.`));
1156
+ return;
979
1157
  }
1158
+ console.log(`${c.teal("Unpublished:")} ${doc.slug}`);
1159
+ console.log(c.muted("Public and shared views now serve the live document again."));
980
1160
  }
981
1161
 
982
1162
  // src/commands/annotation.ts
@@ -1511,8 +1691,32 @@ async function workSetStatus(config, docId, status, json) {
1511
1691
  }
1512
1692
  console.log(c.bright(`${data.work_id || docId} \u2192 ${demote ? "removed from board" : statusLabel(status)}`));
1513
1693
  }
1514
- function escapeHtml(s) {
1515
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1694
+ async function workTouch(config, ids, flags, json) {
1695
+ if (ids.length === 0) {
1696
+ console.error(c.rose("Error: pass one or more work IDs or doc IDs to keep live."));
1697
+ process.exitCode = 1;
1698
+ return;
1699
+ }
1700
+ const action = typeof flags.action === "string" ? flags.action : void 0;
1701
+ const agent = typeof flags.agent === "string" ? flags.agent : void 0;
1702
+ const spin = spinner("Sending heartbeat\u2026");
1703
+ spin.start();
1704
+ const data = await apiFetch(config, "/projects/work/touch", {
1705
+ method: "POST",
1706
+ headers: { "Content-Type": "application/json" },
1707
+ body: JSON.stringify({ ids, action, agent })
1708
+ });
1709
+ spin.stop();
1710
+ if (json) {
1711
+ console.log(JSON.stringify(data));
1712
+ return;
1713
+ }
1714
+ const warmed = data.warmed ?? [];
1715
+ if (warmed.length === 0) {
1716
+ console.log(c.muted("No in-progress items matched - nothing warmed."));
1717
+ return;
1718
+ }
1719
+ console.log(c.bright(`Kept live: ${warmed.length} item${warmed.length === 1 ? "" : "s"}`));
1516
1720
  }
1517
1721
  async function workCreate(config, title, flags, json) {
1518
1722
  const projectId = flags.project;
@@ -1534,10 +1738,12 @@ async function workCreate(config, title, flags, json) {
1534
1738
  slug,
1535
1739
  title,
1536
1740
  category: "inbox",
1537
- md_content: `# ${title}
1538
- `,
1741
+ // AX-165: stubs store no body. The title lives in metadata as the
1742
+ // source of truth; the reader composes the empty-doc view from it and
1743
+ // the editor seeds `# Title` on first edit.
1744
+ md_content: "",
1539
1745
  format: "md",
1540
- html_content: `<h1>${escapeHtml(title)}</h1>`
1746
+ html_content: ""
1541
1747
  })
1542
1748
  });
1543
1749
  if (saveRes.ok) {
@@ -1702,18 +1908,25 @@ When asked for the next task for a specific agent (for example "next Codex task"
1702
1908
 
1703
1909
  ### Thin work items
1704
1910
 
1705
- When a work item only has a short title/description and no task checklist, don't jump straight into implementation.
1911
+ A work item that is only a title and a one-to-three-sentence description, with no task checklist, is **not** a starting point for implementation. It is a request to *design*, not to build - so stop.
1706
1912
 
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.
1913
+ A thin stub's body is usually **empty** - quick-create and the CLI store the title and description in metadata only, and the reader composes the view from them until real content exists (AX-165). So \`get_document\` returning empty \`content\` does not mean the item has no substance: read its \`description\` field. When you write the stub up into a design, that body content replaces the empty state, and the write-up should also refresh the title and description to match what the item now delivers.
1914
+
1915
+ 1. Write it up as a proper design/plan doc first.
1916
+ 2. Put the write-up and the proposed tasks to the requester for **approval** - a structured question/approval tool where the runtime provides one (e.g. \`AskUserQuestion\`); otherwise say so and wait for typed approval.
1917
+ 3. Only after approval: generate the task checklist, mark the first task \`active\`, and begin - updating progress as you go.
1712
1918
 
1713
1919
  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
1920
 
1715
1921
  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
1922
 
1923
+ ### Promoting to the board
1924
+
1925
+ A document with \`project_id\` + \`parent\` and **no** \`work_status\` is a child doc carried under its parent - it is **not** a board card. Setting \`work_status\` is what promotes a doc to a board work item and mints its \`${p}-\` card.
1926
+
1927
+ - **The parent is the single board item.** A parent-and-children design lives on the board as **one** card (the parent), which rolls up its children's progress via \`task_source: children\`. The children are docs carried under it, not separate cards.
1928
+ - **Never set \`work_status\` on a child doc without explicit approval.** Don't promote children to the board "for structure" or to make slices individually trackable - that is the whole point of parent/child docs: one board item carrying many docs. The default for a child doc is \`project_id\` + \`parent\`, no \`work_status\`. The user elevates a child to its own board card if and when they want it there - their call, never a default.
1929
+
1717
1930
  ### Board update cadence
1718
1931
 
1719
1932
  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.
@@ -1732,7 +1945,7 @@ Use Artyfax MCP/CLI for live work updates (status changes, task ticks, assignmen
1732
1945
  ### Conventions
1733
1946
 
1734
1947
  - **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.
1948
+ - **Descriptions are high-value content**: one field feeds the board card, grid cards, search, the public page's og:description (the Google snippet), and - for a body-less stub - the entire visible substance of the item. Craft deliberately (120-180 chars, plain text, state what it delivers) and refresh it whenever the item's scope shifts. A description you write is recorded as manual and never overwritten by body derivation; clear it (save it empty) to hand it back to auto-derivation. Exception: while a doc is body-less its description is a brain dump - edits stay part of the stub, and the first real content save re-derives it from the body's opening (write the intro as the summary it will become); once the doc has content, description edits lock as manual. If it can't fit the bar, the item has outgrown being a stub - put the substance in the body. The MCP tool guidance carries the full rules.
1736
1949
  - **Statuses**: backlog > up_next > in_progress > in_review > done (or on_hold, cancelled).
1737
1950
  `;
1738
1951
  }
@@ -1855,17 +2068,16 @@ async function resolveId(config, identifier) {
1855
2068
  const data = await apiFetch(config, `/resolve/${encodeURIComponent(identifier)}`);
1856
2069
  return data.id;
1857
2070
  }
2071
+ async function fetchTasks(config, id) {
2072
+ const data = await apiFetch(config, `/documents/${id}/tasks`);
2073
+ return data.tasks;
2074
+ }
1858
2075
  async function taskList(config, identifier, json) {
1859
2076
  const spin = spinner("Loading tasks\u2026");
1860
2077
  spin.start();
1861
2078
  const id = await resolveId(config, identifier);
1862
- const doc = await apiFetch(config, `/documents/${id}/content`);
2079
+ const tasks = await fetchTasks(config, id);
1863
2080
  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
2081
  if (json) {
1870
2082
  console.log(JSON.stringify(tasks, null, 2));
1871
2083
  return;
@@ -1891,8 +2103,7 @@ async function taskUpdate(config, identifier, taskText, state, json) {
1891
2103
  const spin = spinner(`Setting task to ${state}\u2026`);
1892
2104
  spin.start();
1893
2105
  const id = await resolveId(config, identifier);
1894
- const doc = await apiFetch(config, `/documents/${id}/content`);
1895
- const tasks = parseTasks(doc.md_source);
2106
+ const tasks = await fetchTasks(config, id);
1896
2107
  const needle = taskText.toLowerCase();
1897
2108
  const match = tasks.find((t) => t.text.toLowerCase().includes(needle));
1898
2109
  if (!match) {
@@ -1913,31 +2124,158 @@ async function taskUpdate(config, identifier, taskText, state, json) {
1913
2124
  const stateLabel = { done: c.teal("done"), active: c.amber("active"), pending: c.muted("pending") };
1914
2125
  console.log(`${stateLabel[state]} ${match.text}`);
1915
2126
  }
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
- }
2127
+
2128
+ // ../shared/src/child-order.ts
2129
+ function resolveChildOrder(input) {
2130
+ const { activeIds, archivedIds, requested } = input;
2131
+ const seen = /* @__PURE__ */ new Set();
2132
+ const dupes = /* @__PURE__ */ new Set();
2133
+ for (const id of requested) {
2134
+ if (seen.has(id)) dupes.add(id);
2135
+ seen.add(id);
2136
+ }
2137
+ if (dupes.size > 0) {
2138
+ return { ok: false, error: `Duplicate id(s) in the order: ${[...dupes].join(", ")}` };
2139
+ }
2140
+ const activeSet = new Set(activeIds);
2141
+ const archivedSet = new Set(archivedIds);
2142
+ const archivedRequested = [];
2143
+ const foreign = [];
2144
+ for (const id of requested) {
2145
+ if (activeSet.has(id)) continue;
2146
+ if (archivedSet.has(id)) archivedRequested.push(id);
2147
+ else foreign.push(id);
2148
+ }
2149
+ if (archivedRequested.length > 0) {
2150
+ return {
2151
+ ok: false,
2152
+ error: `Archived page(s) can't be positioned: ${archivedRequested.join(", ")}. Archived pages keep their order automatically.`
2153
+ };
2154
+ }
2155
+ if (foreign.length > 0) {
2156
+ return { ok: false, error: `Not a child of this parent: ${foreign.join(", ")}` };
2157
+ }
2158
+ const requestedSet = new Set(requested);
2159
+ const missing = activeIds.filter((id) => !requestedSet.has(id));
2160
+ if (missing.length > 0) {
2161
+ return {
2162
+ ok: false,
2163
+ error: `The order must list every visible child. Missing: ${missing.join(", ")}`
2164
+ };
1935
2165
  }
1936
- return tasks;
2166
+ return { ok: true, childIds: [...requested, ...archivedIds] };
2167
+ }
2168
+
2169
+ // src/commands/pages.ts
2170
+ async function fetchChildrenSorted(config, parentId) {
2171
+ const { documents } = await apiFetch(
2172
+ config,
2173
+ `/documents?parent_id=${encodeURIComponent(parentId)}&archived=include&limit=all`
2174
+ );
2175
+ return [...documents].sort(
2176
+ (a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || (a.title ?? "").localeCompare(b.title ?? "")
2177
+ );
2178
+ }
2179
+ async function loadChildren(config, parentId) {
2180
+ const sorted = await fetchChildrenSorted(config, parentId);
2181
+ return {
2182
+ activeIds: sorted.filter((d) => !d.archived).map((d) => d.id),
2183
+ archivedIds: sorted.filter((d) => d.archived).map((d) => d.id)
2184
+ };
2185
+ }
2186
+ async function writeOrder(config, parentId, childIds) {
2187
+ await apiFetch(config, `/documents/${parentId}/children/order`, {
2188
+ method: "PUT",
2189
+ body: JSON.stringify({ child_ids: childIds })
2190
+ });
2191
+ }
2192
+ function fail(spin, message) {
2193
+ spin.stop();
2194
+ console.error(c.rose(message));
2195
+ process.exit(1);
2196
+ }
2197
+ async function pagesList(config, parentRef, json) {
2198
+ const spin = spinner("Loading pages\u2026");
2199
+ spin.start();
2200
+ const parentId = (await resolveSlug(config, parentRef)).id;
2201
+ const children = await fetchChildrenSorted(config, parentId);
2202
+ spin.stop();
2203
+ const visible = children.filter((d) => !d.archived);
2204
+ const archivedCount = children.length - visible.length;
2205
+ if (json) {
2206
+ console.log(JSON.stringify(visible));
2207
+ return;
2208
+ }
2209
+ if (visible.length === 0) {
2210
+ console.log(
2211
+ c.muted(
2212
+ archivedCount > 0 ? `No visible child pages (${archivedCount} archived hidden).` : "No child pages."
2213
+ )
2214
+ );
2215
+ return;
2216
+ }
2217
+ const rows = visible.map((d, i) => [c.muted(`${i + 1}`), d.title ?? "", c.muted(d.slug ?? "")]);
2218
+ console.log(table(rows, { header: [c.muted("#"), "Title", c.muted("Slug")] }));
2219
+ if (archivedCount > 0) {
2220
+ console.log(c.muted(`
2221
+ ${archivedCount} archived page${archivedCount === 1 ? "" : "s"} hidden`));
2222
+ }
2223
+ }
2224
+ async function pagesOrder(config, parentRef, childRefs, json) {
2225
+ const spin = spinner("Setting page order\u2026");
2226
+ spin.start();
2227
+ const parentId = (await resolveSlug(config, parentRef)).id;
2228
+ const requested = await Promise.all(childRefs.map(async (r) => (await resolveSlug(config, r)).id));
2229
+ const { activeIds, archivedIds } = await loadChildren(config, parentId);
2230
+ const order = resolveChildOrder({ activeIds, archivedIds, requested });
2231
+ if (!order.ok) fail(spin, order.error);
2232
+ await writeOrder(config, parentId, order.childIds);
2233
+ spin.stop();
2234
+ if (json) {
2235
+ console.log(JSON.stringify({ ok: true, child_ids: order.childIds }));
2236
+ return;
2237
+ }
2238
+ const n = order.childIds.length;
2239
+ console.log(`${c.teal("\u2713")} Reordered ${n} page${n === 1 ? "" : "s"}`);
2240
+ }
2241
+ async function pagesMove(config, childRef, position, targetRef, json) {
2242
+ if (position !== "before" && position !== "after") {
2243
+ console.error(c.rose(`Position must be "before" or "after", got "${position}".`));
2244
+ process.exit(1);
2245
+ }
2246
+ const spin = spinner("Moving page\u2026");
2247
+ spin.start();
2248
+ const child = await resolveSlug(config, childRef);
2249
+ const target = await resolveSlug(config, targetRef);
2250
+ if (child.id === target.id) fail(spin, "Can't move a page relative to itself.");
2251
+ if (!child.parent_id) fail(spin, `"${child.title}" is not a child page - it has no parent.`);
2252
+ if (target.parent_id !== child.parent_id) {
2253
+ fail(spin, `"${target.title}" is not a sibling of "${child.title}".`);
2254
+ }
2255
+ const { activeIds, archivedIds } = await loadChildren(config, child.parent_id);
2256
+ if (!activeIds.includes(child.id)) {
2257
+ fail(spin, `"${child.title}" is archived - unarchive it before reordering.`);
2258
+ }
2259
+ if (!activeIds.includes(target.id)) {
2260
+ fail(spin, `"${target.title}" is archived - pick a visible sibling as the target.`);
2261
+ }
2262
+ const without = activeIds.filter((id) => id !== child.id);
2263
+ const targetIdx = without.indexOf(target.id);
2264
+ const insertIdx = position === "before" ? targetIdx : targetIdx + 1;
2265
+ without.splice(insertIdx, 0, child.id);
2266
+ const order = resolveChildOrder({ activeIds, archivedIds, requested: without });
2267
+ if (!order.ok) fail(spin, order.error);
2268
+ await writeOrder(config, child.parent_id, order.childIds);
2269
+ spin.stop();
2270
+ if (json) {
2271
+ console.log(JSON.stringify({ ok: true, child_ids: order.childIds }));
2272
+ return;
2273
+ }
2274
+ console.log(`${c.teal("\u2713")} Moved "${child.title}" ${position} "${target.title}"`);
1937
2275
  }
1938
2276
 
1939
2277
  // src/cli.ts
1940
- var VERSION = true ? "0.3.0" : "0.0.0-dev";
2278
+ var VERSION = true ? "0.3.2" : "0.0.0-dev";
1941
2279
  function brandedHelp() {
1942
2280
  return `
1943
2281
  ${c.amber("artyfax")} ${c.muted(`v${VERSION}`)} \u2014 your personal document library
@@ -1959,6 +2297,8 @@ ${c.bright("Documents")}
1959
2297
  unsecure <slug> Remove encryption
1960
2298
  cover <doc> <file> Set a document's cover image from a local file
1961
2299
  image <doc> <file> Upload a local image, print the markdown to embed
2300
+ publish <doc> Pin a published version (public/shared views serve it)
2301
+ unpublish <doc> Clear the published pointer, revert to serving live
1962
2302
 
1963
2303
  ${c.bright("Sub-resources")}
1964
2304
  share create <slug> Create a share link
@@ -2040,7 +2380,11 @@ ${c.bright("Options:")}
2040
2380
  ${c.bright("Examples:")}
2041
2381
  artyfax save notes.md --category inbox
2042
2382
  artyfax save --url https://example.com/article --category articles
2043
- artyfax save report.md --secure --category reports`,
2383
+ artyfax save report.md --secure --category reports
2384
+
2385
+ ${c.bright("Markdown:")} mermaid diagrams render (\`\`\`mermaid fences); colour flowchart
2386
+ nodes on-brand with \`class <node> amber|teal|blue|violet|rose\` rather than raw hex.
2387
+ Full syntax: the editor's Markdown Reference, or the seeded "Markdown Formatting" doc.`,
2044
2388
  read: `${c.amber("artyfax read")} \u2014 read document content
2045
2389
 
2046
2390
  ${c.bright("Usage:")} artyfax read <slug>
@@ -2179,6 +2523,25 @@ ${c.bright("Usage:")} artyfax version list <slug> Show version history
2179
2523
  ${c.bright("Examples:")}
2180
2524
  artyfax version list inbox/my-doc
2181
2525
  artyfax version restore inbox/my-doc --yes`,
2526
+ publish: `${c.amber("artyfax publish")} \u2014 pin a published version
2527
+
2528
+ ${c.bright("Usage:")} artyfax publish <doc> [version-id]
2529
+
2530
+ Public and shared views serve the pinned version while later edits stay draft
2531
+ until you publish again. With no version-id, publishes the current draft;
2532
+ pass a version id (from ${c.amber("artyfax version list")}) to publish an older state.
2533
+
2534
+ ${c.bright("Examples:")}
2535
+ artyfax publish plans/my-post
2536
+ artyfax publish AX-12 1843`,
2537
+ unpublish: `${c.amber("artyfax unpublish")} \u2014 clear the published pointer
2538
+
2539
+ ${c.bright("Usage:")} artyfax unpublish <doc>
2540
+
2541
+ Public and shared views revert to serving the live document.
2542
+
2543
+ ${c.bright("Examples:")}
2544
+ artyfax unpublish plans/my-post`,
2182
2545
  annotation: `${c.amber("artyfax annotation")} \u2014 read a document's annotations (highlights, underlines, strikethroughs, notes, comments, redactions)
2183
2546
 
2184
2547
  ${c.bright("Usage:")} artyfax annotation list <slug> List annotations (--include-recipients, --since <iso>)
@@ -2198,7 +2561,20 @@ ${c.bright("Usage:")} artyfax doctor [--json]`,
2198
2561
 
2199
2562
  ${c.bright("Usage:")} artyfax skill install [--project] Install Artyfax skill
2200
2563
  artyfax skill status Check installation
2201
- artyfax skill update Update to latest version`
2564
+ artyfax skill update Update to latest version`,
2565
+ pages: `${c.amber("artyfax pages")} \u2014 list and reorder a parent document's child pages
2566
+
2567
+ ${c.bright("Usage:")} artyfax pages list <parent> Show child pages in reading order
2568
+ artyfax pages order <parent> <child...> Set the full reading order
2569
+ artyfax pages move <child> before|after <target> Nudge one page past a sibling
2570
+
2571
+ Refs accept a document id, slug, or work id (e.g. ${c.muted("AX-12")}).
2572
+ ${c.bright("order")} must name every visible child once; archived pages keep their order automatically.
2573
+
2574
+ ${c.bright("Examples:")}
2575
+ artyfax pages list AX-94
2576
+ artyfax pages order AX-94 intro setup usage
2577
+ artyfax pages move usage before setup`
2202
2578
  };
2203
2579
  function parseArgs(args) {
2204
2580
  const flags = {};
@@ -2237,10 +2613,36 @@ function parseArgs(args) {
2237
2613
  }
2238
2614
  return { command, subcommand, positional, flags };
2239
2615
  }
2240
- var SUB_RESOURCES = /* @__PURE__ */ new Set(["share", "cat", "tag", "version", "annotation", "skill", "snippets", "work", "project", "workspace"]);
2616
+ var SUB_RESOURCES = /* @__PURE__ */ new Set(["share", "cat", "tag", "version", "annotation", "skill", "snippets", "work", "project", "workspace", "pages"]);
2241
2617
  function isSubResource(cmd) {
2242
2618
  return SUB_RESOURCES.has(cmd);
2243
2619
  }
2620
+ var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
2621
+ "init",
2622
+ "save",
2623
+ "read",
2624
+ "list",
2625
+ "get",
2626
+ "search",
2627
+ "delete",
2628
+ "open",
2629
+ "secure",
2630
+ "unsecure",
2631
+ "update",
2632
+ "metadata",
2633
+ "cover",
2634
+ "image",
2635
+ "unsplash",
2636
+ "publish",
2637
+ "unpublish",
2638
+ "doctor",
2639
+ "task",
2640
+ "tasks",
2641
+ ...SUB_RESOURCES
2642
+ ]);
2643
+ function isKnownCommand(command) {
2644
+ return KNOWN_COMMANDS.has(command);
2645
+ }
2244
2646
  async function main() {
2245
2647
  const parsed = parseArgs(process.argv.slice(2));
2246
2648
  const { command, subcommand, positional, flags } = parsed;
@@ -2262,36 +2664,7 @@ async function main() {
2262
2664
  console.log(brandedHelp());
2263
2665
  process.exit(0);
2264
2666
  }
2265
- const KNOWN_COMMANDS = /* @__PURE__ */ new Set([
2266
- "init",
2267
- "save",
2268
- "read",
2269
- "list",
2270
- "get",
2271
- "search",
2272
- "delete",
2273
- "open",
2274
- "secure",
2275
- "unsecure",
2276
- "update",
2277
- "metadata",
2278
- "cover",
2279
- "image",
2280
- "share",
2281
- "cat",
2282
- "tag",
2283
- "version",
2284
- "annotation",
2285
- "skill",
2286
- "snippets",
2287
- "doctor",
2288
- "work",
2289
- "project",
2290
- "workspace",
2291
- "task",
2292
- "tasks"
2293
- ]);
2294
- if (!KNOWN_COMMANDS.has(command)) {
2667
+ if (!isKnownCommand(command)) {
2295
2668
  error(`Unknown command: ${command}`, `Run \`artyfax--help\` for available commands`);
2296
2669
  }
2297
2670
  if (command === "skill") {
@@ -2422,6 +2795,24 @@ async function main() {
2422
2795
  await open(config, slug);
2423
2796
  break;
2424
2797
  }
2798
+ case "publish": {
2799
+ const slug = positional[0];
2800
+ if (!slug) error("Missing slug argument", "Usage: artyfax publish <doc> [version-id]");
2801
+ const versionArg = positional[1];
2802
+ let version;
2803
+ if (versionArg !== void 0) {
2804
+ version = Number(versionArg);
2805
+ if (!Number.isInteger(version)) error(`Invalid version id: ${versionArg}`, "Usage: artyfax publish <doc> [version-id]");
2806
+ }
2807
+ await publish(config, slug, { version, json });
2808
+ break;
2809
+ }
2810
+ case "unpublish": {
2811
+ const slug = positional[0];
2812
+ if (!slug) error("Missing slug argument", "Usage: artyfax unpublish <doc>");
2813
+ await unpublish(config, slug, json);
2814
+ break;
2815
+ }
2425
2816
  case "secure": {
2426
2817
  const slug = positional[0];
2427
2818
  if (!slug) error("Missing slug argument", "Usage: artyfax secure <slug>");
@@ -2436,8 +2827,8 @@ async function main() {
2436
2827
  }
2437
2828
  case "update": {
2438
2829
  const slug = positional[0];
2439
- if (!slug) error("Missing slug argument", "Usage: artyfax update <slug> [file] (reads from stdin if no file)");
2440
- await update(config, slug, positional[1], json);
2830
+ if (!slug) error("Missing slug argument", "Usage: artyfax update <slug> [file] [--force] (reads from stdin if no file)");
2831
+ await update(config, slug, positional[1], json, !!flags.force);
2441
2832
  break;
2442
2833
  }
2443
2834
  case "metadata": {
@@ -2458,15 +2849,35 @@ async function main() {
2458
2849
  case "cover": {
2459
2850
  const slug = positional[0];
2460
2851
  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 });
2852
+ const unsplash = flags.unsplash;
2853
+ const photo = flags.photo;
2854
+ if (!slug) error("Missing arguments", 'Usage: artyfax cover <doc> <image-file> | cover <doc> --unsplash "<query>" | --photo <id>');
2855
+ if (unsplash || photo) {
2856
+ await coverFromUnsplash(config, slug, { unsplash, photo, json });
2857
+ } else {
2858
+ if (!file) error("Missing image", 'Provide an image file, or --unsplash "<query>", or --photo <id>');
2859
+ await setCover(config, slug, file, { alt: flags.alt, json });
2860
+ }
2463
2861
  break;
2464
2862
  }
2465
2863
  case "image": {
2466
2864
  const slug = positional[0];
2467
2865
  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 });
2866
+ const unsplash = flags.unsplash;
2867
+ const photo = flags.photo;
2868
+ if (!slug) error("Missing arguments", 'Usage: artyfax image <doc> <image-file> | image <doc> --unsplash "<query>" | --photo <id>');
2869
+ if (unsplash || photo) {
2870
+ await imageFromUnsplash(config, slug, { unsplash, photo, json });
2871
+ } else {
2872
+ if (!file) error("Missing image", 'Provide an image file, or --unsplash "<query>", or --photo <id>');
2873
+ await addImage(config, slug, file, { alt: flags.alt, json });
2874
+ }
2875
+ break;
2876
+ }
2877
+ case "unsplash": {
2878
+ const query = positional.join(" ").trim();
2879
+ if (!query) error("Missing query", "Usage: artyfax unsplash <query>");
2880
+ await unsplashSearch(config, query, { json, page: flags.page ? Number(flags.page) : void 0 });
2470
2881
  break;
2471
2882
  }
2472
2883
  case "share": {
@@ -2637,6 +3048,38 @@ async function main() {
2637
3048
  }
2638
3049
  break;
2639
3050
  }
3051
+ case "pages": {
3052
+ switch (subcommand) {
3053
+ case "list": {
3054
+ const parent = positional[0];
3055
+ if (!parent) error("Missing parent", "Usage: artyfax pages list <parent>");
3056
+ await pagesList(config, parent, json);
3057
+ break;
3058
+ }
3059
+ case "order": {
3060
+ const parent = positional[0];
3061
+ const childRefs = positional.slice(1);
3062
+ if (!parent || childRefs.length === 0) {
3063
+ error("Missing arguments", "Usage: artyfax pages order <parent> <child...>");
3064
+ }
3065
+ await pagesOrder(config, parent, childRefs, json);
3066
+ break;
3067
+ }
3068
+ case "move": {
3069
+ const child = positional[0];
3070
+ const position = positional[1];
3071
+ const target = positional[2];
3072
+ if (!child || !position || !target) {
3073
+ error("Missing arguments", "Usage: artyfax pages move <child> before|after <target>");
3074
+ }
3075
+ await pagesMove(config, child, position, target, json);
3076
+ break;
3077
+ }
3078
+ default:
3079
+ error(`Unknown pages subcommand: ${subcommand || "(none)"}`, "Usage: artyfax pages <list|order|move>");
3080
+ }
3081
+ break;
3082
+ }
2640
3083
  case "work": {
2641
3084
  switch (subcommand) {
2642
3085
  case "":
@@ -2673,8 +3116,13 @@ async function main() {
2673
3116
  await workCreate(config, title, flags, json);
2674
3117
  break;
2675
3118
  }
3119
+ case "touch": {
3120
+ if (positional.length === 0) error("Missing work item ID(s)", "Usage: artyfax work touch <id...> [--action editing|building|reviewing|running]");
3121
+ await workTouch(config, positional, flags, json);
3122
+ break;
3123
+ }
2676
3124
  default:
2677
- error(`Unknown work subcommand: ${subcommand}`, "Usage: artyfax work <list|next|start|done|status|create>");
3125
+ error(`Unknown work subcommand: ${subcommand}`, "Usage: artyfax work <list|next|start|done|status|create|touch>");
2678
3126
  }
2679
3127
  break;
2680
3128
  }
@@ -2732,5 +3180,6 @@ if (isDirectRun()) {
2732
3180
  }
2733
3181
  export {
2734
3182
  VERSION,
3183
+ isKnownCommand,
2735
3184
  parseArgs
2736
3185
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "artyfax",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "CLI for Artyfax - your personal document library. Save, theme, search, and share.",
5
5
  "type": "module",
6
6
  "bin": {