artyfax 0.2.3 → 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.
- package/README.md +69 -36
- package/dist/cli.js +1847 -269
- 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
|
|
5
|
-
import { resolve as
|
|
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
|
|
34
|
+
endpoint,
|
|
17
35
|
passphrase: process.env.ARTYFAX_SECURE_PASSPHRASE || null
|
|
18
36
|
};
|
|
19
37
|
}
|
|
20
|
-
async function apiFetch(config, path,
|
|
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
|
-
...
|
|
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
|
-
...
|
|
49
|
+
...init2?.headers
|
|
31
50
|
}
|
|
32
51
|
});
|
|
33
52
|
if (!res.ok) {
|
|
34
|
-
const
|
|
35
|
-
|
|
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((
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
242
|
+
resolve6(answer.trim().toLowerCase() === "y");
|
|
212
243
|
});
|
|
213
244
|
});
|
|
214
245
|
}
|
|
@@ -282,19 +313,28 @@ ${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/${
|
|
318
|
+
const doc = await apiFetch(config, `/documents/by-slug/${encodedSlug}`);
|
|
287
319
|
return doc;
|
|
288
320
|
} catch {
|
|
289
321
|
}
|
|
290
322
|
}
|
|
291
|
-
if (
|
|
323
|
+
if (/^[A-Z]+-\d+$/.test(slugOrId)) {
|
|
292
324
|
try {
|
|
293
|
-
const
|
|
325
|
+
const { id } = await apiFetch(config, `/resolve/${encodeURIComponent(slugOrId)}`);
|
|
326
|
+
const doc = await apiFetch(config, `/documents/${id}`);
|
|
294
327
|
return doc;
|
|
295
328
|
} catch {
|
|
296
329
|
}
|
|
297
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
|
+
}
|
|
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 \`
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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,93 @@ 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
|
+
function readImageFile(file) {
|
|
691
|
+
const path = resolve2(file);
|
|
692
|
+
let stat;
|
|
693
|
+
try {
|
|
694
|
+
stat = statSync(path);
|
|
695
|
+
} catch {
|
|
696
|
+
throw new Error(`Image not found: ${path}`);
|
|
697
|
+
}
|
|
698
|
+
if (!stat.isFile()) throw new Error(`Not a file: ${path}`);
|
|
699
|
+
const ext = extname(path).toLowerCase();
|
|
700
|
+
const mime = MIME_BY_EXT[ext];
|
|
701
|
+
if (!mime) {
|
|
702
|
+
throw new Error(`Unsupported image extension: ${ext}. Allowed: ${Object.keys(MIME_BY_EXT).join(", ")}`);
|
|
703
|
+
}
|
|
704
|
+
if (stat.size > MAX_BYTES) {
|
|
705
|
+
throw new Error(`Image exceeds 5 MB limit (${formatBytes(stat.size)})`);
|
|
706
|
+
}
|
|
707
|
+
return { bytes: readFileSync2(path), name: basename(path), mime };
|
|
708
|
+
}
|
|
709
|
+
async function uploadImage(config, docId, file, kind) {
|
|
710
|
+
const form = new FormData();
|
|
711
|
+
form.set("file", new File([file.bytes], file.name, { type: file.mime }));
|
|
712
|
+
const query = kind === "cover" ? "?kind=cover" : "";
|
|
713
|
+
return apiFetch(config, `/documents/${docId}/images${query}`, {
|
|
714
|
+
method: "POST",
|
|
715
|
+
body: form
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
async function setCover(config, slugOrId, file, opts) {
|
|
719
|
+
const image = readImageFile(file);
|
|
720
|
+
const spin = spinner("Resolving document\u2026");
|
|
721
|
+
spin.start();
|
|
722
|
+
const doc = await resolveSlug(config, slugOrId);
|
|
723
|
+
spin.text = `Uploading ${image.name}\u2026`;
|
|
724
|
+
const uploaded = await uploadImage(config, doc.id, image, "cover");
|
|
725
|
+
spin.text = "Setting cover\u2026";
|
|
726
|
+
const patch = { cover_image: uploaded.path };
|
|
727
|
+
if (typeof opts.alt === "string") patch.cover_alt = opts.alt;
|
|
728
|
+
await apiFetch(config, `/documents/${doc.id}/metadata`, {
|
|
729
|
+
method: "PATCH",
|
|
730
|
+
body: JSON.stringify(patch)
|
|
731
|
+
});
|
|
732
|
+
spin.stop();
|
|
733
|
+
if (opts.json) {
|
|
734
|
+
console.log(JSON.stringify({ ok: true, id: doc.id, slug: doc.slug, cover_image: uploaded.path }));
|
|
735
|
+
} else {
|
|
736
|
+
console.log(`${c.teal("Cover set")} on ${c.bright(doc.slug)} ${c.muted(`(${formatBytes(image.bytes.length)})`)}`);
|
|
737
|
+
console.log(c.muted(" Tip: covers render best at 1200\xD7630 (hero + social card shape)."));
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
async function addImage(config, slugOrId, file, opts) {
|
|
741
|
+
const image = readImageFile(file);
|
|
742
|
+
const spin = spinner("Resolving document\u2026");
|
|
743
|
+
spin.start();
|
|
744
|
+
const doc = await resolveSlug(config, slugOrId);
|
|
745
|
+
spin.text = `Uploading ${image.name}\u2026`;
|
|
746
|
+
const uploaded = await uploadImage(config, doc.id, image, "content");
|
|
747
|
+
spin.stop();
|
|
748
|
+
const alt = typeof opts.alt === "string" ? opts.alt : "";
|
|
749
|
+
const snippet = ``;
|
|
750
|
+
if (opts.json) {
|
|
751
|
+
console.log(JSON.stringify({ ok: true, id: doc.id, slug: doc.slug, path: uploaded.path, markdown: snippet }));
|
|
752
|
+
} else {
|
|
753
|
+
console.log(`${c.teal("Uploaded")} to ${c.bright(doc.slug)} ${c.muted(`(${formatBytes(image.bytes.length)})`)}`);
|
|
754
|
+
console.log(c.muted(" Paste this into the document body:"));
|
|
755
|
+
console.log(` ${snippet}`);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
589
759
|
// src/commands/share.ts
|
|
590
760
|
async function shareCreate(config, slugOrId, json) {
|
|
591
761
|
const spin = spinner("Resolving document\u2026");
|
|
@@ -601,9 +771,13 @@ async function shareCreate(config, slugOrId, json) {
|
|
|
601
771
|
}
|
|
602
772
|
);
|
|
603
773
|
spin.stop();
|
|
604
|
-
const
|
|
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}`;
|
|
605
779
|
if (json) {
|
|
606
|
-
console.log(JSON.stringify({ hash
|
|
780
|
+
console.log(JSON.stringify({ hash, url, slug: doc.slug }));
|
|
607
781
|
} else {
|
|
608
782
|
console.log(`${c.teal("Share created:")} ${url}`);
|
|
609
783
|
}
|
|
@@ -690,11 +864,49 @@ async function tagList(config, json) {
|
|
|
690
864
|
console.log(c.muted("No tags found."));
|
|
691
865
|
return;
|
|
692
866
|
}
|
|
693
|
-
const
|
|
694
|
-
|
|
867
|
+
const namespaces = data.namespaces ?? [];
|
|
868
|
+
const simple = data.simple ?? [];
|
|
869
|
+
for (const ns of namespaces) {
|
|
870
|
+
const heading = ns.intent ? `${c.bright(ns.prefix + ":")} ${c.muted(ns.intent)}` : c.bright(ns.prefix + ":");
|
|
871
|
+
console.log("\n" + heading);
|
|
872
|
+
const rows = ns.tags.map((t) => [t.tag, String(t.count)]);
|
|
873
|
+
console.log(table(rows, { header: ["Tag", "Count"] }));
|
|
874
|
+
}
|
|
875
|
+
if (simple.length > 0) {
|
|
876
|
+
console.log("\n" + c.bright("(no namespace)"));
|
|
877
|
+
const rows = simple.map((t) => [t.tag, String(t.count)]);
|
|
878
|
+
console.log(table(rows, { header: ["Tag", "Count"] }));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
async function tagDelete(config, tag, json) {
|
|
882
|
+
const spin = spinner(`Deleting tag \u201C${tag}\u201D\u2026`);
|
|
883
|
+
spin.start();
|
|
884
|
+
const data = await apiFetch(
|
|
885
|
+
config,
|
|
886
|
+
"/tags/delete",
|
|
887
|
+
{ method: "POST", body: JSON.stringify({ tag }) }
|
|
888
|
+
);
|
|
889
|
+
spin.stop();
|
|
890
|
+
if (json) {
|
|
891
|
+
console.log(JSON.stringify(data));
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
const { documents, annotations, snippets } = data.updated;
|
|
895
|
+
const total = documents + annotations + snippets;
|
|
896
|
+
if (total === 0) {
|
|
897
|
+
console.log(c.muted(`No items carried \u201C${tag}\u201D.`));
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
console.log(
|
|
901
|
+
c.teal(`Removed \u201C${tag}\u201D from ${total} item(s)`) + c.muted(` (${documents} docs, ${annotations} annotations, ${snippets} snippets)`)
|
|
902
|
+
);
|
|
695
903
|
}
|
|
696
904
|
|
|
697
905
|
// src/commands/version.ts
|
|
906
|
+
function fmtTs(ts) {
|
|
907
|
+
if (!ts) return "\u2014";
|
|
908
|
+
return ts.replace("T", " ").replace(/\.\d+Z$/, "");
|
|
909
|
+
}
|
|
698
910
|
async function versionList(config, slugOrId, json) {
|
|
699
911
|
const spin = spinner("Resolving document\u2026");
|
|
700
912
|
spin.start();
|
|
@@ -706,18 +918,24 @@ async function versionList(config, slugOrId, json) {
|
|
|
706
918
|
console.log(JSON.stringify(data.versions));
|
|
707
919
|
return;
|
|
708
920
|
}
|
|
709
|
-
|
|
710
|
-
|
|
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}).`)}`);
|
|
711
925
|
return;
|
|
712
926
|
}
|
|
713
|
-
console.log(
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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") : ""
|
|
719
937
|
]);
|
|
720
|
-
console.log(table(rows, { header: ["", "
|
|
938
|
+
console.log(table(rows, { header: ["", "Saved", "Source", ""] }));
|
|
721
939
|
}
|
|
722
940
|
async function versionRestore(config, slugOrId, yes, json) {
|
|
723
941
|
const spin = spinner("Resolving document\u2026");
|
|
@@ -726,25 +944,26 @@ async function versionRestore(config, slugOrId, yes, json) {
|
|
|
726
944
|
spin.text = "Loading versions\u2026";
|
|
727
945
|
const data = await apiFetch(config, `/documents/${doc.id}/versions`);
|
|
728
946
|
spin.stop();
|
|
729
|
-
|
|
947
|
+
const versions = data.versions ?? [];
|
|
948
|
+
if (versions.length === 0) {
|
|
730
949
|
console.log(c.muted("No previous versions to restore."));
|
|
731
950
|
return;
|
|
732
951
|
}
|
|
733
|
-
|
|
734
|
-
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"}:
|
|
735
953
|
`);
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
const label = `v${
|
|
739
|
-
const
|
|
740
|
-
const
|
|
741
|
-
console.log(` ${c.amber(label)} ${
|
|
742
|
-
}
|
|
743
|
-
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
|
+
});
|
|
744
961
|
console.log();
|
|
962
|
+
const target = versions[0];
|
|
745
963
|
if (!yes) {
|
|
746
|
-
const
|
|
747
|
-
|
|
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
|
+
);
|
|
748
967
|
if (!confirmed) {
|
|
749
968
|
console.log(c.muted("Cancelled."));
|
|
750
969
|
return;
|
|
@@ -754,87 +973,102 @@ async function versionRestore(config, slugOrId, yes, json) {
|
|
|
754
973
|
restoreSpin.start();
|
|
755
974
|
await apiFetch(config, `/documents/${doc.id}/versions/restore`, {
|
|
756
975
|
method: "POST",
|
|
757
|
-
|
|
976
|
+
// The restore endpoint keys on the numeric version id, not a timestamp.
|
|
977
|
+
body: JSON.stringify({ version_id: target.id })
|
|
758
978
|
});
|
|
759
979
|
restoreSpin.stop();
|
|
760
980
|
if (json) {
|
|
761
|
-
console.log(JSON.stringify({ restored: true, slug: doc.slug,
|
|
981
|
+
console.log(JSON.stringify({ restored: true, slug: doc.slug, version_id: target.id, created_at: target.created_at }));
|
|
762
982
|
} else {
|
|
763
|
-
console.log(`${c.teal("Restored:")} ${doc.slug} to ${target.
|
|
983
|
+
console.log(`${c.teal("Restored:")} ${doc.slug} to v${n} (${fmtTs(target.created_at)})`);
|
|
764
984
|
}
|
|
765
985
|
}
|
|
766
986
|
|
|
767
|
-
// src/commands/
|
|
768
|
-
async function
|
|
987
|
+
// src/commands/publish.ts
|
|
988
|
+
async function publish(config, slugOrId, opts) {
|
|
769
989
|
const spin = spinner("Resolving document\u2026");
|
|
770
990
|
spin.start();
|
|
771
991
|
const doc = await resolveSlug(config, slugOrId);
|
|
772
|
-
spin.text =
|
|
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;
|
|
773
995
|
const data = await apiFetch(
|
|
774
996
|
config,
|
|
775
|
-
`/
|
|
997
|
+
`/documents/${doc.id}/publish`,
|
|
998
|
+
{ method: "POST", body: JSON.stringify(body) }
|
|
776
999
|
);
|
|
777
1000
|
spin.stop();
|
|
778
|
-
if (json) {
|
|
779
|
-
console.log(JSON.stringify(data
|
|
1001
|
+
if (opts.json) {
|
|
1002
|
+
console.log(JSON.stringify({ published: true, slug: doc.slug, ...data }));
|
|
780
1003
|
return;
|
|
781
1004
|
}
|
|
782
|
-
|
|
783
|
-
|
|
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 }));
|
|
784
1022
|
return;
|
|
785
1023
|
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
const type = a.type === "highlight" ? c.amber("highlight") : a.type === "comment" ? c.blue("comment") : c.muted(a.type);
|
|
790
|
-
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" : ""}`);
|
|
794
|
-
console.log();
|
|
1024
|
+
if (data.was_published === false) {
|
|
1025
|
+
console.log(c.muted(`${doc.slug} was not published - nothing to do.`));
|
|
1026
|
+
return;
|
|
795
1027
|
}
|
|
1028
|
+
console.log(`${c.teal("Unpublished:")} ${doc.slug}`);
|
|
1029
|
+
console.log(c.muted("Public and shared views now serve the live document again."));
|
|
796
1030
|
}
|
|
797
|
-
|
|
1031
|
+
|
|
1032
|
+
// src/commands/annotation.ts
|
|
1033
|
+
async function annotationList(config, slugOrId, json, opts = {}) {
|
|
798
1034
|
const spin = spinner("Resolving document\u2026");
|
|
799
1035
|
spin.start();
|
|
800
1036
|
const doc = await resolveSlug(config, slugOrId);
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
chunks.push(chunk);
|
|
813
|
-
}
|
|
814
|
-
body = Buffer.concat(chunks).toString("utf-8").trim();
|
|
815
|
-
spin.start();
|
|
1037
|
+
spin.text = "Loading annotations\u2026";
|
|
1038
|
+
const qs = new URLSearchParams();
|
|
1039
|
+
qs.set("include_recipients", opts.includeRecipients ? "true" : "false");
|
|
1040
|
+
if (opts.since) qs.set("since", opts.since);
|
|
1041
|
+
const path = `/annotations/doc/${doc.id}${qs.toString() ? `?${qs.toString()}` : ""}`;
|
|
1042
|
+
const data = await apiFetch(config, path);
|
|
1043
|
+
spin.stop();
|
|
1044
|
+
const annotations = Array.isArray(data) ? data : data.annotations ?? [];
|
|
1045
|
+
if (json) {
|
|
1046
|
+
console.log(JSON.stringify(annotations, null, 2));
|
|
1047
|
+
return;
|
|
816
1048
|
}
|
|
817
|
-
if (
|
|
818
|
-
|
|
819
|
-
|
|
1049
|
+
if (annotations.length === 0) {
|
|
1050
|
+
console.log(c.muted("No annotations found."));
|
|
1051
|
+
return;
|
|
820
1052
|
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
`/annotations/doc/${doc.id}`,
|
|
825
|
-
{
|
|
826
|
-
method: "POST",
|
|
827
|
-
body: JSON.stringify({ type: "comment", body })
|
|
828
|
-
}
|
|
1053
|
+
console.log(
|
|
1054
|
+
`${c.bright(doc.slug)} \u2014 ${annotations.length} annotation${annotations.length === 1 ? "" : "s"}
|
|
1055
|
+
`
|
|
829
1056
|
);
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
console.log(
|
|
1057
|
+
for (const a of annotations) {
|
|
1058
|
+
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);
|
|
1059
|
+
const date = c.muted(a.created_at.slice(0, 10));
|
|
1060
|
+
const authorTag = a.author === "recipient" ? c.muted(` \xB7 ${a.share_link_label ?? "recipient"}`) : "";
|
|
1061
|
+
console.log(` ${typeLabel} ${date}${authorTag}`);
|
|
1062
|
+
if (a.quoted_text) {
|
|
1063
|
+
console.log(` ${c.muted(">")} ${a.quoted_text.slice(0, 80)}${a.quoted_text.length > 80 ? "\u2026" : ""}`);
|
|
1064
|
+
}
|
|
1065
|
+
if (a.note) {
|
|
1066
|
+
console.log(` ${a.note.slice(0, 120)}${a.note.length > 120 ? "\u2026" : ""}`);
|
|
1067
|
+
}
|
|
1068
|
+
console.log();
|
|
835
1069
|
}
|
|
836
1070
|
}
|
|
837
|
-
async function
|
|
1071
|
+
async function annotationSearch(config, query, json) {
|
|
838
1072
|
const spin = spinner(`Searching annotations for "${query}"\u2026`);
|
|
839
1073
|
spin.start();
|
|
840
1074
|
const data = await apiFetch(
|
|
@@ -842,22 +1076,290 @@ async function noteSearch(config, query, json) {
|
|
|
842
1076
|
`/annotations/search?q=${encodeURIComponent(query)}`
|
|
843
1077
|
);
|
|
844
1078
|
spin.stop();
|
|
1079
|
+
const annotations = Array.isArray(data) ? data : data.annotations ?? [];
|
|
845
1080
|
if (json) {
|
|
846
|
-
console.log(JSON.stringify(
|
|
1081
|
+
console.log(JSON.stringify(annotations, null, 2));
|
|
847
1082
|
return;
|
|
848
1083
|
}
|
|
849
|
-
if (
|
|
1084
|
+
if (annotations.length === 0) {
|
|
850
1085
|
console.log(c.muted(`No annotation results for "${query}".`));
|
|
851
1086
|
return;
|
|
852
1087
|
}
|
|
853
|
-
const rows =
|
|
854
|
-
a.
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1088
|
+
const rows = annotations.map((a) => {
|
|
1089
|
+
const text = a.note ?? a.quoted_text ?? "";
|
|
1090
|
+
return [
|
|
1091
|
+
a.type === "highlight" ? c.amber("HL") : a.type === "note" ? c.blue("NT") : c.muted(a.type.slice(0, 2).toUpperCase()),
|
|
1092
|
+
text.slice(0, 60) + (text.length > 60 ? "\u2026" : ""),
|
|
1093
|
+
c.muted(a.created_at.slice(0, 10))
|
|
1094
|
+
];
|
|
1095
|
+
});
|
|
858
1096
|
console.log(table(rows, { header: ["", "Text", c.muted("Date")] }));
|
|
859
1097
|
console.log(c.muted(`
|
|
860
|
-
${
|
|
1098
|
+
${annotations.length} result${annotations.length === 1 ? "" : "s"}`));
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// src/commands/snippet.ts
|
|
1102
|
+
import { readFileSync as readFileSync3, statSync as statSync2 } from "fs";
|
|
1103
|
+
import { extname as extname2, basename as basename2, resolve as resolve3 } from "path";
|
|
1104
|
+
var MIME_BY_EXT2 = {
|
|
1105
|
+
".png": "image/png",
|
|
1106
|
+
".jpg": "image/jpeg",
|
|
1107
|
+
".jpeg": "image/jpeg",
|
|
1108
|
+
".webp": "image/webp",
|
|
1109
|
+
".gif": "image/gif",
|
|
1110
|
+
".svg": "image/svg+xml"
|
|
1111
|
+
};
|
|
1112
|
+
function parseTags(raw) {
|
|
1113
|
+
if (!raw) return [];
|
|
1114
|
+
if (Array.isArray(raw)) return raw;
|
|
1115
|
+
try {
|
|
1116
|
+
const parsed = JSON.parse(raw);
|
|
1117
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1118
|
+
} catch {
|
|
1119
|
+
return [];
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
function typeBadge(type) {
|
|
1123
|
+
if (type === "quote") return c.amber("quote");
|
|
1124
|
+
if (type === "code") return c.blue("code ");
|
|
1125
|
+
if (type === "image") return c.teal("image");
|
|
1126
|
+
return c.muted(type);
|
|
1127
|
+
}
|
|
1128
|
+
function formatBytes2(bytes) {
|
|
1129
|
+
if (!bytes) return "";
|
|
1130
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1131
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1132
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
1133
|
+
}
|
|
1134
|
+
function safeHostname(url) {
|
|
1135
|
+
if (!url) return "";
|
|
1136
|
+
try {
|
|
1137
|
+
return new URL(url).hostname.replace(/^www\./, "");
|
|
1138
|
+
} catch {
|
|
1139
|
+
return "";
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
async function snippetList(config, options, json) {
|
|
1143
|
+
const spin = spinner("Loading snippets\u2026");
|
|
1144
|
+
spin.start();
|
|
1145
|
+
const qs = new URLSearchParams();
|
|
1146
|
+
if (options.type) qs.set("type", options.type);
|
|
1147
|
+
if (options.starred) qs.set("starred", "1");
|
|
1148
|
+
if (options.tag) qs.set("tag", options.tag);
|
|
1149
|
+
if (options.limit) qs.set("limit", String(options.limit));
|
|
1150
|
+
const data = await apiFetch(
|
|
1151
|
+
config,
|
|
1152
|
+
`/snippets${qs.toString() ? `?${qs.toString()}` : ""}`
|
|
1153
|
+
);
|
|
1154
|
+
spin.stop();
|
|
1155
|
+
if (json) {
|
|
1156
|
+
console.log(JSON.stringify(data));
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
const items = Array.isArray(data) ? data : data.items ?? [];
|
|
1160
|
+
const total = Array.isArray(data) ? data.length : data.total ?? items.length;
|
|
1161
|
+
if (items.length === 0) {
|
|
1162
|
+
console.log(c.muted("No snippets found."));
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
const rows = items.map((s) => {
|
|
1166
|
+
const star = s.starred ? c.amber("\u2605") : " ";
|
|
1167
|
+
const preview = s.type === "image" ? s.alt || "(image)" : s.content.split("\n")[0];
|
|
1168
|
+
const trimmed = preview.length > 60 ? preview.slice(0, 60) + "\u2026" : preview;
|
|
1169
|
+
const source = s.source_site_name || safeHostname(s.source_url);
|
|
1170
|
+
return [
|
|
1171
|
+
star,
|
|
1172
|
+
typeBadge(s.type),
|
|
1173
|
+
c.bright(s.id),
|
|
1174
|
+
trimmed,
|
|
1175
|
+
c.muted(source || ""),
|
|
1176
|
+
c.muted(s.created_at.slice(0, 10))
|
|
1177
|
+
];
|
|
1178
|
+
});
|
|
1179
|
+
console.log(table(rows, { header: ["", "", "ID", "Preview", "Source", c.muted("Date")] }));
|
|
1180
|
+
console.log(c.muted(`
|
|
1181
|
+
${total} snippet${total === 1 ? "" : "s"} total`));
|
|
1182
|
+
}
|
|
1183
|
+
async function snippetSearch(config, query, options, json) {
|
|
1184
|
+
const spin = spinner(`Searching snippets for "${query}"\u2026`);
|
|
1185
|
+
spin.start();
|
|
1186
|
+
const qs = new URLSearchParams({ q: query });
|
|
1187
|
+
if (options.type) qs.set("type", options.type);
|
|
1188
|
+
if (options.limit) qs.set("limit", String(options.limit));
|
|
1189
|
+
const data = await apiFetch(config, `/snippets/search?${qs.toString()}`);
|
|
1190
|
+
spin.stop();
|
|
1191
|
+
const items = Array.isArray(data) ? data : data.items ?? [];
|
|
1192
|
+
if (json) {
|
|
1193
|
+
console.log(JSON.stringify(items));
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
if (items.length === 0) {
|
|
1197
|
+
console.log(c.muted(`No snippet results for "${query}".`));
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
for (const s of items) {
|
|
1201
|
+
const star = s.starred ? c.amber("\u2605 ") : "";
|
|
1202
|
+
const tags = parseTags(s.tags);
|
|
1203
|
+
const tagStr = tags.length ? " " + tags.map((t) => c.muted(`#${t}`)).join(" ") : "";
|
|
1204
|
+
console.log(`${star}${typeBadge(s.type)} ${c.bright(s.id)}${tagStr}`);
|
|
1205
|
+
if (s.type === "image") {
|
|
1206
|
+
console.log(` ${c.muted("alt:")} ${s.alt || "(none)"} ${c.muted(formatBytes2(s.byte_size))}`);
|
|
1207
|
+
} else {
|
|
1208
|
+
const lines = s.content.split("\n").slice(0, 3);
|
|
1209
|
+
for (const line of lines) {
|
|
1210
|
+
const trimmed = line.length > 100 ? line.slice(0, 100) + "\u2026" : line;
|
|
1211
|
+
console.log(` ${trimmed}`);
|
|
1212
|
+
}
|
|
1213
|
+
if (s.content.split("\n").length > 3) console.log(c.muted(" \u2026"));
|
|
1214
|
+
}
|
|
1215
|
+
if (s.note) console.log(` ${c.muted("note:")} ${s.note}`);
|
|
1216
|
+
if (s.source_url) console.log(` ${c.muted("source:")} ${s.source_url}`);
|
|
1217
|
+
console.log();
|
|
1218
|
+
}
|
|
1219
|
+
console.log(c.muted(`${items.length} result${items.length === 1 ? "" : "s"}`));
|
|
1220
|
+
}
|
|
1221
|
+
async function snippetShow(config, id, json) {
|
|
1222
|
+
const spin = spinner("Loading snippet\u2026");
|
|
1223
|
+
spin.start();
|
|
1224
|
+
const s = await apiFetch(config, `/snippets/${id}`);
|
|
1225
|
+
spin.stop();
|
|
1226
|
+
if (json) {
|
|
1227
|
+
console.log(JSON.stringify(s));
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
const tags = parseTags(s.tags);
|
|
1231
|
+
console.log(`${typeBadge(s.type)} ${c.bright(s.id)}${s.starred ? " " + c.amber("\u2605") : ""}`);
|
|
1232
|
+
console.log(c.muted(`created ${s.created_at}`));
|
|
1233
|
+
if (tags.length) console.log(c.muted("tags: ") + tags.map((t) => `#${t}`).join(" "));
|
|
1234
|
+
console.log();
|
|
1235
|
+
if (s.type === "image") {
|
|
1236
|
+
console.log(`${c.muted("alt:")} ${s.alt || "(none)"}`);
|
|
1237
|
+
console.log(`${c.muted("mime:")} ${s.mime || "(unknown)"}`);
|
|
1238
|
+
if (s.width && s.height) console.log(`${c.muted("size:")} ${s.width}\xD7${s.height} (${formatBytes2(s.byte_size)})`);
|
|
1239
|
+
console.log(`${c.muted("url:")} ${config.endpoint}/api/snippets/${s.id}/raw`);
|
|
1240
|
+
} else {
|
|
1241
|
+
if (s.type === "code" && s.language) console.log(c.muted(`language: ${s.language}
|
|
1242
|
+
`));
|
|
1243
|
+
console.log(s.content);
|
|
1244
|
+
}
|
|
1245
|
+
if (s.note) {
|
|
1246
|
+
console.log();
|
|
1247
|
+
console.log(c.muted("Note:"));
|
|
1248
|
+
console.log(s.note);
|
|
1249
|
+
}
|
|
1250
|
+
if (s.source_url || s.source_title || s.source_site_name || s.source_author) {
|
|
1251
|
+
console.log();
|
|
1252
|
+
console.log(c.muted("Source:"));
|
|
1253
|
+
if (s.source_title) console.log(` ${s.source_title}`);
|
|
1254
|
+
if (s.source_url) console.log(` ${c.muted(s.source_url)}`);
|
|
1255
|
+
const meta = [s.source_site_name, s.source_author].filter(Boolean).join(" \xB7 ");
|
|
1256
|
+
if (meta) console.log(` ${c.muted(meta)}`);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
async function snippetNew(config, options, json) {
|
|
1260
|
+
if (options.image) {
|
|
1261
|
+
const path = resolve3(options.image);
|
|
1262
|
+
let stat;
|
|
1263
|
+
try {
|
|
1264
|
+
stat = statSync2(path);
|
|
1265
|
+
} catch {
|
|
1266
|
+
throw new Error(`Image not found: ${path}`);
|
|
1267
|
+
}
|
|
1268
|
+
if (!stat.isFile()) throw new Error(`Not a file: ${path}`);
|
|
1269
|
+
const ext = extname2(path).toLowerCase();
|
|
1270
|
+
const mime = MIME_BY_EXT2[ext];
|
|
1271
|
+
if (!mime) {
|
|
1272
|
+
throw new Error(`Unsupported image extension: ${ext}. Allowed: ${Object.keys(MIME_BY_EXT2).join(", ")}`);
|
|
1273
|
+
}
|
|
1274
|
+
if (stat.size > 25 * 1024 * 1024) {
|
|
1275
|
+
throw new Error(`Image exceeds 25 MB limit (${formatBytes2(stat.size)})`);
|
|
1276
|
+
}
|
|
1277
|
+
const spin2 = spinner(`Uploading ${basename2(path)}\u2026`);
|
|
1278
|
+
spin2.start();
|
|
1279
|
+
const bytes = readFileSync3(path);
|
|
1280
|
+
const form = new FormData();
|
|
1281
|
+
form.set("metadata", JSON.stringify({
|
|
1282
|
+
alt: options.alt ?? null,
|
|
1283
|
+
note: options.note ?? null,
|
|
1284
|
+
source_url: options.sourceUrl ?? null,
|
|
1285
|
+
source_title: options.sourceTitle ?? null,
|
|
1286
|
+
source_site_name: options.sourceSiteName ?? null,
|
|
1287
|
+
source_author: options.sourceAuthor ?? null,
|
|
1288
|
+
tags: options.tags ?? [],
|
|
1289
|
+
starred: options.starred ? 1 : 0
|
|
1290
|
+
}));
|
|
1291
|
+
form.set("file", new File([bytes], basename2(path), { type: mime }));
|
|
1292
|
+
const result2 = await apiFetch(config, "/snippets", {
|
|
1293
|
+
method: "POST",
|
|
1294
|
+
body: form
|
|
1295
|
+
});
|
|
1296
|
+
spin2.stop();
|
|
1297
|
+
if (json) {
|
|
1298
|
+
console.log(JSON.stringify(result2));
|
|
1299
|
+
} else {
|
|
1300
|
+
console.log(`${c.teal("Created image snippet")} ${c.bright(result2.id)}`);
|
|
1301
|
+
}
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
let content = options.text;
|
|
1305
|
+
if (!content && options.file) {
|
|
1306
|
+
content = readFileSync3(resolve3(options.file), "utf-8");
|
|
1307
|
+
}
|
|
1308
|
+
if (!content && !process.stdin.isTTY) {
|
|
1309
|
+
const chunks = [];
|
|
1310
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
1311
|
+
content = Buffer.concat(chunks).toString("utf-8");
|
|
1312
|
+
}
|
|
1313
|
+
if (!content) {
|
|
1314
|
+
throw new Error('No content. Provide --text "...", --file <path>, or pipe content via stdin.');
|
|
1315
|
+
}
|
|
1316
|
+
const spin = spinner("Creating snippet\u2026");
|
|
1317
|
+
spin.start();
|
|
1318
|
+
const result = await apiFetch(config, "/snippets", {
|
|
1319
|
+
method: "POST",
|
|
1320
|
+
headers: { "Content-Type": "application/json" },
|
|
1321
|
+
body: JSON.stringify({
|
|
1322
|
+
type: options.type ?? "quote",
|
|
1323
|
+
content: content.trim(),
|
|
1324
|
+
language: options.language ?? null,
|
|
1325
|
+
note: options.note ?? null,
|
|
1326
|
+
source_url: options.sourceUrl ?? null,
|
|
1327
|
+
source_title: options.sourceTitle ?? null,
|
|
1328
|
+
source_site_name: options.sourceSiteName ?? null,
|
|
1329
|
+
source_author: options.sourceAuthor ?? null,
|
|
1330
|
+
tags: options.tags ?? [],
|
|
1331
|
+
starred: options.starred ? 1 : 0
|
|
1332
|
+
})
|
|
1333
|
+
});
|
|
1334
|
+
spin.stop();
|
|
1335
|
+
if (json) {
|
|
1336
|
+
console.log(JSON.stringify(result));
|
|
1337
|
+
} else {
|
|
1338
|
+
console.log(`${c.teal("Created snippet")} ${c.bright(result.id)} ${c.muted(`(${result.type})`)}`);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
async function snippetDelete(config, id, json) {
|
|
1342
|
+
const spin = spinner("Deleting snippet\u2026");
|
|
1343
|
+
spin.start();
|
|
1344
|
+
await apiFetch(config, `/snippets/${id}`, { method: "DELETE" });
|
|
1345
|
+
spin.stop();
|
|
1346
|
+
if (json) {
|
|
1347
|
+
console.log(JSON.stringify({ deleted: true, id }));
|
|
1348
|
+
} else {
|
|
1349
|
+
console.log(`${c.teal("Deleted snippet")} ${id}`);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
async function snippetStar(config, id, starred, json) {
|
|
1353
|
+
const result = await apiFetch(config, `/snippets/${id}`, {
|
|
1354
|
+
method: "PATCH",
|
|
1355
|
+
headers: { "Content-Type": "application/json" },
|
|
1356
|
+
body: JSON.stringify({ starred: starred ? 1 : 0 })
|
|
1357
|
+
});
|
|
1358
|
+
if (json) {
|
|
1359
|
+
console.log(JSON.stringify(result));
|
|
1360
|
+
} else {
|
|
1361
|
+
console.log(`${c.teal(starred ? "Starred" : "Unstarred")} ${id}`);
|
|
1362
|
+
}
|
|
861
1363
|
}
|
|
862
1364
|
|
|
863
1365
|
// src/commands/doctor.ts
|
|
@@ -898,7 +1400,7 @@ async function doctor(config, json) {
|
|
|
898
1400
|
const where = hasProjectSkill ? "project" : "user";
|
|
899
1401
|
checks.push({ name: "Claude skill", status: "ok", detail: `installed (${where}-level)` });
|
|
900
1402
|
} else {
|
|
901
|
-
checks.push({ name: "Claude skill", status: "warn", detail: "not installed (run `
|
|
1403
|
+
checks.push({ name: "Claude skill", status: "warn", detail: "not installed (run `artyfax skill install`)" });
|
|
902
1404
|
}
|
|
903
1405
|
if (json) {
|
|
904
1406
|
console.log(JSON.stringify(checks));
|
|
@@ -922,32 +1424,739 @@ async function doctor(config, json) {
|
|
|
922
1424
|
}
|
|
923
1425
|
}
|
|
924
1426
|
|
|
925
|
-
// src/commands/
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1427
|
+
// src/commands/work.ts
|
|
1428
|
+
var STATUS_SYMBOLS = {
|
|
1429
|
+
backlog: "\u25CB",
|
|
1430
|
+
up_next: "\u25CE",
|
|
1431
|
+
on_hold: "\u23F8",
|
|
1432
|
+
in_progress: "\u25CF",
|
|
1433
|
+
in_review: "\u25C9",
|
|
1434
|
+
done: "\u2713",
|
|
1435
|
+
cancelled: "\u2717"
|
|
1436
|
+
};
|
|
1437
|
+
var STATUS_LABELS = {
|
|
1438
|
+
backlog: "Backlog",
|
|
1439
|
+
up_next: "Up Next",
|
|
1440
|
+
on_hold: "On Hold",
|
|
1441
|
+
in_progress: "In Progress",
|
|
1442
|
+
in_review: "In Review",
|
|
1443
|
+
done: "Done",
|
|
1444
|
+
cancelled: "Cancelled"
|
|
1445
|
+
};
|
|
1446
|
+
function statusLabel(s) {
|
|
1447
|
+
const sym = STATUS_SYMBOLS[s] || "?";
|
|
1448
|
+
const label = STATUS_LABELS[s] || s;
|
|
1449
|
+
return `${sym} ${label}`;
|
|
930
1450
|
}
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1451
|
+
function progress(item) {
|
|
1452
|
+
if (item.task_total === 0) return c.muted("-");
|
|
1453
|
+
return `${item.task_done}/${item.task_total}`;
|
|
934
1454
|
}
|
|
935
|
-
async function
|
|
936
|
-
|
|
1455
|
+
async function workBoard(config, json) {
|
|
1456
|
+
const spin = spinner("Loading board\u2026");
|
|
1457
|
+
spin.start();
|
|
1458
|
+
const data = await apiFetch(config, "/projects/work");
|
|
1459
|
+
spin.stop();
|
|
1460
|
+
if (json) {
|
|
1461
|
+
console.log(JSON.stringify(data.work_items, null, 2));
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
if (data.work_items.length === 0) {
|
|
1465
|
+
console.log(c.muted("No work items. Promote a document in its settings panel."));
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
const byStatus = /* @__PURE__ */ new Map();
|
|
1469
|
+
for (const item of data.work_items) {
|
|
1470
|
+
const list2 = byStatus.get(item.work_status) || [];
|
|
1471
|
+
list2.push(item);
|
|
1472
|
+
byStatus.set(item.work_status, list2);
|
|
1473
|
+
}
|
|
1474
|
+
for (const status of ["up_next", "on_hold", "in_progress", "in_review", "backlog"]) {
|
|
1475
|
+
const items = byStatus.get(status);
|
|
1476
|
+
if (!items || items.length === 0) continue;
|
|
1477
|
+
console.log(`
|
|
1478
|
+
${c.bright(statusLabel(status))} ${c.muted(`(${items.length})`)}`);
|
|
1479
|
+
for (const item of items) {
|
|
1480
|
+
const id = item.work_id ? c.amber(item.work_id.padEnd(8)) : c.muted("--------");
|
|
1481
|
+
const prog = progress(item);
|
|
1482
|
+
const agent = item.work_agent ? c.muted(`[${item.work_agent}]`) : "";
|
|
1483
|
+
const current = item.task_current ? c.muted(` \u2014 ${item.task_current.slice(0, 50)}`) : "";
|
|
1484
|
+
console.log(` ${id} ${item.title} ${prog} ${agent}${current}`);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
console.log();
|
|
937
1488
|
}
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1489
|
+
async function workList(config, json, flags) {
|
|
1490
|
+
const spin = spinner("Loading work items\u2026");
|
|
1491
|
+
spin.start();
|
|
1492
|
+
const qs = new URLSearchParams();
|
|
1493
|
+
if (flags.project) qs.set("project_id", flags.project);
|
|
1494
|
+
if (flags.agent) qs.set("agent", flags.agent);
|
|
1495
|
+
if (flags.status) qs.set("status", flags.status);
|
|
1496
|
+
if (flags.type) qs.set("type", flags.type);
|
|
1497
|
+
const data = await apiFetch(config, `/projects/work?${qs}`);
|
|
1498
|
+
spin.stop();
|
|
1499
|
+
if (json) {
|
|
1500
|
+
console.log(JSON.stringify(data.work_items, null, 2));
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
if (data.work_items.length === 0) {
|
|
1504
|
+
console.log(c.muted("No matching work items."));
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
const rows = data.work_items.map((item) => [
|
|
1508
|
+
item.work_id ? c.amber(item.work_id) : c.muted("-"),
|
|
1509
|
+
item.title.slice(0, 50),
|
|
1510
|
+
statusLabel(item.work_status),
|
|
1511
|
+
item.work_type || c.muted("-"),
|
|
1512
|
+
item.work_agent || c.muted("-"),
|
|
1513
|
+
progress(item)
|
|
1514
|
+
]);
|
|
1515
|
+
console.log(table(rows, { header: ["ID", "Title", "Status", "Type", "Agent", "Progress"] }));
|
|
1516
|
+
}
|
|
1517
|
+
async function workNext(config, json, agent) {
|
|
1518
|
+
const spin = spinner("Picking next\u2026");
|
|
1519
|
+
spin.start();
|
|
1520
|
+
const data = await apiFetch(config, "/projects/work?status=up_next");
|
|
1521
|
+
spin.stop();
|
|
1522
|
+
const items = data.work_items;
|
|
1523
|
+
if (items.length === 0) {
|
|
1524
|
+
console.log(json ? "null" : c.muted("No items in Up Next."));
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
let pick = items[0];
|
|
1528
|
+
if (agent) {
|
|
1529
|
+
const assigned = items.find((i) => i.work_agent === agent.toLowerCase());
|
|
1530
|
+
const unassigned = items.find((i) => !i.work_agent);
|
|
1531
|
+
pick = assigned || unassigned || items[0];
|
|
1532
|
+
}
|
|
1533
|
+
if (json) {
|
|
1534
|
+
console.log(JSON.stringify(pick, null, 2));
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
const id = pick.work_id ? c.amber(pick.work_id) : pick.id;
|
|
1538
|
+
console.log(`${id} ${c.bright(pick.title)}`);
|
|
1539
|
+
if (pick.task_current) console.log(c.muted(` Current: ${pick.task_current}`));
|
|
1540
|
+
console.log(c.muted(` Progress: ${progress(pick)} | Agent: ${pick.work_agent || "unassigned"}`));
|
|
1541
|
+
console.log(c.muted(` ID: ${pick.id}`));
|
|
1542
|
+
}
|
|
1543
|
+
async function resolveWorkId(config, idOrWorkId) {
|
|
1544
|
+
const data = await apiFetch(config, `/resolve/${encodeURIComponent(idOrWorkId)}`);
|
|
1545
|
+
return data.id;
|
|
1546
|
+
}
|
|
1547
|
+
async function workSetStatus(config, docId, status, json) {
|
|
1548
|
+
const demote = ["none", "off", "remove"].includes(status.toLowerCase());
|
|
1549
|
+
const spin = spinner(demote ? "Removing from board\u2026" : `Setting status to ${status}\u2026`);
|
|
1550
|
+
spin.start();
|
|
1551
|
+
const resolvedId = await resolveWorkId(config, docId);
|
|
1552
|
+
const data = await apiFetch(config, `/documents/${resolvedId}/metadata`, {
|
|
1553
|
+
method: "PATCH",
|
|
1554
|
+
headers: { "Content-Type": "application/json" },
|
|
1555
|
+
body: JSON.stringify({ work_status: demote ? null : status })
|
|
1556
|
+
});
|
|
1557
|
+
spin.stop();
|
|
1558
|
+
if (json) {
|
|
1559
|
+
console.log(JSON.stringify(data));
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
console.log(c.bright(`${data.work_id || docId} \u2192 ${demote ? "removed from board" : statusLabel(status)}`));
|
|
1563
|
+
}
|
|
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"}`));
|
|
1590
|
+
}
|
|
1591
|
+
async function workCreate(config, title, flags, json) {
|
|
1592
|
+
const projectId = flags.project;
|
|
1593
|
+
if (!projectId) {
|
|
1594
|
+
console.error(c.rose("Error: --project is required. Work items must belong to a project."));
|
|
1595
|
+
process.exitCode = 1;
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
const spin = spinner("Creating work item\u2026");
|
|
1599
|
+
spin.start();
|
|
1600
|
+
const id = crypto.randomUUID();
|
|
1601
|
+
const slugBody = title.toLowerCase().replace(/[\s_]+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
1602
|
+
const slug = `inbox/${slugBody || `item-${id.slice(0, 8)}`}`;
|
|
1603
|
+
const saveRes = await apiFetch(config, "/documents", {
|
|
1604
|
+
method: "POST",
|
|
1605
|
+
headers: { "Content-Type": "application/json" },
|
|
1606
|
+
body: JSON.stringify({
|
|
1607
|
+
id,
|
|
1608
|
+
slug,
|
|
1609
|
+
title,
|
|
1610
|
+
category: "inbox",
|
|
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: "",
|
|
1615
|
+
format: "md",
|
|
1616
|
+
html_content: ""
|
|
1617
|
+
})
|
|
1618
|
+
});
|
|
1619
|
+
if (saveRes.ok) {
|
|
1620
|
+
const status = flags.status || "up_next";
|
|
1621
|
+
await apiFetch(config, `/documents/${id}/metadata`, {
|
|
1622
|
+
method: "PATCH",
|
|
1623
|
+
headers: { "Content-Type": "application/json" },
|
|
1624
|
+
body: JSON.stringify({
|
|
1625
|
+
project_id: projectId,
|
|
1626
|
+
work_status: status,
|
|
1627
|
+
work_type: flags.type || null,
|
|
1628
|
+
work_agent: flags.agent || null
|
|
1629
|
+
})
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
spin.stop();
|
|
1633
|
+
if (json) {
|
|
1634
|
+
console.log(JSON.stringify({ ok: true, id }));
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
console.log(c.bright(`Created: ${title}`));
|
|
1638
|
+
console.log(c.muted(`ID: ${id}`));
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// src/commands/project.ts
|
|
1642
|
+
import { readFileSync as readFileSync4, writeFileSync, existsSync as existsSync2 } from "node:fs";
|
|
1643
|
+
import { resolve as resolve4 } from "node:path";
|
|
1644
|
+
var ARTYFAX_FILE = "ARTYFAX.md";
|
|
1645
|
+
var USER_MARKER = "<!-- arty:user -->";
|
|
1646
|
+
async function projectList(config, json) {
|
|
1647
|
+
const spin = spinner("Loading projects\u2026");
|
|
1648
|
+
spin.start();
|
|
1649
|
+
const data = await apiFetch(config, "/projects");
|
|
1650
|
+
spin.stop();
|
|
1651
|
+
if (json) {
|
|
1652
|
+
console.log(JSON.stringify(data.projects, null, 2));
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
if (data.projects.length === 0) {
|
|
1656
|
+
console.log(c.muted('No projects. Create one with `artyfax project create "Name" --prefix XX`.'));
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
const rows = data.projects.map((p) => [
|
|
1660
|
+
c.amber(p.prefix),
|
|
1661
|
+
p.name,
|
|
1662
|
+
c.muted(String(p.next_work_id - 1) + " items"),
|
|
1663
|
+
p.description ? c.muted(p.description.slice(0, 40)) : ""
|
|
1664
|
+
]);
|
|
1665
|
+
console.log(table(rows, { header: ["Prefix", "Name", "Items", "Description"] }));
|
|
1666
|
+
}
|
|
1667
|
+
async function projectCreate(config, name, prefix, description, json, workspaces) {
|
|
1668
|
+
const spin = spinner("Creating project\u2026");
|
|
1669
|
+
spin.start();
|
|
1670
|
+
const workspace_ids = workspaces?.length ? await resolveWorkspaceIds(config, workspaces) : [];
|
|
1671
|
+
const data = await apiFetch(config, "/projects", {
|
|
1672
|
+
method: "POST",
|
|
1673
|
+
headers: { "Content-Type": "application/json" },
|
|
1674
|
+
body: JSON.stringify({
|
|
1675
|
+
name,
|
|
1676
|
+
prefix,
|
|
1677
|
+
description,
|
|
1678
|
+
workspace_scope: workspace_ids.length ? "scoped" : "global",
|
|
1679
|
+
workspace_ids
|
|
1680
|
+
})
|
|
1681
|
+
});
|
|
1682
|
+
spin.stop();
|
|
1683
|
+
if (json) {
|
|
1684
|
+
console.log(JSON.stringify(data, null, 2));
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
console.log(c.bright(`Project created: ${name} (${c.amber(data.prefix)})`));
|
|
1688
|
+
}
|
|
1689
|
+
var STATUS_ORDER = ["in_progress", "in_review", "up_next", "on_hold", "backlog", "done", "cancelled"];
|
|
1690
|
+
var STATUS_HEADINGS = {
|
|
1691
|
+
in_progress: "In Progress",
|
|
1692
|
+
in_review: "In Review",
|
|
1693
|
+
up_next: "Up Next",
|
|
1694
|
+
on_hold: "On Hold",
|
|
1695
|
+
backlog: "Backlog",
|
|
1696
|
+
done: "Done",
|
|
1697
|
+
cancelled: "Cancelled"
|
|
1698
|
+
};
|
|
1699
|
+
function formatProgress(item) {
|
|
1700
|
+
if (item.task_total === 0) return "";
|
|
1701
|
+
return ` [${item.task_done}/${item.task_total}]`;
|
|
1702
|
+
}
|
|
1703
|
+
function resolveProject(projects, hint) {
|
|
1704
|
+
if (hint) {
|
|
1705
|
+
return projects.find(
|
|
1706
|
+
(p) => p.slug === hint || p.prefix.toLowerCase() === hint.toLowerCase() || p.name.toLowerCase() === hint.toLowerCase()
|
|
1707
|
+
) || null;
|
|
1708
|
+
}
|
|
1709
|
+
if (projects.length === 1) return projects[0];
|
|
1710
|
+
return null;
|
|
1711
|
+
}
|
|
1712
|
+
function parseExistingPrefix(filePath) {
|
|
1713
|
+
if (!existsSync2(filePath)) return null;
|
|
1714
|
+
const content = readFileSync4(filePath, "utf-8");
|
|
1715
|
+
const match = content.match(/^# .+\(([A-Z]+)\)/m);
|
|
1716
|
+
return match ? match[1] : null;
|
|
1717
|
+
}
|
|
1718
|
+
function extractUserContent(filePath) {
|
|
1719
|
+
if (!existsSync2(filePath)) return "";
|
|
1720
|
+
const content = readFileSync4(filePath, "utf-8");
|
|
1721
|
+
const matches = [...content.matchAll(new RegExp(`^${USER_MARKER}$`, "gm"))];
|
|
1722
|
+
const match = matches[matches.length - 1];
|
|
1723
|
+
if (!match || match.index == null) return "";
|
|
1724
|
+
return content.substring(match.index + USER_MARKER.length);
|
|
1725
|
+
}
|
|
1726
|
+
function padTable(rows) {
|
|
1727
|
+
const cols = rows[0].length;
|
|
1728
|
+
const widths = Array.from(
|
|
1729
|
+
{ length: cols },
|
|
1730
|
+
(_, i) => Math.max(...rows.map((r) => r[i].length))
|
|
1731
|
+
);
|
|
1732
|
+
const lines = [];
|
|
1733
|
+
for (let r = 0; r < rows.length; r++) {
|
|
1734
|
+
const cells = rows[r].map((cell, i) => ` ${cell.padEnd(widths[i])} `);
|
|
1735
|
+
lines.push(`|${cells.join("|")}|`);
|
|
1736
|
+
if (r === 0) {
|
|
1737
|
+
const sep = widths.map((w) => "-".repeat(w + 2));
|
|
1738
|
+
lines.push(`|${sep.join("|")}|`);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
return lines.join("\n");
|
|
1742
|
+
}
|
|
1743
|
+
function generateWorkflow(project) {
|
|
1744
|
+
const p = project.prefix;
|
|
1745
|
+
const tbl = padTable([
|
|
1746
|
+
["Action", "CLI", "MCP"],
|
|
1747
|
+
["Board overview", "`artyfax work list`", `\`list_work(project: '${p}')\``],
|
|
1748
|
+
["Read a work item", "`artyfax read <slug>`", `\`get_document('${p}-2', include_annotations: true)\``],
|
|
1749
|
+
["Update content", "`artyfax update <slug> <file>`", `\`patch_document('${p}-2', patches)\``],
|
|
1750
|
+
["Tick a task", '`artyfax task <id> "text" <state>`', `\`update_task('${p}-2', task, state)\``],
|
|
1751
|
+
["Claim and start", "`artyfax work status <id> in_progress`", `\`start_work('${p}-2', agent: '<agent>')\``],
|
|
1752
|
+
["Change status", "`artyfax work status <id> <status>`", `\`update_work_status('${p}-2', status)\``],
|
|
1753
|
+
["Refresh this file", "`artyfax init`", "--"]
|
|
1754
|
+
]);
|
|
1755
|
+
return `## Workflow
|
|
1756
|
+
|
|
1757
|
+
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.
|
|
1758
|
+
|
|
1759
|
+
${tbl}
|
|
1760
|
+
|
|
1761
|
+
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.
|
|
1762
|
+
|
|
1763
|
+
All MCP tools that take a document ID also accept work IDs (e.g. \`${p}-2\`) directly.
|
|
1764
|
+
|
|
1765
|
+
### Picking Up Agent Work
|
|
1766
|
+
|
|
1767
|
+
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.
|
|
1768
|
+
|
|
1769
|
+
### Lifecycle
|
|
1770
|
+
|
|
1771
|
+
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.
|
|
1772
|
+
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.
|
|
1773
|
+
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.
|
|
1774
|
+
4. **Blocked?** - \`update_work_status('${p}-15', 'on_hold')\` and note why
|
|
1775
|
+
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.
|
|
1776
|
+
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.
|
|
1777
|
+
7. **Done** - only the user moves parent items from In Review to Done after personal review
|
|
1778
|
+
|
|
1779
|
+
### Thin work items
|
|
1780
|
+
|
|
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.
|
|
1782
|
+
|
|
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.
|
|
1788
|
+
|
|
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.
|
|
1790
|
+
|
|
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.
|
|
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
|
+
|
|
1800
|
+
### Board update cadence
|
|
1801
|
+
|
|
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.
|
|
1803
|
+
|
|
1804
|
+
- **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.
|
|
1805
|
+
- **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.
|
|
1806
|
+
- **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.
|
|
1807
|
+
- **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.
|
|
1808
|
+
|
|
1809
|
+
### Keeping this file current
|
|
1810
|
+
|
|
1811
|
+
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.
|
|
1812
|
+
|
|
1813
|
+
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.
|
|
1814
|
+
|
|
1815
|
+
### Conventions
|
|
1816
|
+
|
|
1817
|
+
- **Work IDs** (e.g. ${p}-15) are stable. Reference them in commits and changelogs. All MCP tools accept them directly.
|
|
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.
|
|
1819
|
+
- **Statuses**: backlog > up_next > in_progress > in_review > done (or on_hold, cancelled).
|
|
1820
|
+
`;
|
|
1821
|
+
}
|
|
1822
|
+
function generateSaving() {
|
|
1823
|
+
return `## Saving documents
|
|
1824
|
+
|
|
1825
|
+
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.
|
|
1826
|
+
|
|
1827
|
+
### Images on documents
|
|
1828
|
+
|
|
1829
|
+
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).
|
|
1830
|
+
|
|
1831
|
+
- \`artyfax cover <doc> <file>\` - set a document's cover image (renders best at 1200x630).
|
|
1832
|
+
- \`artyfax image <doc> <file>\` - upload an inline image and print the \`\` markdown to paste into the body.
|
|
1833
|
+
|
|
1834
|
+
\`<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.
|
|
1835
|
+
`;
|
|
1836
|
+
}
|
|
1837
|
+
function generateBoard(items) {
|
|
1838
|
+
const byStatus = /* @__PURE__ */ new Map();
|
|
1839
|
+
for (const item of items) {
|
|
1840
|
+
const list2 = byStatus.get(item.work_status) || [];
|
|
1841
|
+
list2.push(item);
|
|
1842
|
+
byStatus.set(item.work_status, list2);
|
|
1843
|
+
}
|
|
1844
|
+
const lines = [];
|
|
1845
|
+
for (const status of STATUS_ORDER) {
|
|
1846
|
+
const group = byStatus.get(status);
|
|
1847
|
+
if (!group || group.length === 0) continue;
|
|
1848
|
+
const heading = STATUS_HEADINGS[status] || status;
|
|
1849
|
+
if (status === "done" || status === "cancelled") {
|
|
1850
|
+
lines.push(`## ${heading} (${group.length})`);
|
|
1851
|
+
lines.push("");
|
|
1852
|
+
lines.push(`${group.length} ${status === "done" ? "completed" : "cancelled"} items. Use \`artyfax work list --status ${status}\` or \`list_work(status: '${status}')\` for details.`);
|
|
1853
|
+
} else {
|
|
1854
|
+
lines.push(`## ${heading}`);
|
|
1855
|
+
lines.push("");
|
|
1856
|
+
for (const item of group) {
|
|
1857
|
+
const id = item.work_id || item.id.slice(0, 8);
|
|
1858
|
+
const prog = formatProgress(item);
|
|
1859
|
+
const agent = item.work_agent ? ` @${item.work_agent}` : "";
|
|
1860
|
+
lines.push(`- ${id} ${item.title}${prog}${agent}`);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
lines.push("");
|
|
1864
|
+
}
|
|
1865
|
+
return lines.join("\n");
|
|
1866
|
+
}
|
|
1867
|
+
async function init(config, projectHint) {
|
|
1868
|
+
const filePath = resolve4(process.cwd(), ARTYFAX_FILE);
|
|
1869
|
+
const isUpdate = existsSync2(filePath);
|
|
1870
|
+
const spin = spinner(isUpdate ? "Refreshing ARTYFAX.md\u2026" : "Initialising ARTYFAX.md\u2026");
|
|
1871
|
+
spin.start();
|
|
1872
|
+
const existingPrefix = isUpdate ? parseExistingPrefix(filePath) : null;
|
|
1873
|
+
const hint = projectHint || existingPrefix || void 0;
|
|
1874
|
+
const projects = await apiFetch(config, "/projects");
|
|
1875
|
+
const project = resolveProject(projects.projects, hint);
|
|
1876
|
+
if (!project) {
|
|
1877
|
+
spin.stop();
|
|
1878
|
+
if (projects.projects.length === 0) {
|
|
1879
|
+
console.error(c.rose('No projects found. Create one first: artyfax project create "Name" --prefix XX'));
|
|
1880
|
+
} else {
|
|
1881
|
+
const prefixes = projects.projects.map((p) => c.amber(p.prefix)).join(", ");
|
|
1882
|
+
console.error(c.rose(`Specify a project: artyfax init <prefix>`));
|
|
1883
|
+
console.error(c.muted(`Available: ${prefixes}`));
|
|
1884
|
+
}
|
|
1885
|
+
process.exitCode = 1;
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
const qs = new URLSearchParams({ project_id: project.id });
|
|
1889
|
+
const data = await apiFetch(config, `/projects/work?${qs}`);
|
|
1890
|
+
spin.stop();
|
|
1891
|
+
const userContent = extractUserContent(filePath);
|
|
1892
|
+
const d = /* @__PURE__ */ new Date();
|
|
1893
|
+
const now = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
1894
|
+
const generated = [
|
|
1895
|
+
`# ${project.name} (${project.prefix})`,
|
|
1896
|
+
"",
|
|
1897
|
+
`Last updated: ${now} \xB7 Live data: \`artyfax work list\` or \`list_work(project: '${project.prefix}')\``,
|
|
1898
|
+
"",
|
|
1899
|
+
generateSaving(),
|
|
1900
|
+
generateWorkflow(project),
|
|
1901
|
+
generateBoard(data.work_items),
|
|
1902
|
+
USER_MARKER
|
|
1903
|
+
].join("\n");
|
|
1904
|
+
const content = generated + userContent;
|
|
1905
|
+
writeFileSync(filePath, content);
|
|
1906
|
+
const action = isUpdate ? "Updated" : "Created";
|
|
1907
|
+
console.log(c.bright(`${action} ${ARTYFAX_FILE}`));
|
|
1908
|
+
console.log(c.muted(`${project.name} (${project.prefix}) \xB7 ${data.work_items.length} work items`));
|
|
1909
|
+
if (!isUpdate) {
|
|
1910
|
+
const claudeMd = resolve4(process.cwd(), "CLAUDE.md");
|
|
1911
|
+
if (existsSync2(claudeMd)) {
|
|
1912
|
+
const claudeContent = readFileSync4(claudeMd, "utf-8");
|
|
1913
|
+
if (!claudeContent.includes("@ARTYFAX.md")) {
|
|
1914
|
+
console.log("");
|
|
1915
|
+
console.log(c.muted("Add to CLAUDE.md to give agents the project board:"));
|
|
1916
|
+
console.log(c.amber(" @ARTYFAX.md"));
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// src/commands/skill.ts
|
|
1923
|
+
async function skillInstall(_project) {
|
|
1924
|
+
console.log(c.amber("Skill installation is not available yet."));
|
|
1925
|
+
console.log(c.muted("The Artyfax skill content is being designed in design-28."));
|
|
1926
|
+
console.log(c.muted("Once ready, `artyfax skill install` will write skill files to ~/.claude/skills/artyfax"));
|
|
1927
|
+
}
|
|
1928
|
+
async function skillStatus() {
|
|
1929
|
+
console.log(c.amber("Skill status is not available yet."));
|
|
1930
|
+
console.log(c.muted("Run `artyfax doctor` to check basic setup."));
|
|
1931
|
+
}
|
|
1932
|
+
async function skillUpdate() {
|
|
1933
|
+
console.log(c.amber("Skill update is not available yet."));
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
// src/commands/task.ts
|
|
1937
|
+
async function resolveId(config, identifier) {
|
|
1938
|
+
const data = await apiFetch(config, `/resolve/${encodeURIComponent(identifier)}`);
|
|
1939
|
+
return data.id;
|
|
1940
|
+
}
|
|
1941
|
+
async function fetchTasks(config, id) {
|
|
1942
|
+
const data = await apiFetch(config, `/documents/${id}/tasks`);
|
|
1943
|
+
return data.tasks;
|
|
1944
|
+
}
|
|
1945
|
+
async function taskList(config, identifier, json) {
|
|
1946
|
+
const spin = spinner("Loading tasks\u2026");
|
|
1947
|
+
spin.start();
|
|
1948
|
+
const id = await resolveId(config, identifier);
|
|
1949
|
+
const tasks = await fetchTasks(config, id);
|
|
1950
|
+
spin.stop();
|
|
1951
|
+
if (json) {
|
|
1952
|
+
console.log(JSON.stringify(tasks, null, 2));
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
if (tasks.length === 0) {
|
|
1956
|
+
console.log(c.muted("No tasks found"));
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
const stateIcon = { done: c.teal("\u2713"), active: c.amber("~"), pending: c.muted("\u25CB") };
|
|
1960
|
+
for (const t of tasks) {
|
|
1961
|
+
console.log(`${stateIcon[t.state]} ${c.muted(`[${t.index}]`)} ${t.text}`);
|
|
1962
|
+
}
|
|
1963
|
+
const done = tasks.filter((t) => t.state === "done").length;
|
|
1964
|
+
const active = tasks.filter((t) => t.state === "active").length;
|
|
1965
|
+
console.log(c.muted(`
|
|
1966
|
+
${done}/${tasks.length} done${active ? `, ${active} active` : ""}`));
|
|
1967
|
+
}
|
|
1968
|
+
async function taskUpdate(config, identifier, taskText, state, json) {
|
|
1969
|
+
if (!["active", "done", "pending"].includes(state)) {
|
|
1970
|
+
console.error(c.rose(`Invalid state "${state}". Must be: active, done, pending`));
|
|
1971
|
+
process.exit(1);
|
|
1972
|
+
}
|
|
1973
|
+
const spin = spinner(`Setting task to ${state}\u2026`);
|
|
1974
|
+
spin.start();
|
|
1975
|
+
const id = await resolveId(config, identifier);
|
|
1976
|
+
const tasks = await fetchTasks(config, id);
|
|
1977
|
+
const needle = taskText.toLowerCase();
|
|
1978
|
+
const match = tasks.find((t) => t.text.toLowerCase().includes(needle));
|
|
1979
|
+
if (!match) {
|
|
1980
|
+
spin.stop();
|
|
1981
|
+
console.error(c.rose(`No task matching "${taskText}" found`));
|
|
1982
|
+
process.exit(1);
|
|
1983
|
+
}
|
|
1984
|
+
await apiFetch(config, `/documents/${id}/task/${match.index}`, {
|
|
1985
|
+
method: "PATCH",
|
|
1986
|
+
headers: { "Content-Type": "application/json" },
|
|
1987
|
+
body: JSON.stringify({ state })
|
|
1988
|
+
});
|
|
1989
|
+
spin.stop();
|
|
1990
|
+
if (json) {
|
|
1991
|
+
console.log(JSON.stringify({ ok: true, task_index: match.index, task_text: match.text, state }));
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
const stateLabel = { done: c.teal("done"), active: c.amber("active"), pending: c.muted("pending") };
|
|
1995
|
+
console.log(`${stateLabel[state]} ${match.text}`);
|
|
1996
|
+
}
|
|
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
|
+
};
|
|
2024
|
+
}
|
|
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}"`);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
// src/cli.ts
|
|
2148
|
+
var VERSION = true ? "0.3.1" : "0.0.0-dev";
|
|
2149
|
+
function brandedHelp() {
|
|
2150
|
+
return `
|
|
2151
|
+
${c.amber("artyfax")} ${c.muted(`v${VERSION}`)} \u2014 your personal document library
|
|
2152
|
+
|
|
2153
|
+
${c.bright("Usage:")} artyfax <command> [options]
|
|
2154
|
+
${c.muted("aliases:")} arty <command> \xB7 ax <command>
|
|
2155
|
+
|
|
2156
|
+
${c.bright("Documents")}
|
|
2157
|
+
save <file> Save a document (markdown or HTML)
|
|
2158
|
+
read <slug> Read document content (handles E2EE)
|
|
2159
|
+
list List documents
|
|
951
2160
|
get <slug> Document metadata as JSON
|
|
952
2161
|
search <query> Full-text search
|
|
953
2162
|
update <slug> <file> Push updated content (auto-encrypts secure docs)
|
|
@@ -956,20 +2165,48 @@ ${c.bright("Documents")}
|
|
|
956
2165
|
open <slug> Open document in browser
|
|
957
2166
|
secure <slug> Encrypt a document
|
|
958
2167
|
unsecure <slug> Remove encryption
|
|
2168
|
+
cover <doc> <file> Set a document's cover image from a local file
|
|
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
|
|
959
2172
|
|
|
960
2173
|
${c.bright("Sub-resources")}
|
|
961
2174
|
share create <slug> Create a share link
|
|
962
2175
|
share list [slug] List shares
|
|
963
2176
|
share revoke <hash> Revoke a share link
|
|
964
2177
|
cat list List categories
|
|
965
|
-
tag list List tags
|
|
2178
|
+
tag list List tags (grouped by namespace)
|
|
2179
|
+
tag delete <tag> Remove a tag from all docs, annotations, snippets
|
|
966
2180
|
version list <slug> Version history
|
|
967
2181
|
version restore <slug> Restore a previous version
|
|
968
2182
|
|
|
969
2183
|
${c.bright("Annotations")}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
2184
|
+
annotation list <slug> List a document's annotations (--include-recipients, --since)
|
|
2185
|
+
annotation search <q> Search annotations
|
|
2186
|
+
|
|
2187
|
+
${c.bright("Snippets")}
|
|
2188
|
+
snippets list List snippets (filter --type --tag --starred)
|
|
2189
|
+
snippets search <query> Full-text search across snippets
|
|
2190
|
+
snippets show <id> Show a snippet's full content
|
|
2191
|
+
snippets new Create a snippet (--text, --file, --image)
|
|
2192
|
+
snippets delete <id> Delete a snippet
|
|
2193
|
+
snippets star <id> Star a snippet
|
|
2194
|
+
snippets unstar <id> Unstar a snippet
|
|
2195
|
+
|
|
2196
|
+
${c.bright("Work")}
|
|
2197
|
+
work Board summary (kanban overview)
|
|
2198
|
+
work list List work items (--project, --agent, --status, --type)
|
|
2199
|
+
work next Pick next Up Next item (--agent)
|
|
2200
|
+
work start <id> Mark In Progress
|
|
2201
|
+
work done <id> Mark Done
|
|
2202
|
+
work status <id> <s> Set arbitrary status ("none" removes from board)
|
|
2203
|
+
work create "title" Quick-create a work item (--project, --type, --agent)
|
|
2204
|
+
|
|
2205
|
+
${c.bright("Projects")}
|
|
2206
|
+
project list List projects
|
|
2207
|
+
project create "name" Create a project (--prefix XX)
|
|
2208
|
+
init [prefix] Generate or refresh ARTYFAX.md for a project board
|
|
2209
|
+
workspace list List workspaces
|
|
973
2210
|
|
|
974
2211
|
${c.bright("Tools")}
|
|
975
2212
|
doctor Verify CLI setup
|
|
@@ -992,10 +2229,10 @@ ${c.bright("Environment")}
|
|
|
992
2229
|
`.trim();
|
|
993
2230
|
}
|
|
994
2231
|
var COMMAND_HELP = {
|
|
995
|
-
save: `${c.amber("
|
|
2232
|
+
save: `${c.amber("artyfax save")} \u2014 save a document
|
|
996
2233
|
|
|
997
|
-
${c.bright("Usage:")}
|
|
998
|
-
|
|
2234
|
+
${c.bright("Usage:")} artyfax save <file> [options]
|
|
2235
|
+
artyfax save --url <url> [options]
|
|
999
2236
|
|
|
1000
2237
|
${c.bright("Options:")}
|
|
1001
2238
|
--url <url> Save from URL (server-side extraction)
|
|
@@ -1011,23 +2248,27 @@ ${c.bright("Options:")}
|
|
|
1011
2248
|
--json JSON output
|
|
1012
2249
|
|
|
1013
2250
|
${c.bright("Examples:")}
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
2251
|
+
artyfax save notes.md --category inbox
|
|
2252
|
+
artyfax save --url https://example.com/article --category articles
|
|
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.`,
|
|
2258
|
+
read: `${c.amber("artyfax read")} \u2014 read document content
|
|
1018
2259
|
|
|
1019
|
-
${c.bright("Usage:")}
|
|
2260
|
+
${c.bright("Usage:")} artyfax read <slug>
|
|
1020
2261
|
|
|
1021
2262
|
Outputs the document's markdown source to stdout. Handles E2EE
|
|
1022
2263
|
decryption automatically (prompts for passphrase if needed).
|
|
1023
2264
|
|
|
1024
2265
|
${c.bright("Examples:")}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
list: `${c.amber("
|
|
2266
|
+
artyfax read inbox/my-doc
|
|
2267
|
+
artyfax read my-doc # partial slug match
|
|
2268
|
+
artyfax read inbox/my-doc > out.md # pipe to file`,
|
|
2269
|
+
list: `${c.amber("artyfax list")} \u2014 list documents
|
|
1029
2270
|
|
|
1030
|
-
${c.bright("Usage:")}
|
|
2271
|
+
${c.bright("Usage:")} artyfax list [options]
|
|
1031
2272
|
|
|
1032
2273
|
${c.bright("Options:")}
|
|
1033
2274
|
--category <name> Filter by category
|
|
@@ -1035,43 +2276,46 @@ ${c.bright("Options:")}
|
|
|
1035
2276
|
--offset <n> Skip first N results
|
|
1036
2277
|
--archived Include archived documents
|
|
1037
2278
|
--parent-id <id> Filter by parent document
|
|
2279
|
+
--tag <tag> Filter to documents carrying this exact tag
|
|
1038
2280
|
--json JSON output
|
|
1039
2281
|
|
|
1040
2282
|
${c.bright("Examples:")}
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
2283
|
+
artyfax list --category inbox --limit 10
|
|
2284
|
+
artyfax list --tag role:design
|
|
2285
|
+
artyfax list --archived --json`,
|
|
2286
|
+
get: `${c.amber("artyfax get")} \u2014 document metadata
|
|
1044
2287
|
|
|
1045
|
-
${c.bright("Usage:")}
|
|
2288
|
+
${c.bright("Usage:")} artyfax get <slug>
|
|
1046
2289
|
|
|
1047
2290
|
Outputs full document metadata as JSON. For scripting and inspection.
|
|
1048
2291
|
|
|
1049
2292
|
${c.bright("Examples:")}
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
search: `${c.amber("
|
|
2293
|
+
artyfax get inbox/my-doc
|
|
2294
|
+
artyfax get my-doc | jq .category`,
|
|
2295
|
+
search: `${c.amber("artyfax search")} \u2014 full-text search
|
|
1053
2296
|
|
|
1054
|
-
${c.bright("Usage:")}
|
|
2297
|
+
${c.bright("Usage:")} artyfax search <query> [--json]
|
|
1055
2298
|
|
|
1056
2299
|
${c.bright("Examples:")}
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
update: `${c.amber("
|
|
2300
|
+
artyfax search "deployment guide"
|
|
2301
|
+
artyfax search cloudflare --json`,
|
|
2302
|
+
update: `${c.amber("artyfax update")} \u2014 push updated content
|
|
1060
2303
|
|
|
1061
|
-
${c.bright("Usage:")}
|
|
1062
|
-
cat file.md |
|
|
2304
|
+
${c.bright("Usage:")} artyfax update <slug> [file]
|
|
2305
|
+
cat file.md | artyfax update <slug>
|
|
1063
2306
|
|
|
1064
2307
|
Reads from file or stdin. Auto-encrypts if the target is a secure document.
|
|
1065
2308
|
|
|
1066
2309
|
${c.bright("Examples:")}
|
|
1067
|
-
|
|
1068
|
-
echo "# New content" |
|
|
1069
|
-
metadata: `${c.amber("
|
|
2310
|
+
artyfax update inbox/my-doc updated.md
|
|
2311
|
+
echo "# New content" | artyfax update inbox/my-doc`,
|
|
2312
|
+
metadata: `${c.amber("artyfax metadata")} \u2014 update document metadata
|
|
1070
2313
|
|
|
1071
|
-
${c.bright("Usage:")}
|
|
2314
|
+
${c.bright("Usage:")} artyfax metadata <slug> [options]
|
|
1072
2315
|
|
|
1073
2316
|
${c.bright("Options:")}
|
|
1074
2317
|
--category <name> Move to category
|
|
2318
|
+
--slug <cat/slug> Rename the URL slug (cosmetic; URLs resolve by id)
|
|
1075
2319
|
--tags <t1,t2> Set tags (comma-separated)
|
|
1076
2320
|
--title <name> Update title
|
|
1077
2321
|
--theme <name> Set theme
|
|
@@ -1080,61 +2324,127 @@ ${c.bright("Options:")}
|
|
|
1080
2324
|
--json JSON output
|
|
1081
2325
|
|
|
1082
2326
|
${c.bright("Examples:")}
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
2327
|
+
artyfax metadata inbox/my-doc --category reports --tags "q1,finance"
|
|
2328
|
+
artyfax metadata my-doc --slug plans/rename-safe-urls
|
|
2329
|
+
artyfax metadata my-doc --archived`,
|
|
2330
|
+
cover: `${c.amber("artyfax cover")} \u2014 set a document's cover image
|
|
1086
2331
|
|
|
1087
|
-
${c.bright("Usage:")}
|
|
2332
|
+
${c.bright("Usage:")} artyfax cover <doc> <image-file> [options]
|
|
2333
|
+
|
|
2334
|
+
Uploads a local image and sets it as the document's cover. Works for any
|
|
2335
|
+
document (pass a doc id, slug, or work id). Unlike the MCP set_cover_image
|
|
2336
|
+
tool, this carries a full-quality local image with no transport truncation.
|
|
2337
|
+
|
|
2338
|
+
${c.bright("Options:")}
|
|
2339
|
+
--alt "..." Alt text for the cover image
|
|
2340
|
+
--json JSON output
|
|
2341
|
+
|
|
2342
|
+
${c.bright("Notes:")}
|
|
2343
|
+
Accepts png/jpg/webp/gif/avif/svg up to 5 MB. Covers render best at
|
|
2344
|
+
1200\xD7630 (the hero + social card shape).
|
|
2345
|
+
|
|
2346
|
+
${c.bright("Examples:")}
|
|
2347
|
+
artyfax cover p8KV4dvrgJTA ./hero.png
|
|
2348
|
+
artyfax cover inbox/my-doc ./hero.png --alt "Release banner"`,
|
|
2349
|
+
image: `${c.amber("artyfax image")} \u2014 upload an inline image to a document
|
|
2350
|
+
|
|
2351
|
+
${c.bright("Usage:")} artyfax image <doc> <image-file> [options]
|
|
2352
|
+
|
|
2353
|
+
Uploads a local image into the document's image store and prints the
|
|
2354
|
+
markdown snippet to paste into the body. Works for any document (doc id,
|
|
2355
|
+
slug, or work id).
|
|
2356
|
+
|
|
2357
|
+
${c.bright("Options:")}
|
|
2358
|
+
--alt "..." Alt text for the image
|
|
2359
|
+
--json JSON output
|
|
2360
|
+
|
|
2361
|
+
${c.bright("Examples:")}
|
|
2362
|
+
artyfax image p8KV4dvrgJTA ./screenshot.png
|
|
2363
|
+
artyfax image inbox/my-doc ./diagram.png --alt "Architecture"`,
|
|
2364
|
+
delete: `${c.amber("artyfax delete")} \u2014 delete a document
|
|
2365
|
+
|
|
2366
|
+
${c.bright("Usage:")} artyfax delete <slug> [--yes] [--json]
|
|
1088
2367
|
|
|
1089
2368
|
Prompts for confirmation unless --yes is passed.
|
|
1090
2369
|
|
|
1091
2370
|
${c.bright("Examples:")}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
open: `${c.amber("
|
|
2371
|
+
artyfax delete inbox/old-doc
|
|
2372
|
+
artyfax delete inbox/old-doc --yes # skip confirmation`,
|
|
2373
|
+
open: `${c.amber("artyfax open")} \u2014 open in browser
|
|
1095
2374
|
|
|
1096
|
-
${c.bright("Usage:")}
|
|
2375
|
+
${c.bright("Usage:")} artyfax open <slug>
|
|
1097
2376
|
|
|
1098
2377
|
Opens the document URL in your default browser.`,
|
|
1099
|
-
share: `${c.amber("
|
|
2378
|
+
share: `${c.amber("artyfax share")} \u2014 manage share links
|
|
2379
|
+
|
|
2380
|
+
${c.bright("Usage:")} artyfax share create <slug> Create a share link
|
|
2381
|
+
artyfax share list [slug] List all shares (or per-document)
|
|
2382
|
+
artyfax share revoke <hash> Revoke a share link
|
|
2383
|
+
|
|
2384
|
+
${c.bright("Examples:")}
|
|
2385
|
+
artyfax share create inbox/my-doc
|
|
2386
|
+
artyfax share list --json
|
|
2387
|
+
artyfax share revoke SnP5UJUhP6 --yes`,
|
|
2388
|
+
version: `${c.amber("artyfax version")} \u2014 version history
|
|
1100
2389
|
|
|
1101
|
-
${c.bright("Usage:")}
|
|
1102
|
-
|
|
1103
|
-
arty share revoke <hash> Revoke a share link
|
|
2390
|
+
${c.bright("Usage:")} artyfax version list <slug> Show version history
|
|
2391
|
+
artyfax version restore <slug> Restore a previous version
|
|
1104
2392
|
|
|
1105
2393
|
${c.bright("Examples:")}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
2394
|
+
artyfax version list inbox/my-doc
|
|
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>
|
|
1110
2410
|
|
|
1111
|
-
|
|
1112
|
-
arty version restore <slug> Restore a previous version
|
|
2411
|
+
Public and shared views revert to serving the live document.
|
|
1113
2412
|
|
|
1114
2413
|
${c.bright("Examples:")}
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
note: `${c.amber("arty note")} \u2014 annotations
|
|
2414
|
+
artyfax unpublish plans/my-post`,
|
|
2415
|
+
annotation: `${c.amber("artyfax annotation")} \u2014 read a document's annotations (highlights, underlines, strikethroughs, notes, comments, redactions)
|
|
1118
2416
|
|
|
1119
|
-
${c.bright("Usage:")}
|
|
1120
|
-
|
|
1121
|
-
arty note search <query> Search annotations
|
|
2417
|
+
${c.bright("Usage:")} artyfax annotation list <slug> List annotations (--include-recipients, --since <iso>)
|
|
2418
|
+
artyfax annotation search <query> Search annotations
|
|
1122
2419
|
|
|
1123
2420
|
${c.bright("Examples:")}
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
doctor: `${c.amber("
|
|
2421
|
+
artyfax annotation list inbox/my-doc
|
|
2422
|
+
artyfax annotation list inbox/my-doc --include-recipients
|
|
2423
|
+
artyfax annotation list inbox/my-doc --since 2026-05-01T00:00:00Z
|
|
2424
|
+
artyfax annotation search "review"`,
|
|
2425
|
+
doctor: `${c.amber("artyfax doctor")} \u2014 verify CLI setup
|
|
1129
2426
|
|
|
1130
2427
|
Checks API key, endpoint, connectivity, E2EE status, and skill installation.
|
|
1131
2428
|
|
|
1132
|
-
${c.bright("Usage:")}
|
|
1133
|
-
skill: `${c.amber("
|
|
2429
|
+
${c.bright("Usage:")} artyfax doctor [--json]`,
|
|
2430
|
+
skill: `${c.amber("artyfax skill")} \u2014 Claude Code skill management
|
|
1134
2431
|
|
|
1135
|
-
${c.bright("Usage:")}
|
|
1136
|
-
|
|
1137
|
-
|
|
2432
|
+
${c.bright("Usage:")} artyfax skill install [--project] Install Artyfax skill
|
|
2433
|
+
artyfax skill status Check installation
|
|
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`
|
|
1138
2448
|
};
|
|
1139
2449
|
function parseArgs(args) {
|
|
1140
2450
|
const flags = {};
|
|
@@ -1144,13 +2454,22 @@ function parseArgs(args) {
|
|
|
1144
2454
|
for (let i = 0; i < args.length; i++) {
|
|
1145
2455
|
const arg = args[i];
|
|
1146
2456
|
if (arg.startsWith("--")) {
|
|
1147
|
-
const
|
|
1148
|
-
const
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
2457
|
+
const body = arg.slice(2);
|
|
2458
|
+
const eq = body.indexOf("=");
|
|
2459
|
+
const key = eq === -1 ? body : body.slice(0, eq);
|
|
2460
|
+
if (key === "") {
|
|
2461
|
+
continue;
|
|
2462
|
+
}
|
|
2463
|
+
if (eq !== -1) {
|
|
2464
|
+
flags[key] = body.slice(eq + 1);
|
|
1152
2465
|
} else {
|
|
1153
|
-
|
|
2466
|
+
const next = args[i + 1];
|
|
2467
|
+
if (next !== void 0 && !next.startsWith("-")) {
|
|
2468
|
+
flags[key] = next;
|
|
2469
|
+
i++;
|
|
2470
|
+
} else {
|
|
2471
|
+
flags[key] = true;
|
|
2472
|
+
}
|
|
1154
2473
|
}
|
|
1155
2474
|
} else if (arg.startsWith("-")) {
|
|
1156
2475
|
flags[arg.slice(1)] = true;
|
|
@@ -1164,10 +2483,35 @@ function parseArgs(args) {
|
|
|
1164
2483
|
}
|
|
1165
2484
|
return { command, subcommand, positional, flags };
|
|
1166
2485
|
}
|
|
1167
|
-
var SUB_RESOURCES = /* @__PURE__ */ new Set(["share", "cat", "tag", "version", "
|
|
2486
|
+
var SUB_RESOURCES = /* @__PURE__ */ new Set(["share", "cat", "tag", "version", "annotation", "skill", "snippets", "work", "project", "workspace", "pages"]);
|
|
1168
2487
|
function isSubResource(cmd) {
|
|
1169
2488
|
return SUB_RESOURCES.has(cmd);
|
|
1170
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
|
+
}
|
|
1171
2515
|
async function main() {
|
|
1172
2516
|
const parsed = parseArgs(process.argv.slice(2));
|
|
1173
2517
|
const { command, subcommand, positional, flags } = parsed;
|
|
@@ -1189,28 +2533,8 @@ async function main() {
|
|
|
1189
2533
|
console.log(brandedHelp());
|
|
1190
2534
|
process.exit(0);
|
|
1191
2535
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
"read",
|
|
1195
|
-
"list",
|
|
1196
|
-
"get",
|
|
1197
|
-
"search",
|
|
1198
|
-
"delete",
|
|
1199
|
-
"open",
|
|
1200
|
-
"secure",
|
|
1201
|
-
"unsecure",
|
|
1202
|
-
"update",
|
|
1203
|
-
"metadata",
|
|
1204
|
-
"share",
|
|
1205
|
-
"cat",
|
|
1206
|
-
"tag",
|
|
1207
|
-
"version",
|
|
1208
|
-
"note",
|
|
1209
|
-
"skill",
|
|
1210
|
-
"doctor"
|
|
1211
|
-
]);
|
|
1212
|
-
if (!KNOWN_COMMANDS.has(command)) {
|
|
1213
|
-
error(`Unknown command: ${command}`, `Run \`arty --help\` for available commands`);
|
|
2536
|
+
if (!isKnownCommand(command)) {
|
|
2537
|
+
error(`Unknown command: ${command}`, `Run \`artyfax--help\` for available commands`);
|
|
1214
2538
|
}
|
|
1215
2539
|
if (command === "skill") {
|
|
1216
2540
|
switch (subcommand) {
|
|
@@ -1224,7 +2548,7 @@ async function main() {
|
|
|
1224
2548
|
await skillUpdate();
|
|
1225
2549
|
break;
|
|
1226
2550
|
default:
|
|
1227
|
-
error(`Unknown skill subcommand: ${subcommand || "(none)"}`, "Usage:
|
|
2551
|
+
error(`Unknown skill subcommand: ${subcommand || "(none)"}`, "Usage: artyfax skill <install|status|update>");
|
|
1228
2552
|
}
|
|
1229
2553
|
process.exit(0);
|
|
1230
2554
|
}
|
|
@@ -1234,11 +2558,15 @@ async function main() {
|
|
|
1234
2558
|
process.exit(0);
|
|
1235
2559
|
}
|
|
1236
2560
|
switch (command) {
|
|
2561
|
+
case "init": {
|
|
2562
|
+
await init(config, positional[0]);
|
|
2563
|
+
break;
|
|
2564
|
+
}
|
|
1237
2565
|
case "save": {
|
|
1238
2566
|
const fileOrUrl = positional[0];
|
|
1239
2567
|
const url = flags.url;
|
|
1240
2568
|
if (!fileOrUrl && !url) {
|
|
1241
|
-
error("Missing file argument", "Usage:
|
|
2569
|
+
error("Missing file argument", "Usage: artyfax save <file> [--secure] [--category <name>]");
|
|
1242
2570
|
}
|
|
1243
2571
|
if (url) {
|
|
1244
2572
|
await save(config, {
|
|
@@ -1263,7 +2591,7 @@ async function main() {
|
|
|
1263
2591
|
}
|
|
1264
2592
|
let content;
|
|
1265
2593
|
try {
|
|
1266
|
-
content =
|
|
2594
|
+
content = readFileSync5(resolve5(fileOrUrl), "utf-8");
|
|
1267
2595
|
} catch {
|
|
1268
2596
|
error(`File not found: ${fileOrUrl}`);
|
|
1269
2597
|
}
|
|
@@ -1285,7 +2613,7 @@ async function main() {
|
|
|
1285
2613
|
}
|
|
1286
2614
|
case "read": {
|
|
1287
2615
|
const slug = positional[0];
|
|
1288
|
-
if (!slug) error("Missing slug argument", "Usage:
|
|
2616
|
+
if (!slug) error("Missing slug argument", "Usage: artyfax read <slug>");
|
|
1289
2617
|
await read(config, slug, !!flags.secure);
|
|
1290
2618
|
break;
|
|
1291
2619
|
}
|
|
@@ -1306,57 +2634,78 @@ async function main() {
|
|
|
1306
2634
|
offset,
|
|
1307
2635
|
archived: !!flags.archived,
|
|
1308
2636
|
parentId: flags["parent-id"],
|
|
2637
|
+
tag: flags.tag,
|
|
2638
|
+
workspace: flags.workspace,
|
|
1309
2639
|
json
|
|
1310
2640
|
});
|
|
1311
2641
|
break;
|
|
1312
2642
|
}
|
|
1313
2643
|
case "get": {
|
|
1314
2644
|
const slug = positional[0];
|
|
1315
|
-
if (!slug) error("Missing slug argument", "Usage:
|
|
2645
|
+
if (!slug) error("Missing slug argument", "Usage: artyfax get <slug>");
|
|
1316
2646
|
await get(config, slug);
|
|
1317
2647
|
break;
|
|
1318
2648
|
}
|
|
1319
2649
|
case "search": {
|
|
1320
2650
|
const query = positional.join(" ");
|
|
1321
|
-
if (!query) error("Missing search query", "Usage:
|
|
1322
|
-
await search(config, query, json);
|
|
2651
|
+
if (!query) error("Missing search query", "Usage: artyfax search <query>");
|
|
2652
|
+
await search(config, query, json, flags.workspace);
|
|
1323
2653
|
break;
|
|
1324
2654
|
}
|
|
1325
2655
|
case "delete": {
|
|
1326
2656
|
const slug = positional[0];
|
|
1327
|
-
if (!slug) error("Missing slug argument", "Usage:
|
|
2657
|
+
if (!slug) error("Missing slug argument", "Usage: artyfax delete <slug>");
|
|
1328
2658
|
await del(config, slug, yes, json);
|
|
1329
2659
|
break;
|
|
1330
2660
|
}
|
|
1331
2661
|
case "open": {
|
|
1332
2662
|
const slug = positional[0];
|
|
1333
|
-
if (!slug) error("Missing slug argument", "Usage:
|
|
2663
|
+
if (!slug) error("Missing slug argument", "Usage: artyfax open <slug>");
|
|
1334
2664
|
await open(config, slug);
|
|
1335
2665
|
break;
|
|
1336
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
|
+
}
|
|
1337
2685
|
case "secure": {
|
|
1338
2686
|
const slug = positional[0];
|
|
1339
|
-
if (!slug) error("Missing slug argument", "Usage:
|
|
2687
|
+
if (!slug) error("Missing slug argument", "Usage: artyfax secure <slug>");
|
|
1340
2688
|
await secure(config, slug);
|
|
1341
2689
|
break;
|
|
1342
2690
|
}
|
|
1343
2691
|
case "unsecure": {
|
|
1344
2692
|
const slug = positional[0];
|
|
1345
|
-
if (!slug) error("Missing slug argument", "Usage:
|
|
2693
|
+
if (!slug) error("Missing slug argument", "Usage: artyfax unsecure <slug>");
|
|
1346
2694
|
await unsecure(config, slug);
|
|
1347
2695
|
break;
|
|
1348
2696
|
}
|
|
1349
2697
|
case "update": {
|
|
1350
2698
|
const slug = positional[0];
|
|
1351
|
-
if (!slug) error("Missing slug argument", "Usage:
|
|
2699
|
+
if (!slug) error("Missing slug argument", "Usage: artyfax update <slug> [file] (reads from stdin if no file)");
|
|
1352
2700
|
await update(config, slug, positional[1], json);
|
|
1353
2701
|
break;
|
|
1354
2702
|
}
|
|
1355
2703
|
case "metadata": {
|
|
1356
2704
|
const slug = positional[0];
|
|
1357
|
-
if (!slug) error("Missing slug argument", "Usage:
|
|
2705
|
+
if (!slug) error("Missing slug argument", "Usage: artyfax metadata <slug> --category <name> --tags <t1,t2>");
|
|
1358
2706
|
await metadata(config, slug, {
|
|
1359
2707
|
category: flags.category,
|
|
2708
|
+
slug: flags.slug,
|
|
1360
2709
|
tags: flags.tags,
|
|
1361
2710
|
title: flags.title,
|
|
1362
2711
|
theme: flags.theme,
|
|
@@ -1366,11 +2715,25 @@ async function main() {
|
|
|
1366
2715
|
});
|
|
1367
2716
|
break;
|
|
1368
2717
|
}
|
|
2718
|
+
case "cover": {
|
|
2719
|
+
const slug = positional[0];
|
|
2720
|
+
const file = positional[1];
|
|
2721
|
+
if (!slug || !file) error("Missing arguments", 'Usage: artyfax cover <doc> <image-file> [--alt "..."]');
|
|
2722
|
+
await setCover(config, slug, file, { alt: flags.alt, json });
|
|
2723
|
+
break;
|
|
2724
|
+
}
|
|
2725
|
+
case "image": {
|
|
2726
|
+
const slug = positional[0];
|
|
2727
|
+
const file = positional[1];
|
|
2728
|
+
if (!slug || !file) error("Missing arguments", 'Usage: artyfax image <doc> <image-file> [--alt "..."]');
|
|
2729
|
+
await addImage(config, slug, file, { alt: flags.alt, json });
|
|
2730
|
+
break;
|
|
2731
|
+
}
|
|
1369
2732
|
case "share": {
|
|
1370
2733
|
switch (subcommand) {
|
|
1371
2734
|
case "create": {
|
|
1372
2735
|
const slug = positional[0];
|
|
1373
|
-
if (!slug) error("Missing slug argument", "Usage:
|
|
2736
|
+
if (!slug) error("Missing slug argument", "Usage: artyfax share create <slug>");
|
|
1374
2737
|
await shareCreate(config, slug, json);
|
|
1375
2738
|
break;
|
|
1376
2739
|
}
|
|
@@ -1379,78 +2742,293 @@ async function main() {
|
|
|
1379
2742
|
break;
|
|
1380
2743
|
case "revoke": {
|
|
1381
2744
|
const hash = positional[0];
|
|
1382
|
-
if (!hash) error("Missing share hash", "Usage:
|
|
2745
|
+
if (!hash) error("Missing share hash", "Usage: artyfax share revoke <hash>");
|
|
1383
2746
|
await shareRevoke(config, hash, yes, json);
|
|
1384
2747
|
break;
|
|
1385
2748
|
}
|
|
1386
2749
|
default:
|
|
1387
|
-
error(`Unknown share subcommand: ${subcommand || "(none)"}`, "Usage:
|
|
2750
|
+
error(`Unknown share subcommand: ${subcommand || "(none)"}`, "Usage: artyfax share <create|list|revoke>");
|
|
1388
2751
|
}
|
|
1389
2752
|
break;
|
|
1390
2753
|
}
|
|
1391
2754
|
case "cat": {
|
|
1392
|
-
if (subcommand !== "list") error(`Unknown cat subcommand: ${subcommand || "(none)"}`, "Usage:
|
|
2755
|
+
if (subcommand !== "list") error(`Unknown cat subcommand: ${subcommand || "(none)"}`, "Usage: artyfax cat list");
|
|
1393
2756
|
await catList(config, json);
|
|
1394
2757
|
break;
|
|
1395
2758
|
}
|
|
1396
2759
|
case "tag": {
|
|
1397
|
-
|
|
1398
|
-
|
|
2760
|
+
switch (subcommand) {
|
|
2761
|
+
case "list":
|
|
2762
|
+
await tagList(config, json);
|
|
2763
|
+
break;
|
|
2764
|
+
case "delete": {
|
|
2765
|
+
const tag = positional[0];
|
|
2766
|
+
if (!tag) error("Missing tag argument", "Usage: artyfax tag delete <tag>");
|
|
2767
|
+
await tagDelete(config, tag, json);
|
|
2768
|
+
break;
|
|
2769
|
+
}
|
|
2770
|
+
default:
|
|
2771
|
+
error(`Unknown tag subcommand: ${subcommand || "(none)"}`, "Usage: artyfax tag list | artyfax tag delete <tag>");
|
|
2772
|
+
}
|
|
1399
2773
|
break;
|
|
1400
2774
|
}
|
|
1401
2775
|
case "version": {
|
|
1402
2776
|
switch (subcommand) {
|
|
1403
2777
|
case "list": {
|
|
1404
2778
|
const slug = positional[0];
|
|
1405
|
-
if (!slug) error("Missing slug argument", "Usage:
|
|
2779
|
+
if (!slug) error("Missing slug argument", "Usage: artyfax version list <slug>");
|
|
1406
2780
|
await versionList(config, slug, json);
|
|
1407
2781
|
break;
|
|
1408
2782
|
}
|
|
1409
2783
|
case "restore": {
|
|
1410
2784
|
const slug = positional[0];
|
|
1411
|
-
if (!slug) error("Missing slug argument", "Usage:
|
|
2785
|
+
if (!slug) error("Missing slug argument", "Usage: artyfax version restore <slug>");
|
|
1412
2786
|
await versionRestore(config, slug, yes, json);
|
|
1413
2787
|
break;
|
|
1414
2788
|
}
|
|
1415
2789
|
default:
|
|
1416
|
-
error(`Unknown version subcommand: ${subcommand || "(none)"}`, "Usage:
|
|
2790
|
+
error(`Unknown version subcommand: ${subcommand || "(none)"}`, "Usage: artyfax version <list|restore>");
|
|
1417
2791
|
}
|
|
1418
2792
|
break;
|
|
1419
2793
|
}
|
|
1420
|
-
case "
|
|
2794
|
+
case "annotation": {
|
|
1421
2795
|
switch (subcommand) {
|
|
1422
2796
|
case "list": {
|
|
1423
2797
|
const slug = positional[0];
|
|
1424
|
-
if (!slug) error("Missing slug argument", "Usage:
|
|
1425
|
-
await
|
|
2798
|
+
if (!slug) error("Missing slug argument", "Usage: artyfax annotation list <slug> [--include-recipients] [--since <iso>]");
|
|
2799
|
+
await annotationList(config, slug, json, {
|
|
2800
|
+
includeRecipients: Boolean(flags["include-recipients"]),
|
|
2801
|
+
since: typeof flags.since === "string" ? flags.since : void 0
|
|
2802
|
+
});
|
|
1426
2803
|
break;
|
|
1427
2804
|
}
|
|
1428
|
-
case "
|
|
1429
|
-
const
|
|
1430
|
-
if (!
|
|
1431
|
-
await
|
|
2805
|
+
case "search": {
|
|
2806
|
+
const query = positional.join(" ");
|
|
2807
|
+
if (!query) error("Missing search query", "Usage: artyfax annotation search <query>");
|
|
2808
|
+
await annotationSearch(config, query, json);
|
|
2809
|
+
break;
|
|
2810
|
+
}
|
|
2811
|
+
default:
|
|
2812
|
+
error(`Unknown annotation subcommand: ${subcommand || "(none)"}`, "Usage: artyfax annotation <list|search>");
|
|
2813
|
+
}
|
|
2814
|
+
break;
|
|
2815
|
+
}
|
|
2816
|
+
case "snippets": {
|
|
2817
|
+
const tagsCsv = flags.tags;
|
|
2818
|
+
const parsedTags = tagsCsv ? tagsCsv.split(",").map((t) => t.trim()).filter(Boolean) : void 0;
|
|
2819
|
+
switch (subcommand) {
|
|
2820
|
+
case "list":
|
|
2821
|
+
case void 0: {
|
|
2822
|
+
await snippetList(config, {
|
|
2823
|
+
type: flags.type,
|
|
2824
|
+
starred: !!flags.starred,
|
|
2825
|
+
tag: flags.tag,
|
|
2826
|
+
limit: flags.limit ? Number(flags.limit) : void 0
|
|
2827
|
+
}, json);
|
|
1432
2828
|
break;
|
|
1433
2829
|
}
|
|
1434
2830
|
case "search": {
|
|
1435
2831
|
const query = positional.join(" ");
|
|
1436
|
-
if (!query) error("Missing search query", "Usage:
|
|
1437
|
-
await
|
|
2832
|
+
if (!query) error("Missing search query", "Usage: artyfax snippets search <query>");
|
|
2833
|
+
await snippetSearch(config, query, {
|
|
2834
|
+
type: flags.type,
|
|
2835
|
+
limit: flags.limit ? Number(flags.limit) : void 0
|
|
2836
|
+
}, json);
|
|
2837
|
+
break;
|
|
2838
|
+
}
|
|
2839
|
+
case "show": {
|
|
2840
|
+
const id = positional[0];
|
|
2841
|
+
if (!id) error("Missing snippet id", "Usage: artyfax snippets show <id>");
|
|
2842
|
+
await snippetShow(config, id, json);
|
|
2843
|
+
break;
|
|
2844
|
+
}
|
|
2845
|
+
case "new": {
|
|
2846
|
+
await snippetNew(config, {
|
|
2847
|
+
image: flags.image,
|
|
2848
|
+
type: flags.type ?? void 0,
|
|
2849
|
+
text: flags.text,
|
|
2850
|
+
file: flags.file,
|
|
2851
|
+
language: flags.language,
|
|
2852
|
+
note: flags.note,
|
|
2853
|
+
sourceUrl: flags["source-url"],
|
|
2854
|
+
sourceTitle: flags["source-title"],
|
|
2855
|
+
sourceSiteName: flags["source-site-name"],
|
|
2856
|
+
sourceAuthor: flags["source-author"],
|
|
2857
|
+
alt: flags.alt,
|
|
2858
|
+
tags: parsedTags,
|
|
2859
|
+
starred: !!flags.starred
|
|
2860
|
+
}, json);
|
|
2861
|
+
break;
|
|
2862
|
+
}
|
|
2863
|
+
case "delete": {
|
|
2864
|
+
const id = positional[0];
|
|
2865
|
+
if (!id) error("Missing snippet id", "Usage: artyfax snippets delete <id>");
|
|
2866
|
+
await snippetDelete(config, id, json);
|
|
2867
|
+
break;
|
|
2868
|
+
}
|
|
2869
|
+
case "star": {
|
|
2870
|
+
const id = positional[0];
|
|
2871
|
+
if (!id) error("Missing snippet id", "Usage: artyfax snippets star <id>");
|
|
2872
|
+
await snippetStar(config, id, true, json);
|
|
2873
|
+
break;
|
|
2874
|
+
}
|
|
2875
|
+
case "unstar": {
|
|
2876
|
+
const id = positional[0];
|
|
2877
|
+
if (!id) error("Missing snippet id", "Usage: artyfax snippets unstar <id>");
|
|
2878
|
+
await snippetStar(config, id, false, json);
|
|
2879
|
+
break;
|
|
2880
|
+
}
|
|
2881
|
+
default:
|
|
2882
|
+
error(`Unknown snippets subcommand: ${subcommand}`, "Usage: artyfax snippets <list|search|show|new|delete|star|unstar>");
|
|
2883
|
+
}
|
|
2884
|
+
break;
|
|
2885
|
+
}
|
|
2886
|
+
case "task":
|
|
2887
|
+
case "tasks": {
|
|
2888
|
+
const id = positional[0];
|
|
2889
|
+
if (!id) error("Missing document or work ID", 'Usage: artyfax tasks <id>\n artyfax task <id> "task text" <active|done|pending>');
|
|
2890
|
+
if (positional.length <= 1) {
|
|
2891
|
+
await taskList(config, id, json);
|
|
2892
|
+
} else {
|
|
2893
|
+
const state = positional[positional.length - 1];
|
|
2894
|
+
const taskText = positional.slice(1, -1).join(" ");
|
|
2895
|
+
if (!taskText) error("Missing task text", 'Usage: artyfax task <id> "task text" <active|done|pending>');
|
|
2896
|
+
await taskUpdate(config, id, taskText, state, json);
|
|
2897
|
+
}
|
|
2898
|
+
break;
|
|
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);
|
|
1438
2925
|
break;
|
|
1439
2926
|
}
|
|
1440
2927
|
default:
|
|
1441
|
-
error(`Unknown
|
|
2928
|
+
error(`Unknown pages subcommand: ${subcommand || "(none)"}`, "Usage: artyfax pages <list|order|move>");
|
|
2929
|
+
}
|
|
2930
|
+
break;
|
|
2931
|
+
}
|
|
2932
|
+
case "work": {
|
|
2933
|
+
switch (subcommand) {
|
|
2934
|
+
case "":
|
|
2935
|
+
await workBoard(config, json);
|
|
2936
|
+
break;
|
|
2937
|
+
case "list":
|
|
2938
|
+
await workList(config, json, flags);
|
|
2939
|
+
break;
|
|
2940
|
+
case "next":
|
|
2941
|
+
await workNext(config, json, flags.agent);
|
|
2942
|
+
break;
|
|
2943
|
+
case "start": {
|
|
2944
|
+
const id = positional[0];
|
|
2945
|
+
if (!id) error("Missing work item ID", "Usage: artyfax work start <id>");
|
|
2946
|
+
await workSetStatus(config, id, "in_progress", json);
|
|
2947
|
+
break;
|
|
2948
|
+
}
|
|
2949
|
+
case "done": {
|
|
2950
|
+
const id = positional[0];
|
|
2951
|
+
if (!id) error("Missing work item ID", "Usage: artyfax work done <id>");
|
|
2952
|
+
await workSetStatus(config, id, "done", json);
|
|
2953
|
+
break;
|
|
2954
|
+
}
|
|
2955
|
+
case "status": {
|
|
2956
|
+
const id = positional[0];
|
|
2957
|
+
const status = positional[1];
|
|
2958
|
+
if (!id || !status) error("Missing arguments", "Usage: artyfax work status <id> <status>");
|
|
2959
|
+
await workSetStatus(config, id, status, json);
|
|
2960
|
+
break;
|
|
2961
|
+
}
|
|
2962
|
+
case "create": {
|
|
2963
|
+
const title = positional.join(" ");
|
|
2964
|
+
if (!title) error("Missing title", 'Usage: artyfax work create "Title"');
|
|
2965
|
+
await workCreate(config, title, flags, json);
|
|
2966
|
+
break;
|
|
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
|
+
}
|
|
2973
|
+
default:
|
|
2974
|
+
error(`Unknown work subcommand: ${subcommand}`, "Usage: artyfax work <list|next|start|done|status|create|touch>");
|
|
2975
|
+
}
|
|
2976
|
+
break;
|
|
2977
|
+
}
|
|
2978
|
+
case "project": {
|
|
2979
|
+
switch (subcommand) {
|
|
2980
|
+
case "list":
|
|
2981
|
+
case "":
|
|
2982
|
+
await projectList(config, json);
|
|
2983
|
+
break;
|
|
2984
|
+
case "create": {
|
|
2985
|
+
const name = positional.join(" ");
|
|
2986
|
+
if (!name) error("Missing project name", 'Usage: artyfax project create "Name" --prefix XX');
|
|
2987
|
+
const prefix = flags.prefix;
|
|
2988
|
+
if (!prefix) error("Missing --prefix", 'Usage: artyfax project create "Name" --prefix XX');
|
|
2989
|
+
const wsFlag = flags.workspaces ?? flags.workspace;
|
|
2990
|
+
const workspaces = wsFlag ? wsFlag.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
|
|
2991
|
+
await projectCreate(config, name, prefix, flags.description, json, workspaces);
|
|
2992
|
+
break;
|
|
2993
|
+
}
|
|
2994
|
+
default:
|
|
2995
|
+
error(`Unknown project subcommand: ${subcommand}`, "Usage: artyfax project <list|create>");
|
|
2996
|
+
}
|
|
2997
|
+
break;
|
|
2998
|
+
}
|
|
2999
|
+
case "workspace": {
|
|
3000
|
+
switch (subcommand) {
|
|
3001
|
+
case "list":
|
|
3002
|
+
case "":
|
|
3003
|
+
await workspaceList(config, json);
|
|
3004
|
+
break;
|
|
3005
|
+
default:
|
|
3006
|
+
error(`Unknown workspace subcommand: ${subcommand}`, "Usage: artyfax workspace <list>");
|
|
1442
3007
|
}
|
|
1443
3008
|
break;
|
|
1444
3009
|
}
|
|
1445
3010
|
default:
|
|
1446
|
-
error(`Unknown command: ${command}`, `Run \`
|
|
3011
|
+
error(`Unknown command: ${command}`, `Run \`artyfax--help\` for available commands`);
|
|
1447
3012
|
}
|
|
1448
3013
|
}
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
3014
|
+
function isDirectRun() {
|
|
3015
|
+
const entry = process.argv[1];
|
|
3016
|
+
if (!entry) return false;
|
|
3017
|
+
const self = fileURLToPath(import.meta.url);
|
|
3018
|
+
try {
|
|
3019
|
+
return realpathSync(entry) === realpathSync(self);
|
|
3020
|
+
} catch {
|
|
3021
|
+
return resolve5(entry) === self;
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
if (isDirectRun()) {
|
|
3025
|
+
main().catch((e) => {
|
|
3026
|
+
console.error(c.rose(e.message || String(e)));
|
|
3027
|
+
process.exit(1);
|
|
3028
|
+
});
|
|
3029
|
+
}
|
|
1453
3030
|
export {
|
|
1454
3031
|
VERSION,
|
|
3032
|
+
isKnownCommand,
|
|
1455
3033
|
parseArgs
|
|
1456
3034
|
};
|