artyfax 0.3.0 → 0.3.1

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 +420 -122
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -320,13 +320,6 @@ async function resolveSlug(config, slugOrId) {
320
320
  } catch {
321
321
  }
322
322
  }
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
323
  if (/^[A-Z]+-\d+$/.test(slugOrId)) {
331
324
  try {
332
325
  const { id } = await apiFetch(config, `/resolve/${encodeURIComponent(slugOrId)}`);
@@ -335,6 +328,13 @@ async function resolveSlug(config, slugOrId) {
335
328
  } catch {
336
329
  }
337
330
  }
331
+ if (!slugOrId.includes("/") && !/^[A-Z]+-\d+$/.test(slugOrId)) {
332
+ try {
333
+ return await apiFetch(config, `/documents/${encodeURIComponent(slugOrId)}`);
334
+ } catch (e) {
335
+ if (e.status !== 404) throw e;
336
+ }
337
+ }
338
338
  const docs = await apiFetch(
339
339
  config,
340
340
  `/documents?limit=500`
@@ -687,17 +687,6 @@ function formatBytes(bytes) {
687
687
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
688
688
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
689
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
690
  function readImageFile(file) {
702
691
  const path = resolve2(file);
703
692
  let stat;
@@ -730,7 +719,7 @@ async function setCover(config, slugOrId, file, opts) {
730
719
  const image = readImageFile(file);
731
720
  const spin = spinner("Resolving document\u2026");
732
721
  spin.start();
733
- const doc = await resolveDocArg(config, slugOrId);
722
+ const doc = await resolveSlug(config, slugOrId);
734
723
  spin.text = `Uploading ${image.name}\u2026`;
735
724
  const uploaded = await uploadImage(config, doc.id, image, "cover");
736
725
  spin.text = "Setting cover\u2026";
@@ -752,7 +741,7 @@ async function addImage(config, slugOrId, file, opts) {
752
741
  const image = readImageFile(file);
753
742
  const spin = spinner("Resolving document\u2026");
754
743
  spin.start();
755
- const doc = await resolveDocArg(config, slugOrId);
744
+ const doc = await resolveSlug(config, slugOrId);
756
745
  spin.text = `Uploading ${image.name}\u2026`;
757
746
  const uploaded = await uploadImage(config, doc.id, image, "content");
758
747
  spin.stop();
@@ -782,9 +771,13 @@ async function shareCreate(config, slugOrId, json) {
782
771
  }
783
772
  );
784
773
  spin.stop();
785
- const url = `${config.endpoint}/s/${result.hash}`;
774
+ const hash = result.share?.hash;
775
+ if (!hash) {
776
+ throw new Error("Share created but the server response contained no hash.");
777
+ }
778
+ const url = `${config.endpoint}/d/${hash}`;
786
779
  if (json) {
787
- console.log(JSON.stringify({ hash: result.hash, url, slug: doc.slug }));
780
+ console.log(JSON.stringify({ hash, url, slug: doc.slug }));
788
781
  } else {
789
782
  console.log(`${c.teal("Share created:")} ${url}`);
790
783
  }
@@ -910,6 +903,10 @@ async function tagDelete(config, tag, json) {
910
903
  }
911
904
 
912
905
  // src/commands/version.ts
906
+ function fmtTs(ts) {
907
+ if (!ts) return "\u2014";
908
+ return ts.replace("T", " ").replace(/\.\d+Z$/, "");
909
+ }
913
910
  async function versionList(config, slugOrId, json) {
914
911
  const spin = spinner("Resolving document\u2026");
915
912
  spin.start();
@@ -921,18 +918,24 @@ async function versionList(config, slugOrId, json) {
921
918
  console.log(JSON.stringify(data.versions));
922
919
  return;
923
920
  }
924
- if (data.versions.length === 0) {
925
- console.log(c.muted("No versions found."));
921
+ const versions = data.versions ?? [];
922
+ const currentSaved = fmtTs(data.current?.updated_at);
923
+ if (versions.length === 0) {
924
+ console.log(`${c.bright(doc.slug)} \u2014 ${c.muted(`no previous versions (current saved ${currentSaved}).`)}`);
926
925
  return;
927
926
  }
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")
927
+ console.log(
928
+ `${c.bright(doc.slug)} \u2014 current saved ${c.muted(currentSaved)}, ${versions.length} previous version${versions.length === 1 ? "" : "s"}
929
+ `
930
+ );
931
+ const n = versions.length;
932
+ const rows = versions.map((v, i) => [
933
+ c.muted(`v${n - i}`),
934
+ fmtTs(v.created_at),
935
+ v.source || c.muted("\u2014"),
936
+ v.published ? c.teal("\u25CF published") : ""
934
937
  ]);
935
- console.log(table(rows, { header: ["", "Timestamp", "Words"] }));
938
+ console.log(table(rows, { header: ["", "Saved", "Source", ""] }));
936
939
  }
937
940
  async function versionRestore(config, slugOrId, yes, json) {
938
941
  const spin = spinner("Resolving document\u2026");
@@ -941,25 +944,26 @@ async function versionRestore(config, slugOrId, yes, json) {
941
944
  spin.text = "Loading versions\u2026";
942
945
  const data = await apiFetch(config, `/documents/${doc.id}/versions`);
943
946
  spin.stop();
944
- if (data.versions.length < 2) {
947
+ const versions = data.versions ?? [];
948
+ if (versions.length === 0) {
945
949
  console.log(c.muted("No previous versions to restore."));
946
950
  return;
947
951
  }
948
- const previous = data.versions.slice(1);
949
- console.log(`${c.bright(doc.slug)} \u2014 ${previous.length} previous version${previous.length === 1 ? "" : "s"}:
952
+ console.log(`${c.bright(doc.slug)} \u2014 ${versions.length} previous version${versions.length === 1 ? "" : "s"}:
950
953
  `);
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];
954
+ const n = versions.length;
955
+ versions.forEach((v, i) => {
956
+ const label = `v${n - i}`;
957
+ const source = v.source ? c.muted(` (${v.source})`) : "";
958
+ const pin = v.published ? c.teal(" \u25CF published") : "";
959
+ console.log(` ${c.amber(label)} ${fmtTs(v.created_at)}${source}${pin}`);
960
+ });
959
961
  console.log();
962
+ const target = versions[0];
960
963
  if (!yes) {
961
- const ts = target.timestamp.replace("T", " ").replace(/\.\d+Z$/, "");
962
- const confirmed = await confirm(`Restore to ${ts}?`);
964
+ const confirmed = await confirm(
965
+ `Restore to v${n} (${fmtTs(target.created_at)})? The current draft is snapshotted first, so this is reversible.`
966
+ );
963
967
  if (!confirmed) {
964
968
  console.log(c.muted("Cancelled."));
965
969
  return;
@@ -969,14 +973,60 @@ async function versionRestore(config, slugOrId, yes, json) {
969
973
  restoreSpin.start();
970
974
  await apiFetch(config, `/documents/${doc.id}/versions/restore`, {
971
975
  method: "POST",
972
- body: JSON.stringify({ timestamp: target.timestamp })
976
+ // The restore endpoint keys on the numeric version id, not a timestamp.
977
+ body: JSON.stringify({ version_id: target.id })
973
978
  });
974
979
  restoreSpin.stop();
975
980
  if (json) {
976
- console.log(JSON.stringify({ restored: true, slug: doc.slug, timestamp: target.timestamp }));
981
+ console.log(JSON.stringify({ restored: true, slug: doc.slug, version_id: target.id, created_at: target.created_at }));
977
982
  } else {
978
- console.log(`${c.teal("Restored:")} ${doc.slug} to ${target.timestamp.replace("T", " ").replace(/\.\d+Z$/, "")}`);
983
+ console.log(`${c.teal("Restored:")} ${doc.slug} to v${n} (${fmtTs(target.created_at)})`);
984
+ }
985
+ }
986
+
987
+ // src/commands/publish.ts
988
+ async function publish(config, slugOrId, opts) {
989
+ const spin = spinner("Resolving document\u2026");
990
+ spin.start();
991
+ const doc = await resolveSlug(config, slugOrId);
992
+ spin.text = opts.version != null ? `Publishing version ${opts.version}\u2026` : "Publishing\u2026";
993
+ const body = {};
994
+ if (opts.version != null) body.version_id = opts.version;
995
+ const data = await apiFetch(
996
+ config,
997
+ `/documents/${doc.id}/publish`,
998
+ { method: "POST", body: JSON.stringify(body) }
999
+ );
1000
+ spin.stop();
1001
+ if (opts.json) {
1002
+ console.log(JSON.stringify({ published: true, slug: doc.slug, ...data }));
1003
+ return;
979
1004
  }
1005
+ const versionNote = data.published_version_id != null ? c.muted(` (version ${data.published_version_id})`) : "";
1006
+ console.log(`${c.teal("Published:")} ${doc.slug}${versionNote}`);
1007
+ console.log(c.muted("Public and shared views now serve this version. Edits stay draft until you publish again."));
1008
+ }
1009
+ async function unpublish(config, slugOrId, json) {
1010
+ const spin = spinner("Resolving document\u2026");
1011
+ spin.start();
1012
+ const doc = await resolveSlug(config, slugOrId);
1013
+ spin.text = "Unpublishing\u2026";
1014
+ const data = await apiFetch(
1015
+ config,
1016
+ `/documents/${doc.id}/unpublish`,
1017
+ { method: "POST" }
1018
+ );
1019
+ spin.stop();
1020
+ if (json) {
1021
+ console.log(JSON.stringify({ unpublished: true, slug: doc.slug, ...data }));
1022
+ return;
1023
+ }
1024
+ if (data.was_published === false) {
1025
+ console.log(c.muted(`${doc.slug} was not published - nothing to do.`));
1026
+ return;
1027
+ }
1028
+ console.log(`${c.teal("Unpublished:")} ${doc.slug}`);
1029
+ console.log(c.muted("Public and shared views now serve the live document again."));
980
1030
  }
981
1031
 
982
1032
  // src/commands/annotation.ts
@@ -1511,8 +1561,32 @@ async function workSetStatus(config, docId, status, json) {
1511
1561
  }
1512
1562
  console.log(c.bright(`${data.work_id || docId} \u2192 ${demote ? "removed from board" : statusLabel(status)}`));
1513
1563
  }
1514
- function escapeHtml(s) {
1515
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1564
+ async function workTouch(config, ids, flags, json) {
1565
+ if (ids.length === 0) {
1566
+ console.error(c.rose("Error: pass one or more work IDs or doc IDs to keep live."));
1567
+ process.exitCode = 1;
1568
+ return;
1569
+ }
1570
+ const action = typeof flags.action === "string" ? flags.action : void 0;
1571
+ const agent = typeof flags.agent === "string" ? flags.agent : void 0;
1572
+ const spin = spinner("Sending heartbeat\u2026");
1573
+ spin.start();
1574
+ const data = await apiFetch(config, "/projects/work/touch", {
1575
+ method: "POST",
1576
+ headers: { "Content-Type": "application/json" },
1577
+ body: JSON.stringify({ ids, action, agent })
1578
+ });
1579
+ spin.stop();
1580
+ if (json) {
1581
+ console.log(JSON.stringify(data));
1582
+ return;
1583
+ }
1584
+ const warmed = data.warmed ?? [];
1585
+ if (warmed.length === 0) {
1586
+ console.log(c.muted("No in-progress items matched - nothing warmed."));
1587
+ return;
1588
+ }
1589
+ console.log(c.bright(`Kept live: ${warmed.length} item${warmed.length === 1 ? "" : "s"}`));
1516
1590
  }
1517
1591
  async function workCreate(config, title, flags, json) {
1518
1592
  const projectId = flags.project;
@@ -1534,10 +1608,12 @@ async function workCreate(config, title, flags, json) {
1534
1608
  slug,
1535
1609
  title,
1536
1610
  category: "inbox",
1537
- md_content: `# ${title}
1538
- `,
1611
+ // AX-165: stubs store no body. The title lives in metadata as the
1612
+ // source of truth; the reader composes the empty-doc view from it and
1613
+ // the editor seeds `# Title` on first edit.
1614
+ md_content: "",
1539
1615
  format: "md",
1540
- html_content: `<h1>${escapeHtml(title)}</h1>`
1616
+ html_content: ""
1541
1617
  })
1542
1618
  });
1543
1619
  if (saveRes.ok) {
@@ -1702,18 +1778,25 @@ When asked for the next task for a specific agent (for example "next Codex task"
1702
1778
 
1703
1779
  ### Thin work items
1704
1780
 
1705
- When a work item only has a short title/description and no task checklist, don't jump straight into implementation.
1781
+ 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
1782
 
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.
1783
+ 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.
1784
+
1785
+ 1. Write it up as a proper design/plan doc first.
1786
+ 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.
1787
+ 3. Only after approval: generate the task checklist, mark the first task \`active\`, and begin - updating progress as you go.
1712
1788
 
1713
1789
  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
1790
 
1715
1791
  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
1792
 
1793
+ ### Promoting to the board
1794
+
1795
+ 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.
1796
+
1797
+ - **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.
1798
+ - **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.
1799
+
1717
1800
  ### Board update cadence
1718
1801
 
1719
1802
  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 +1815,7 @@ Use Artyfax MCP/CLI for live work updates (status changes, task ticks, assignmen
1732
1815
  ### Conventions
1733
1816
 
1734
1817
  - **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.
1818
+ - **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
1819
  - **Statuses**: backlog > up_next > in_progress > in_review > done (or on_hold, cancelled).
1737
1820
  `;
1738
1821
  }
@@ -1855,17 +1938,16 @@ async function resolveId(config, identifier) {
1855
1938
  const data = await apiFetch(config, `/resolve/${encodeURIComponent(identifier)}`);
1856
1939
  return data.id;
1857
1940
  }
1941
+ async function fetchTasks(config, id) {
1942
+ const data = await apiFetch(config, `/documents/${id}/tasks`);
1943
+ return data.tasks;
1944
+ }
1858
1945
  async function taskList(config, identifier, json) {
1859
1946
  const spin = spinner("Loading tasks\u2026");
1860
1947
  spin.start();
1861
1948
  const id = await resolveId(config, identifier);
1862
- const doc = await apiFetch(config, `/documents/${id}/content`);
1949
+ const tasks = await fetchTasks(config, id);
1863
1950
  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
1951
  if (json) {
1870
1952
  console.log(JSON.stringify(tasks, null, 2));
1871
1953
  return;
@@ -1891,8 +1973,7 @@ async function taskUpdate(config, identifier, taskText, state, json) {
1891
1973
  const spin = spinner(`Setting task to ${state}\u2026`);
1892
1974
  spin.start();
1893
1975
  const id = await resolveId(config, identifier);
1894
- const doc = await apiFetch(config, `/documents/${id}/content`);
1895
- const tasks = parseTasks(doc.md_source);
1976
+ const tasks = await fetchTasks(config, id);
1896
1977
  const needle = taskText.toLowerCase();
1897
1978
  const match = tasks.find((t) => t.text.toLowerCase().includes(needle));
1898
1979
  if (!match) {
@@ -1913,31 +1994,158 @@ async function taskUpdate(config, identifier, taskText, state, json) {
1913
1994
  const stateLabel = { done: c.teal("done"), active: c.amber("active"), pending: c.muted("pending") };
1914
1995
  console.log(`${stateLabel[state]} ${match.text}`);
1915
1996
  }
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
- }
1997
+
1998
+ // ../shared/src/child-order.ts
1999
+ function resolveChildOrder(input) {
2000
+ const { activeIds, archivedIds, requested } = input;
2001
+ const seen = /* @__PURE__ */ new Set();
2002
+ const dupes = /* @__PURE__ */ new Set();
2003
+ for (const id of requested) {
2004
+ if (seen.has(id)) dupes.add(id);
2005
+ seen.add(id);
2006
+ }
2007
+ if (dupes.size > 0) {
2008
+ return { ok: false, error: `Duplicate id(s) in the order: ${[...dupes].join(", ")}` };
2009
+ }
2010
+ const activeSet = new Set(activeIds);
2011
+ const archivedSet = new Set(archivedIds);
2012
+ const archivedRequested = [];
2013
+ const foreign = [];
2014
+ for (const id of requested) {
2015
+ if (activeSet.has(id)) continue;
2016
+ if (archivedSet.has(id)) archivedRequested.push(id);
2017
+ else foreign.push(id);
2018
+ }
2019
+ if (archivedRequested.length > 0) {
2020
+ return {
2021
+ ok: false,
2022
+ error: `Archived page(s) can't be positioned: ${archivedRequested.join(", ")}. Archived pages keep their order automatically.`
2023
+ };
1935
2024
  }
1936
- return tasks;
2025
+ if (foreign.length > 0) {
2026
+ return { ok: false, error: `Not a child of this parent: ${foreign.join(", ")}` };
2027
+ }
2028
+ const requestedSet = new Set(requested);
2029
+ const missing = activeIds.filter((id) => !requestedSet.has(id));
2030
+ if (missing.length > 0) {
2031
+ return {
2032
+ ok: false,
2033
+ error: `The order must list every visible child. Missing: ${missing.join(", ")}`
2034
+ };
2035
+ }
2036
+ return { ok: true, childIds: [...requested, ...archivedIds] };
2037
+ }
2038
+
2039
+ // src/commands/pages.ts
2040
+ async function fetchChildrenSorted(config, parentId) {
2041
+ const { documents } = await apiFetch(
2042
+ config,
2043
+ `/documents?parent_id=${encodeURIComponent(parentId)}&archived=include&limit=all`
2044
+ );
2045
+ return [...documents].sort(
2046
+ (a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || (a.title ?? "").localeCompare(b.title ?? "")
2047
+ );
2048
+ }
2049
+ async function loadChildren(config, parentId) {
2050
+ const sorted = await fetchChildrenSorted(config, parentId);
2051
+ return {
2052
+ activeIds: sorted.filter((d) => !d.archived).map((d) => d.id),
2053
+ archivedIds: sorted.filter((d) => d.archived).map((d) => d.id)
2054
+ };
2055
+ }
2056
+ async function writeOrder(config, parentId, childIds) {
2057
+ await apiFetch(config, `/documents/${parentId}/children/order`, {
2058
+ method: "PUT",
2059
+ body: JSON.stringify({ child_ids: childIds })
2060
+ });
2061
+ }
2062
+ function fail(spin, message) {
2063
+ spin.stop();
2064
+ console.error(c.rose(message));
2065
+ process.exit(1);
2066
+ }
2067
+ async function pagesList(config, parentRef, json) {
2068
+ const spin = spinner("Loading pages\u2026");
2069
+ spin.start();
2070
+ const parentId = (await resolveSlug(config, parentRef)).id;
2071
+ const children = await fetchChildrenSorted(config, parentId);
2072
+ spin.stop();
2073
+ const visible = children.filter((d) => !d.archived);
2074
+ const archivedCount = children.length - visible.length;
2075
+ if (json) {
2076
+ console.log(JSON.stringify(visible));
2077
+ return;
2078
+ }
2079
+ if (visible.length === 0) {
2080
+ console.log(
2081
+ c.muted(
2082
+ archivedCount > 0 ? `No visible child pages (${archivedCount} archived hidden).` : "No child pages."
2083
+ )
2084
+ );
2085
+ return;
2086
+ }
2087
+ const rows = visible.map((d, i) => [c.muted(`${i + 1}`), d.title ?? "", c.muted(d.slug ?? "")]);
2088
+ console.log(table(rows, { header: [c.muted("#"), "Title", c.muted("Slug")] }));
2089
+ if (archivedCount > 0) {
2090
+ console.log(c.muted(`
2091
+ ${archivedCount} archived page${archivedCount === 1 ? "" : "s"} hidden`));
2092
+ }
2093
+ }
2094
+ async function pagesOrder(config, parentRef, childRefs, json) {
2095
+ const spin = spinner("Setting page order\u2026");
2096
+ spin.start();
2097
+ const parentId = (await resolveSlug(config, parentRef)).id;
2098
+ const requested = await Promise.all(childRefs.map(async (r) => (await resolveSlug(config, r)).id));
2099
+ const { activeIds, archivedIds } = await loadChildren(config, parentId);
2100
+ const order = resolveChildOrder({ activeIds, archivedIds, requested });
2101
+ if (!order.ok) fail(spin, order.error);
2102
+ await writeOrder(config, parentId, order.childIds);
2103
+ spin.stop();
2104
+ if (json) {
2105
+ console.log(JSON.stringify({ ok: true, child_ids: order.childIds }));
2106
+ return;
2107
+ }
2108
+ const n = order.childIds.length;
2109
+ console.log(`${c.teal("\u2713")} Reordered ${n} page${n === 1 ? "" : "s"}`);
2110
+ }
2111
+ async function pagesMove(config, childRef, position, targetRef, json) {
2112
+ if (position !== "before" && position !== "after") {
2113
+ console.error(c.rose(`Position must be "before" or "after", got "${position}".`));
2114
+ process.exit(1);
2115
+ }
2116
+ const spin = spinner("Moving page\u2026");
2117
+ spin.start();
2118
+ const child = await resolveSlug(config, childRef);
2119
+ const target = await resolveSlug(config, targetRef);
2120
+ if (child.id === target.id) fail(spin, "Can't move a page relative to itself.");
2121
+ if (!child.parent_id) fail(spin, `"${child.title}" is not a child page - it has no parent.`);
2122
+ if (target.parent_id !== child.parent_id) {
2123
+ fail(spin, `"${target.title}" is not a sibling of "${child.title}".`);
2124
+ }
2125
+ const { activeIds, archivedIds } = await loadChildren(config, child.parent_id);
2126
+ if (!activeIds.includes(child.id)) {
2127
+ fail(spin, `"${child.title}" is archived - unarchive it before reordering.`);
2128
+ }
2129
+ if (!activeIds.includes(target.id)) {
2130
+ fail(spin, `"${target.title}" is archived - pick a visible sibling as the target.`);
2131
+ }
2132
+ const without = activeIds.filter((id) => id !== child.id);
2133
+ const targetIdx = without.indexOf(target.id);
2134
+ const insertIdx = position === "before" ? targetIdx : targetIdx + 1;
2135
+ without.splice(insertIdx, 0, child.id);
2136
+ const order = resolveChildOrder({ activeIds, archivedIds, requested: without });
2137
+ if (!order.ok) fail(spin, order.error);
2138
+ await writeOrder(config, child.parent_id, order.childIds);
2139
+ spin.stop();
2140
+ if (json) {
2141
+ console.log(JSON.stringify({ ok: true, child_ids: order.childIds }));
2142
+ return;
2143
+ }
2144
+ console.log(`${c.teal("\u2713")} Moved "${child.title}" ${position} "${target.title}"`);
1937
2145
  }
1938
2146
 
1939
2147
  // src/cli.ts
1940
- var VERSION = true ? "0.3.0" : "0.0.0-dev";
2148
+ var VERSION = true ? "0.3.1" : "0.0.0-dev";
1941
2149
  function brandedHelp() {
1942
2150
  return `
1943
2151
  ${c.amber("artyfax")} ${c.muted(`v${VERSION}`)} \u2014 your personal document library
@@ -1959,6 +2167,8 @@ ${c.bright("Documents")}
1959
2167
  unsecure <slug> Remove encryption
1960
2168
  cover <doc> <file> Set a document's cover image from a local file
1961
2169
  image <doc> <file> Upload a local image, print the markdown to embed
2170
+ publish <doc> Pin a published version (public/shared views serve it)
2171
+ unpublish <doc> Clear the published pointer, revert to serving live
1962
2172
 
1963
2173
  ${c.bright("Sub-resources")}
1964
2174
  share create <slug> Create a share link
@@ -2040,7 +2250,11 @@ ${c.bright("Options:")}
2040
2250
  ${c.bright("Examples:")}
2041
2251
  artyfax save notes.md --category inbox
2042
2252
  artyfax save --url https://example.com/article --category articles
2043
- artyfax save report.md --secure --category reports`,
2253
+ artyfax save report.md --secure --category reports
2254
+
2255
+ ${c.bright("Markdown:")} mermaid diagrams render (\`\`\`mermaid fences); colour flowchart
2256
+ nodes on-brand with \`class <node> amber|teal|blue|violet|rose\` rather than raw hex.
2257
+ Full syntax: the editor's Markdown Reference, or the seeded "Markdown Formatting" doc.`,
2044
2258
  read: `${c.amber("artyfax read")} \u2014 read document content
2045
2259
 
2046
2260
  ${c.bright("Usage:")} artyfax read <slug>
@@ -2179,6 +2393,25 @@ ${c.bright("Usage:")} artyfax version list <slug> Show version history
2179
2393
  ${c.bright("Examples:")}
2180
2394
  artyfax version list inbox/my-doc
2181
2395
  artyfax version restore inbox/my-doc --yes`,
2396
+ publish: `${c.amber("artyfax publish")} \u2014 pin a published version
2397
+
2398
+ ${c.bright("Usage:")} artyfax publish <doc> [version-id]
2399
+
2400
+ Public and shared views serve the pinned version while later edits stay draft
2401
+ until you publish again. With no version-id, publishes the current draft;
2402
+ pass a version id (from ${c.amber("artyfax version list")}) to publish an older state.
2403
+
2404
+ ${c.bright("Examples:")}
2405
+ artyfax publish plans/my-post
2406
+ artyfax publish AX-12 1843`,
2407
+ unpublish: `${c.amber("artyfax unpublish")} \u2014 clear the published pointer
2408
+
2409
+ ${c.bright("Usage:")} artyfax unpublish <doc>
2410
+
2411
+ Public and shared views revert to serving the live document.
2412
+
2413
+ ${c.bright("Examples:")}
2414
+ artyfax unpublish plans/my-post`,
2182
2415
  annotation: `${c.amber("artyfax annotation")} \u2014 read a document's annotations (highlights, underlines, strikethroughs, notes, comments, redactions)
2183
2416
 
2184
2417
  ${c.bright("Usage:")} artyfax annotation list <slug> List annotations (--include-recipients, --since <iso>)
@@ -2198,7 +2431,20 @@ ${c.bright("Usage:")} artyfax doctor [--json]`,
2198
2431
 
2199
2432
  ${c.bright("Usage:")} artyfax skill install [--project] Install Artyfax skill
2200
2433
  artyfax skill status Check installation
2201
- artyfax skill update Update to latest version`
2434
+ artyfax skill update Update to latest version`,
2435
+ pages: `${c.amber("artyfax pages")} \u2014 list and reorder a parent document's child pages
2436
+
2437
+ ${c.bright("Usage:")} artyfax pages list <parent> Show child pages in reading order
2438
+ artyfax pages order <parent> <child...> Set the full reading order
2439
+ artyfax pages move <child> before|after <target> Nudge one page past a sibling
2440
+
2441
+ Refs accept a document id, slug, or work id (e.g. ${c.muted("AX-12")}).
2442
+ ${c.bright("order")} must name every visible child once; archived pages keep their order automatically.
2443
+
2444
+ ${c.bright("Examples:")}
2445
+ artyfax pages list AX-94
2446
+ artyfax pages order AX-94 intro setup usage
2447
+ artyfax pages move usage before setup`
2202
2448
  };
2203
2449
  function parseArgs(args) {
2204
2450
  const flags = {};
@@ -2237,10 +2483,35 @@ function parseArgs(args) {
2237
2483
  }
2238
2484
  return { command, subcommand, positional, flags };
2239
2485
  }
2240
- var SUB_RESOURCES = /* @__PURE__ */ new Set(["share", "cat", "tag", "version", "annotation", "skill", "snippets", "work", "project", "workspace"]);
2486
+ var SUB_RESOURCES = /* @__PURE__ */ new Set(["share", "cat", "tag", "version", "annotation", "skill", "snippets", "work", "project", "workspace", "pages"]);
2241
2487
  function isSubResource(cmd) {
2242
2488
  return SUB_RESOURCES.has(cmd);
2243
2489
  }
2490
+ var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
2491
+ "init",
2492
+ "save",
2493
+ "read",
2494
+ "list",
2495
+ "get",
2496
+ "search",
2497
+ "delete",
2498
+ "open",
2499
+ "secure",
2500
+ "unsecure",
2501
+ "update",
2502
+ "metadata",
2503
+ "cover",
2504
+ "image",
2505
+ "publish",
2506
+ "unpublish",
2507
+ "doctor",
2508
+ "task",
2509
+ "tasks",
2510
+ ...SUB_RESOURCES
2511
+ ]);
2512
+ function isKnownCommand(command) {
2513
+ return KNOWN_COMMANDS.has(command);
2514
+ }
2244
2515
  async function main() {
2245
2516
  const parsed = parseArgs(process.argv.slice(2));
2246
2517
  const { command, subcommand, positional, flags } = parsed;
@@ -2262,36 +2533,7 @@ async function main() {
2262
2533
  console.log(brandedHelp());
2263
2534
  process.exit(0);
2264
2535
  }
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)) {
2536
+ if (!isKnownCommand(command)) {
2295
2537
  error(`Unknown command: ${command}`, `Run \`artyfax--help\` for available commands`);
2296
2538
  }
2297
2539
  if (command === "skill") {
@@ -2422,6 +2664,24 @@ async function main() {
2422
2664
  await open(config, slug);
2423
2665
  break;
2424
2666
  }
2667
+ case "publish": {
2668
+ const slug = positional[0];
2669
+ if (!slug) error("Missing slug argument", "Usage: artyfax publish <doc> [version-id]");
2670
+ const versionArg = positional[1];
2671
+ let version;
2672
+ if (versionArg !== void 0) {
2673
+ version = Number(versionArg);
2674
+ if (!Number.isInteger(version)) error(`Invalid version id: ${versionArg}`, "Usage: artyfax publish <doc> [version-id]");
2675
+ }
2676
+ await publish(config, slug, { version, json });
2677
+ break;
2678
+ }
2679
+ case "unpublish": {
2680
+ const slug = positional[0];
2681
+ if (!slug) error("Missing slug argument", "Usage: artyfax unpublish <doc>");
2682
+ await unpublish(config, slug, json);
2683
+ break;
2684
+ }
2425
2685
  case "secure": {
2426
2686
  const slug = positional[0];
2427
2687
  if (!slug) error("Missing slug argument", "Usage: artyfax secure <slug>");
@@ -2637,6 +2897,38 @@ async function main() {
2637
2897
  }
2638
2898
  break;
2639
2899
  }
2900
+ case "pages": {
2901
+ switch (subcommand) {
2902
+ case "list": {
2903
+ const parent = positional[0];
2904
+ if (!parent) error("Missing parent", "Usage: artyfax pages list <parent>");
2905
+ await pagesList(config, parent, json);
2906
+ break;
2907
+ }
2908
+ case "order": {
2909
+ const parent = positional[0];
2910
+ const childRefs = positional.slice(1);
2911
+ if (!parent || childRefs.length === 0) {
2912
+ error("Missing arguments", "Usage: artyfax pages order <parent> <child...>");
2913
+ }
2914
+ await pagesOrder(config, parent, childRefs, json);
2915
+ break;
2916
+ }
2917
+ case "move": {
2918
+ const child = positional[0];
2919
+ const position = positional[1];
2920
+ const target = positional[2];
2921
+ if (!child || !position || !target) {
2922
+ error("Missing arguments", "Usage: artyfax pages move <child> before|after <target>");
2923
+ }
2924
+ await pagesMove(config, child, position, target, json);
2925
+ break;
2926
+ }
2927
+ default:
2928
+ error(`Unknown pages subcommand: ${subcommand || "(none)"}`, "Usage: artyfax pages <list|order|move>");
2929
+ }
2930
+ break;
2931
+ }
2640
2932
  case "work": {
2641
2933
  switch (subcommand) {
2642
2934
  case "":
@@ -2673,8 +2965,13 @@ async function main() {
2673
2965
  await workCreate(config, title, flags, json);
2674
2966
  break;
2675
2967
  }
2968
+ case "touch": {
2969
+ if (positional.length === 0) error("Missing work item ID(s)", "Usage: artyfax work touch <id...> [--action editing|building|reviewing|running]");
2970
+ await workTouch(config, positional, flags, json);
2971
+ break;
2972
+ }
2676
2973
  default:
2677
- error(`Unknown work subcommand: ${subcommand}`, "Usage: artyfax work <list|next|start|done|status|create>");
2974
+ error(`Unknown work subcommand: ${subcommand}`, "Usage: artyfax work <list|next|start|done|status|create|touch>");
2678
2975
  }
2679
2976
  break;
2680
2977
  }
@@ -2732,5 +3029,6 @@ if (isDirectRun()) {
2732
3029
  }
2733
3030
  export {
2734
3031
  VERSION,
3032
+ isKnownCommand,
2735
3033
  parseArgs
2736
3034
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "artyfax",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "CLI for Artyfax - your personal document library. Save, theme, search, and share.",
5
5
  "type": "module",
6
6
  "bin": {