artyfax 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/dist/cli.js +1432 -0
  4. package/package.json +49 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1432 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { readFileSync as readFileSync2 } from "fs";
5
+ import { resolve as resolve2 } from "path";
6
+
7
+ // src/config.ts
8
+ function getConfig(flags) {
9
+ const apiKey = flags["api-key"] || process.env.ARTYFAX_API_KEY;
10
+ if (!apiKey) {
11
+ console.error("No API key. Set ARTYFAX_API_KEY or pass --api-key.");
12
+ process.exit(1);
13
+ }
14
+ return {
15
+ apiKey,
16
+ endpoint: flags.endpoint || process.env.ARTYFAX_ENDPOINT || "https://artyfax.io",
17
+ passphrase: process.env.ARTYFAX_SECURE_PASSPHRASE || null
18
+ };
19
+ }
20
+ async function apiFetch(config, path, init) {
21
+ const res = await fetch(`${config.endpoint}/api${path}`, {
22
+ ...init,
23
+ headers: {
24
+ "Content-Type": "application/json",
25
+ "X-API-Key": config.apiKey,
26
+ ...init?.headers
27
+ }
28
+ });
29
+ if (!res.ok) {
30
+ const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
31
+ throw new Error(body.error ?? `HTTP ${res.status}`);
32
+ }
33
+ return res.json();
34
+ }
35
+
36
+ // src/passphrase.ts
37
+ import { createInterface } from "readline";
38
+
39
+ // ../shared/src/secure-crypto.ts
40
+ var PBKDF2_ITERATIONS = 6e5;
41
+ var VERIFICATION_STRING = "artyfax-secure-docs-v1";
42
+ async function deriveSDK(passphrase, salt) {
43
+ const keyMaterial = await crypto.subtle.importKey(
44
+ "raw",
45
+ new TextEncoder().encode(passphrase),
46
+ "PBKDF2",
47
+ false,
48
+ ["deriveKey"]
49
+ );
50
+ return crypto.subtle.deriveKey(
51
+ { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-512" },
52
+ keyMaterial,
53
+ { name: "AES-GCM", length: 256 },
54
+ true,
55
+ ["encrypt", "decrypt"]
56
+ );
57
+ }
58
+ async function encryptSecure(plaintext, sdk) {
59
+ const iv = crypto.getRandomValues(new Uint8Array(12));
60
+ const encoded = new TextEncoder().encode(plaintext);
61
+ const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, sdk, encoded);
62
+ const combined = new Uint8Array(iv.length + ciphertext.byteLength);
63
+ combined.set(iv);
64
+ combined.set(new Uint8Array(ciphertext), iv.length);
65
+ return bufferToBase64(combined.buffer);
66
+ }
67
+ async function decryptSecure(blob, sdk) {
68
+ const data = new Uint8Array(base64ToBuffer(blob));
69
+ const iv = data.slice(0, 12);
70
+ const ciphertext = data.slice(12);
71
+ const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, sdk, ciphertext);
72
+ return new TextDecoder().decode(decrypted);
73
+ }
74
+ async function verifyPassphrase(sdk, verifyBlob) {
75
+ try {
76
+ const result = await decryptSecure(verifyBlob, sdk);
77
+ return result === VERIFICATION_STRING;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+ function base64ToBuffer(base64) {
83
+ const binary = atob(base64);
84
+ const bytes = new Uint8Array(binary.length);
85
+ for (let i = 0; i < binary.length; i++) {
86
+ bytes[i] = binary.charCodeAt(i);
87
+ }
88
+ return bytes.buffer;
89
+ }
90
+ function bufferToBase64(buffer) {
91
+ const bytes = new Uint8Array(buffer);
92
+ let binary = "";
93
+ for (let i = 0; i < bytes.length; i++) {
94
+ binary += String.fromCharCode(bytes[i]);
95
+ }
96
+ return btoa(binary);
97
+ }
98
+
99
+ // src/passphrase.ts
100
+ async function promptPassphrase(prompt = "Passphrase: ") {
101
+ return new Promise((resolve3, reject) => {
102
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
103
+ process.stderr.write(prompt);
104
+ const cleanup = () => {
105
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
106
+ process.stdin.removeListener("data", onData);
107
+ rl.close();
108
+ };
109
+ process.on("exit", cleanup);
110
+ if (process.stdin.isTTY) {
111
+ process.stdin.setRawMode(true);
112
+ }
113
+ let input = "";
114
+ const onData = (ch) => {
115
+ const c2 = ch.toString();
116
+ if (c2 === "\n" || c2 === "\r") {
117
+ process.stderr.write("\n");
118
+ cleanup();
119
+ resolve3(input);
120
+ } else if (c2 === "" || c2.length === 0) {
121
+ process.stderr.write("\n");
122
+ cleanup();
123
+ process.exit(1);
124
+ } else if (c2 === "\x7F" || c2 === "\b") {
125
+ input = input.slice(0, -1);
126
+ } else {
127
+ input += c2;
128
+ }
129
+ };
130
+ process.stdin.on("data", onData);
131
+ process.stdin.resume();
132
+ });
133
+ }
134
+ async function getSDK(config) {
135
+ const passphrase = config.passphrase ?? await promptPassphrase();
136
+ if (config.passphrase) {
137
+ Reflect.deleteProperty(process.env, "ARTYFAX_SECURE_PASSPHRASE");
138
+ }
139
+ const state = await apiFetch(config, "/account/secure-state");
140
+ if (!state.salt || !state.verify) {
141
+ throw new Error("E2EE not set up. Configure a passphrase in the Artyfax web app first.");
142
+ }
143
+ const saltBytes = new Uint8Array(base64ToBuffer(state.salt));
144
+ const sdk = await deriveSDK(passphrase, saltBytes);
145
+ const valid = await verifyPassphrase(sdk, state.verify);
146
+ if (!valid) {
147
+ throw new Error("Incorrect passphrase");
148
+ }
149
+ return sdk;
150
+ }
151
+
152
+ // src/ui.ts
153
+ import { createInterface as createInterface2 } from "readline";
154
+ import pc from "picocolors";
155
+ import ora from "ora";
156
+ var isTTY = process.stdout.isTTY === true;
157
+ var c = {
158
+ amber: (s) => pc.yellow(s),
159
+ teal: (s) => pc.green(s),
160
+ rose: (s) => pc.red(s),
161
+ muted: (s) => pc.dim(s),
162
+ bright: (s) => pc.bold(s),
163
+ blue: (s) => pc.blue(s)
164
+ };
165
+ function spinner(text) {
166
+ return ora({ text, isSilent: !isTTY });
167
+ }
168
+ function table(rows, { header } = {}) {
169
+ const all = header ? [header, ...rows] : rows;
170
+ const widths = [];
171
+ for (const row of all) {
172
+ for (let i = 0; i < row.length; i++) {
173
+ const len = stripAnsi(row[i]).length;
174
+ widths[i] = Math.max(widths[i] || 0, len);
175
+ }
176
+ }
177
+ const termWidth = process.stdout.columns || 120;
178
+ const lines = [];
179
+ for (let r = 0; r < all.length; r++) {
180
+ const row = all[r];
181
+ const parts = [];
182
+ for (let i = 0; i < row.length; i++) {
183
+ const raw = stripAnsi(row[i]);
184
+ const pad = i < row.length - 1 ? " ".repeat(Math.max(0, widths[i] - raw.length)) : "";
185
+ parts.push(row[i] + pad);
186
+ }
187
+ let line = parts.join(" ");
188
+ if (stripAnsi(line).length > termWidth) {
189
+ const plain = stripAnsi(line);
190
+ line = plain.slice(0, termWidth - 1) + "\u2026";
191
+ }
192
+ lines.push(line);
193
+ if (r === 0 && header) {
194
+ lines.push(c.muted("\u2500".repeat(Math.min(stripAnsi(line).length, termWidth))));
195
+ }
196
+ }
197
+ return lines.join("\n");
198
+ }
199
+ function stripAnsi(s) {
200
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
201
+ }
202
+ function confirm(message) {
203
+ return new Promise((resolve3) => {
204
+ const rl = createInterface2({ input: process.stdin, output: process.stderr });
205
+ rl.question(`${message} ${c.muted("[y/N]")} `, (answer) => {
206
+ rl.close();
207
+ resolve3(answer.trim().toLowerCase() === "y");
208
+ });
209
+ });
210
+ }
211
+ function error(message, suggestion) {
212
+ console.error(c.rose(`Error: ${message}`));
213
+ if (suggestion) console.error(c.muted(suggestion));
214
+ process.exit(1);
215
+ }
216
+
217
+ // src/commands/save.ts
218
+ async function save(config, opts) {
219
+ const spin = spinner(opts.url ? "Extracting article\u2026" : "Saving document\u2026");
220
+ spin.start();
221
+ if (opts.url && opts.secure) {
222
+ spin.stop();
223
+ throw new Error("Cannot combine --url with --secure. URL extraction happens server-side, so the CLI cannot encrypt content it never sees.");
224
+ }
225
+ let content = opts.content;
226
+ if (opts.secure && content) {
227
+ spin.text = "Encrypting\u2026";
228
+ const sdk = await getSDK(config);
229
+ content = await encryptSecure(content, sdk);
230
+ }
231
+ try {
232
+ const body = {
233
+ title: opts.title || void 0,
234
+ content: content || void 0,
235
+ format: opts.format,
236
+ category: opts.category,
237
+ doc_type: opts.docType || "original",
238
+ secure: opts.secure || void 0
239
+ };
240
+ if (opts.url) body.url = opts.url;
241
+ if (opts.tags) body.tags = opts.tags.split(",").map((t) => t.trim());
242
+ if (opts.theme) body.theme = opts.theme;
243
+ if (opts.parentId) body.parent_id = opts.parentId;
244
+ const result = await apiFetch(
245
+ config,
246
+ "/ingest",
247
+ { method: "POST", body: JSON.stringify(body) }
248
+ );
249
+ spin.stop();
250
+ if (opts.json) {
251
+ console.log(JSON.stringify(result.document));
252
+ } else {
253
+ console.log(`${c.teal("Saved:")} ${result.document.slug}${opts.secure ? c.muted(" (encrypted)") : ""}`);
254
+ }
255
+ } catch (e) {
256
+ spin.fail("Save failed");
257
+ throw e;
258
+ }
259
+ }
260
+
261
+ // src/resolve.ts
262
+ async function resolveSlug(config, slugOrId) {
263
+ if (slugOrId.includes("/")) {
264
+ try {
265
+ const doc = await apiFetch(config, `/documents/by-slug/${slugOrId}`);
266
+ return doc;
267
+ } catch {
268
+ }
269
+ }
270
+ if (slugOrId.match(/^[0-9a-f-]{36}$/i)) {
271
+ try {
272
+ const doc = await apiFetch(config, `/documents/${slugOrId}`);
273
+ return doc;
274
+ } catch {
275
+ }
276
+ }
277
+ const docs = await apiFetch(
278
+ config,
279
+ `/documents?limit=500`
280
+ );
281
+ const exact = docs.documents.find(
282
+ (d) => d.slug === slugOrId || d.slug.endsWith(`/${slugOrId}`)
283
+ );
284
+ if (exact) return exact;
285
+ const partial = docs.documents.filter(
286
+ (d) => d.slug.includes(slugOrId) || d.title.toLowerCase().includes(slugOrId.toLowerCase())
287
+ );
288
+ if (partial.length === 1) return partial[0];
289
+ if (partial.length > 1) {
290
+ throw new Error(
291
+ `Ambiguous match for "${slugOrId}". Did you mean:
292
+ ` + partial.slice(0, 5).map((d) => ` ${d.slug}`).join("\n")
293
+ );
294
+ }
295
+ throw new Error(`Document not found: ${slugOrId}. Try \`arty search ${slugOrId}\` or \`arty list\``);
296
+ }
297
+
298
+ // src/commands/read.ts
299
+ async function read(config, slugOrId, forceSecure) {
300
+ const spin = spinner("Resolving document\u2026");
301
+ spin.start();
302
+ const doc = await resolveSlug(config, slugOrId);
303
+ const isSecure = doc.secure === 1 || forceSecure;
304
+ spin.text = "Fetching content\u2026";
305
+ const data = await apiFetch(
306
+ config,
307
+ `/documents/${doc.id}/content`
308
+ );
309
+ spin.stop();
310
+ if (isSecure) {
311
+ const sdk = await getSDK(config);
312
+ const plaintext = await decryptSecure(data.content, sdk);
313
+ process.stdout.write(plaintext);
314
+ } else {
315
+ process.stdout.write(data.md_source ?? data.content);
316
+ }
317
+ }
318
+
319
+ // src/commands/secure.ts
320
+ async function secure(config, slugOrId) {
321
+ const spin = spinner("Resolving document\u2026");
322
+ spin.start();
323
+ const doc = await resolveSlug(config, slugOrId);
324
+ if (doc.secure === 1) {
325
+ spin.stop();
326
+ console.log(c.muted(`Already encrypted: ${doc.slug}`));
327
+ return;
328
+ }
329
+ const sdk = await getSDK(config);
330
+ spin.text = "Fetching content\u2026";
331
+ const data = await apiFetch(
332
+ config,
333
+ `/documents/${doc.id}/content`
334
+ );
335
+ const source = data.md_source ?? data.content;
336
+ spin.text = "Encrypting\u2026";
337
+ const encrypted = await encryptSecure(source, sdk);
338
+ spin.text = "Uploading\u2026";
339
+ await apiFetch(config, `/documents/${doc.id}/content`, {
340
+ method: "PATCH",
341
+ body: JSON.stringify({ content: encrypted })
342
+ });
343
+ await apiFetch(config, `/documents/${doc.id}`, {
344
+ method: "PATCH",
345
+ body: JSON.stringify({ secure: 1 })
346
+ });
347
+ spin.succeed(`${c.teal("Encrypted:")} ${doc.slug}`);
348
+ }
349
+ async function unsecure(config, slugOrId) {
350
+ const spin = spinner("Resolving document\u2026");
351
+ spin.start();
352
+ const doc = await resolveSlug(config, slugOrId);
353
+ if (doc.secure !== 1) {
354
+ spin.stop();
355
+ console.log(c.muted(`Not encrypted: ${doc.slug}`));
356
+ return;
357
+ }
358
+ const sdk = await getSDK(config);
359
+ spin.text = "Fetching content\u2026";
360
+ const data = await apiFetch(
361
+ config,
362
+ `/documents/${doc.id}/content`
363
+ );
364
+ spin.text = "Decrypting\u2026";
365
+ const plaintext = await decryptSecure(data.content, sdk);
366
+ spin.text = "Uploading\u2026";
367
+ await apiFetch(config, `/documents/${doc.id}/content`, {
368
+ method: "PATCH",
369
+ body: JSON.stringify({ content: plaintext })
370
+ });
371
+ await apiFetch(config, `/documents/${doc.id}`, {
372
+ method: "PATCH",
373
+ body: JSON.stringify({ secure: 0 })
374
+ });
375
+ spin.succeed(`${c.teal("Decrypted:")} ${doc.slug}`);
376
+ }
377
+
378
+ // src/commands/list.ts
379
+ async function list(config, opts) {
380
+ const spin = spinner("Loading documents\u2026");
381
+ spin.start();
382
+ let path = `/documents?limit=${opts.limit}`;
383
+ if (opts.category) path += `&category=${encodeURIComponent(opts.category)}`;
384
+ if (opts.offset) path += `&offset=${opts.offset}`;
385
+ if (opts.archived) path += `&archived=1`;
386
+ if (opts.parentId) path += `&parent_id=${encodeURIComponent(opts.parentId)}`;
387
+ const data = await apiFetch(config, path);
388
+ spin.stop();
389
+ if (opts.json) {
390
+ console.log(JSON.stringify(data.documents));
391
+ return;
392
+ }
393
+ if (data.documents.length === 0) {
394
+ console.log(c.muted("No documents found."));
395
+ return;
396
+ }
397
+ const rows = data.documents.map((doc) => {
398
+ const icons = [
399
+ doc.secure === 1 ? "\u{1F512}" : "",
400
+ (doc.shared_count ?? 0) > 0 ? "\u{1F517}" : ""
401
+ ].filter(Boolean).join("") || " ";
402
+ return [
403
+ c.muted(doc.updated_at.slice(0, 10)),
404
+ icons,
405
+ doc.title,
406
+ c.muted(doc.slug)
407
+ ];
408
+ });
409
+ console.log(table(rows, { header: [c.muted("Updated"), "", "Title", c.muted("Slug")] }));
410
+ }
411
+
412
+ // src/commands/search.ts
413
+ async function search(config, query, json) {
414
+ const spin = spinner(`Searching for "${query}"\u2026`);
415
+ spin.start();
416
+ const data = await apiFetch(
417
+ config,
418
+ `/search?q=${encodeURIComponent(query)}`
419
+ );
420
+ spin.stop();
421
+ if (json) {
422
+ console.log(JSON.stringify(data.results));
423
+ return;
424
+ }
425
+ if (data.results.length === 0) {
426
+ console.log(c.muted(`No results for "${query}".`));
427
+ return;
428
+ }
429
+ const rows = data.results.map((r) => [
430
+ r.title,
431
+ c.muted(r.slug),
432
+ c.muted(r.updated_at.slice(0, 10))
433
+ ]);
434
+ console.log(table(rows, { header: ["Title", c.muted("Slug"), c.muted("Updated")] }));
435
+ console.log(c.muted(`
436
+ ${data.results.length} result${data.results.length === 1 ? "" : "s"}`));
437
+ }
438
+
439
+ // src/commands/get.ts
440
+ async function get(config, slugOrId) {
441
+ const spin = spinner("Resolving document\u2026");
442
+ spin.start();
443
+ const doc = await resolveSlug(config, slugOrId);
444
+ spin.stop();
445
+ console.log(JSON.stringify(doc, null, 2));
446
+ }
447
+
448
+ // src/commands/delete.ts
449
+ async function del(config, slugOrId, yes, json) {
450
+ const spin = spinner("Resolving document\u2026");
451
+ spin.start();
452
+ const doc = await resolveSlug(config, slugOrId);
453
+ spin.stop();
454
+ if (!yes) {
455
+ const confirmed = await confirm(`Delete ${c.bright(doc.slug)}?`);
456
+ if (!confirmed) {
457
+ console.log(c.muted("Cancelled."));
458
+ return;
459
+ }
460
+ }
461
+ const delSpin = spinner("Deleting\u2026");
462
+ delSpin.start();
463
+ await apiFetch(config, `/documents/${doc.id}`, { method: "DELETE" });
464
+ delSpin.stop();
465
+ if (json) {
466
+ console.log(JSON.stringify({ deleted: true, slug: doc.slug, id: doc.id }));
467
+ } else {
468
+ console.log(`${c.rose("Deleted:")} ${doc.slug}`);
469
+ }
470
+ }
471
+
472
+ // src/commands/open.ts
473
+ import { execFile } from "child_process";
474
+ async function open(config, slugOrId) {
475
+ const spin = spinner("Resolving document\u2026");
476
+ spin.start();
477
+ const doc = await resolveSlug(config, slugOrId);
478
+ spin.stop();
479
+ const url = `${config.endpoint}/${doc.slug}`;
480
+ const platform = process.platform;
481
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
482
+ const args = platform === "win32" ? ["/c", "start", url] : [url];
483
+ execFile(cmd, args, (err) => {
484
+ if (err) {
485
+ console.log(c.muted(`Could not open browser. URL: ${url}`));
486
+ } else {
487
+ console.log(`${c.teal("Opened:")} ${url}`);
488
+ }
489
+ });
490
+ }
491
+
492
+ // src/commands/update.ts
493
+ import { readFileSync } from "fs";
494
+ import { resolve } from "path";
495
+ async function update(config, slugOrId, file, json) {
496
+ const spin = spinner("Resolving document\u2026");
497
+ spin.start();
498
+ const doc = await resolveSlug(config, slugOrId);
499
+ spin.text = "Reading content\u2026";
500
+ let content;
501
+ if (file === "-" || !file) {
502
+ if (process.stdin.isTTY) {
503
+ spin.stop();
504
+ throw new Error("No file provided and stdin is a terminal. Pipe content or specify a file path.");
505
+ }
506
+ const chunks = [];
507
+ for await (const chunk of process.stdin) {
508
+ chunks.push(chunk);
509
+ }
510
+ content = Buffer.concat(chunks).toString("utf-8");
511
+ } else {
512
+ try {
513
+ content = readFileSync(resolve(file), "utf-8");
514
+ } catch {
515
+ spin.stop();
516
+ throw new Error(`File not found: ${file}`);
517
+ }
518
+ }
519
+ if (doc.secure === 1) {
520
+ spin.text = "Encrypting (secure document)\u2026";
521
+ const sdk = await getSDK(config);
522
+ content = await encryptSecure(content, sdk);
523
+ }
524
+ spin.text = "Uploading\u2026";
525
+ await apiFetch(config, `/documents/${doc.id}/content`, {
526
+ method: "PATCH",
527
+ body: JSON.stringify({ content })
528
+ });
529
+ spin.stop();
530
+ if (json) {
531
+ console.log(JSON.stringify({ updated: true, slug: doc.slug, id: doc.id, secure: doc.secure === 1 }));
532
+ } else {
533
+ const suffix = doc.secure === 1 ? c.muted(" (re-encrypted)") : "";
534
+ console.log(`${c.teal("Updated:")} ${doc.slug}${suffix}`);
535
+ }
536
+ }
537
+
538
+ // src/commands/metadata.ts
539
+ async function metadata(config, slugOrId, opts) {
540
+ const spin = spinner("Resolving document\u2026");
541
+ spin.start();
542
+ const doc = await resolveSlug(config, slugOrId);
543
+ const patch = {};
544
+ if (opts.category !== void 0) patch.category = opts.category;
545
+ if (opts.title !== void 0) patch.title = opts.title;
546
+ if (opts.theme !== void 0) patch.theme = opts.theme;
547
+ if (opts.visibility !== void 0) patch.visibility = opts.visibility;
548
+ if (opts.archived !== void 0) patch.archived = opts.archived ? 1 : 0;
549
+ if (opts.tags !== void 0) patch.tags = opts.tags.split(",").map((t) => t.trim());
550
+ if (Object.keys(patch).length === 0) {
551
+ spin.stop();
552
+ throw new Error("No metadata flags provided. Use --category, --tags, --title, --theme, --visibility, or --archived");
553
+ }
554
+ spin.text = "Updating metadata\u2026";
555
+ await apiFetch(config, `/documents/${doc.id}/metadata`, {
556
+ method: "PATCH",
557
+ body: JSON.stringify(patch)
558
+ });
559
+ spin.stop();
560
+ if (opts.json) {
561
+ console.log(JSON.stringify({ updated: true, slug: doc.slug, id: doc.id, fields: Object.keys(patch) }));
562
+ } else {
563
+ const fields = Object.keys(patch).join(", ");
564
+ console.log(`${c.teal("Updated:")} ${doc.slug} ${c.muted(`(${fields})`)}`);
565
+ }
566
+ }
567
+
568
+ // src/commands/share.ts
569
+ async function shareCreate(config, slugOrId, json) {
570
+ const spin = spinner("Resolving document\u2026");
571
+ spin.start();
572
+ const doc = await resolveSlug(config, slugOrId);
573
+ spin.text = "Creating share link\u2026";
574
+ const result = await apiFetch(
575
+ config,
576
+ "/shares",
577
+ {
578
+ method: "POST",
579
+ body: JSON.stringify({ document_id: doc.id })
580
+ }
581
+ );
582
+ spin.stop();
583
+ const url = `${config.endpoint}/s/${result.hash}`;
584
+ if (json) {
585
+ console.log(JSON.stringify({ hash: result.hash, url, slug: doc.slug }));
586
+ } else {
587
+ console.log(`${c.teal("Share created:")} ${url}`);
588
+ }
589
+ }
590
+ async function shareList(config, slugOrId, json) {
591
+ const spin = spinner("Loading shares\u2026");
592
+ spin.start();
593
+ let path = "/shares";
594
+ if (slugOrId) {
595
+ const doc = await resolveSlug(config, slugOrId);
596
+ path = `/shares/${doc.id}`;
597
+ }
598
+ const data = await apiFetch(config, path);
599
+ spin.stop();
600
+ if (json) {
601
+ console.log(JSON.stringify(data.shares));
602
+ return;
603
+ }
604
+ if (data.shares.length === 0) {
605
+ console.log(c.muted("No shares found."));
606
+ return;
607
+ }
608
+ const rows = data.shares.map((s) => [
609
+ s.hash,
610
+ s.title || s.slug || s.document_id,
611
+ String(s.view_count),
612
+ c.muted(s.created_at.slice(0, 10))
613
+ ]);
614
+ console.log(table(rows, { header: ["Hash", "Document", "Views", c.muted("Created")] }));
615
+ }
616
+ async function shareRevoke(config, hash, yes, json) {
617
+ if (!yes) {
618
+ const confirmed = await confirm(`Revoke share ${c.bright(hash)}?`);
619
+ if (!confirmed) {
620
+ console.log(c.muted("Cancelled."));
621
+ return;
622
+ }
623
+ }
624
+ const spin = spinner("Revoking\u2026");
625
+ spin.start();
626
+ await apiFetch(config, `/shares/${hash}`, { method: "DELETE" });
627
+ spin.stop();
628
+ if (json) {
629
+ console.log(JSON.stringify({ revoked: true, hash }));
630
+ } else {
631
+ console.log(`${c.rose("Revoked:")} ${hash}`);
632
+ }
633
+ }
634
+
635
+ // src/commands/category.ts
636
+ async function catList(config, json) {
637
+ const spin = spinner("Loading categories\u2026");
638
+ spin.start();
639
+ const data = await apiFetch(config, "/categories");
640
+ spin.stop();
641
+ if (json) {
642
+ console.log(JSON.stringify(data.categories));
643
+ return;
644
+ }
645
+ if (data.categories.length === 0) {
646
+ console.log(c.muted("No categories found."));
647
+ return;
648
+ }
649
+ const rows = data.categories.map((cat) => [
650
+ cat.icon,
651
+ cat.label,
652
+ c.muted(cat.slug),
653
+ String(cat.count)
654
+ ]);
655
+ console.log(table(rows, { header: ["", "Label", c.muted("Slug"), "Docs"] }));
656
+ }
657
+
658
+ // src/commands/tag.ts
659
+ async function tagList(config, json) {
660
+ const spin = spinner("Loading tags\u2026");
661
+ spin.start();
662
+ const data = await apiFetch(config, "/tags");
663
+ spin.stop();
664
+ if (json) {
665
+ console.log(JSON.stringify(data.tags));
666
+ return;
667
+ }
668
+ if (data.tags.length === 0) {
669
+ console.log(c.muted("No tags found."));
670
+ return;
671
+ }
672
+ const rows = data.tags.map((t) => [t.tag, String(t.count)]);
673
+ console.log(table(rows, { header: ["Tag", "Count"] }));
674
+ }
675
+
676
+ // src/commands/version.ts
677
+ async function versionList(config, slugOrId, json) {
678
+ const spin = spinner("Resolving document\u2026");
679
+ spin.start();
680
+ const doc = await resolveSlug(config, slugOrId);
681
+ spin.text = "Loading versions\u2026";
682
+ const data = await apiFetch(config, `/documents/${doc.id}/versions`);
683
+ spin.stop();
684
+ if (json) {
685
+ console.log(JSON.stringify(data.versions));
686
+ return;
687
+ }
688
+ if (data.versions.length === 0) {
689
+ console.log(c.muted("No versions found."));
690
+ return;
691
+ }
692
+ console.log(`${c.bright(doc.slug)} \u2014 ${data.versions.length} version${data.versions.length === 1 ? "" : "s"}
693
+ `);
694
+ const rows = data.versions.map((v, i) => [
695
+ i === 0 ? c.teal("current") : c.muted(`v${data.versions.length - i}`),
696
+ v.timestamp.replace("T", " ").replace(/\.\d+Z$/, ""),
697
+ v.word_count != null ? `${v.word_count} words` : c.muted("\u2014")
698
+ ]);
699
+ console.log(table(rows, { header: ["", "Timestamp", "Words"] }));
700
+ }
701
+ async function versionRestore(config, slugOrId, yes, json) {
702
+ const spin = spinner("Resolving document\u2026");
703
+ spin.start();
704
+ const doc = await resolveSlug(config, slugOrId);
705
+ spin.text = "Loading versions\u2026";
706
+ const data = await apiFetch(config, `/documents/${doc.id}/versions`);
707
+ spin.stop();
708
+ if (data.versions.length < 2) {
709
+ console.log(c.muted("No previous versions to restore."));
710
+ return;
711
+ }
712
+ const previous = data.versions.slice(1);
713
+ console.log(`${c.bright(doc.slug)} \u2014 ${previous.length} previous version${previous.length === 1 ? "" : "s"}:
714
+ `);
715
+ for (let i = 0; i < previous.length; i++) {
716
+ const v = previous[i];
717
+ const label = `v${data.versions.length - 1 - i}`;
718
+ const ts = v.timestamp.replace("T", " ").replace(/\.\d+Z$/, "");
719
+ const words = v.word_count != null ? ` (${v.word_count} words)` : "";
720
+ console.log(` ${c.amber(label)} ${ts}${c.muted(words)}`);
721
+ }
722
+ const target = previous[0];
723
+ console.log();
724
+ if (!yes) {
725
+ const ts = target.timestamp.replace("T", " ").replace(/\.\d+Z$/, "");
726
+ const confirmed = await confirm(`Restore to ${ts}?`);
727
+ if (!confirmed) {
728
+ console.log(c.muted("Cancelled."));
729
+ return;
730
+ }
731
+ }
732
+ const restoreSpin = spinner("Restoring\u2026");
733
+ restoreSpin.start();
734
+ await apiFetch(config, `/documents/${doc.id}/versions/restore`, {
735
+ method: "POST",
736
+ body: JSON.stringify({ timestamp: target.timestamp })
737
+ });
738
+ restoreSpin.stop();
739
+ if (json) {
740
+ console.log(JSON.stringify({ restored: true, slug: doc.slug, timestamp: target.timestamp }));
741
+ } else {
742
+ console.log(`${c.teal("Restored:")} ${doc.slug} to ${target.timestamp.replace("T", " ").replace(/\.\d+Z$/, "")}`);
743
+ }
744
+ }
745
+
746
+ // src/commands/note.ts
747
+ async function noteList(config, slugOrId, json) {
748
+ const spin = spinner("Resolving document\u2026");
749
+ spin.start();
750
+ const doc = await resolveSlug(config, slugOrId);
751
+ spin.text = "Loading annotations\u2026";
752
+ const data = await apiFetch(
753
+ config,
754
+ `/annotations/doc/${doc.id}`
755
+ );
756
+ spin.stop();
757
+ if (json) {
758
+ console.log(JSON.stringify(data.annotations));
759
+ return;
760
+ }
761
+ if (data.annotations.length === 0) {
762
+ console.log(c.muted("No annotations found."));
763
+ return;
764
+ }
765
+ console.log(`${c.bright(doc.slug)} \u2014 ${data.annotations.length} annotation${data.annotations.length === 1 ? "" : "s"}
766
+ `);
767
+ for (const a of data.annotations) {
768
+ const type = a.type === "highlight" ? c.amber("highlight") : a.type === "comment" ? c.blue("comment") : c.muted(a.type);
769
+ const date = c.muted(a.created_at.slice(0, 10));
770
+ console.log(` ${type} ${date}`);
771
+ if (a.selector_text) console.log(` ${c.muted(">")} ${a.selector_text.slice(0, 80)}${a.selector_text.length > 80 ? "\u2026" : ""}`);
772
+ if (a.body) console.log(` ${a.body.slice(0, 120)}${a.body.length > 120 ? "\u2026" : ""}`);
773
+ console.log();
774
+ }
775
+ }
776
+ async function noteAdd(config, slugOrId, text, json) {
777
+ const spin = spinner("Resolving document\u2026");
778
+ spin.start();
779
+ const doc = await resolveSlug(config, slugOrId);
780
+ let body;
781
+ if (text) {
782
+ body = text;
783
+ } else {
784
+ if (process.stdin.isTTY) {
785
+ spin.stop();
786
+ throw new Error("No text provided. Use --text or pipe text via stdin.");
787
+ }
788
+ spin.stop();
789
+ const chunks = [];
790
+ for await (const chunk of process.stdin) {
791
+ chunks.push(chunk);
792
+ }
793
+ body = Buffer.concat(chunks).toString("utf-8").trim();
794
+ spin.start();
795
+ }
796
+ if (!body) {
797
+ spin.stop();
798
+ throw new Error("No text provided. Use --text or pipe to stdin");
799
+ }
800
+ spin.text = "Adding annotation\u2026";
801
+ const result = await apiFetch(
802
+ config,
803
+ `/annotations/doc/${doc.id}`,
804
+ {
805
+ method: "POST",
806
+ body: JSON.stringify({ type: "comment", body })
807
+ }
808
+ );
809
+ spin.stop();
810
+ if (json) {
811
+ console.log(JSON.stringify({ added: true, id: result.annotation.id, slug: doc.slug }));
812
+ } else {
813
+ console.log(`${c.teal("Added note to")} ${doc.slug}`);
814
+ }
815
+ }
816
+ async function noteSearch(config, query, json) {
817
+ const spin = spinner(`Searching annotations for "${query}"\u2026`);
818
+ spin.start();
819
+ const data = await apiFetch(
820
+ config,
821
+ `/annotations/search?q=${encodeURIComponent(query)}`
822
+ );
823
+ spin.stop();
824
+ if (json) {
825
+ console.log(JSON.stringify(data.annotations));
826
+ return;
827
+ }
828
+ if (data.annotations.length === 0) {
829
+ console.log(c.muted(`No annotation results for "${query}".`));
830
+ return;
831
+ }
832
+ const rows = data.annotations.map((a) => [
833
+ a.type === "highlight" ? c.amber("HL") : c.blue("CM"),
834
+ a.body.slice(0, 60) + (a.body.length > 60 ? "\u2026" : ""),
835
+ c.muted(a.created_at.slice(0, 10))
836
+ ]);
837
+ console.log(table(rows, { header: ["", "Text", c.muted("Date")] }));
838
+ console.log(c.muted(`
839
+ ${data.annotations.length} result${data.annotations.length === 1 ? "" : "s"}`));
840
+ }
841
+
842
+ // src/commands/doctor.ts
843
+ import { existsSync } from "fs";
844
+ import { join } from "path";
845
+ async function doctor(config, json) {
846
+ const checks = [];
847
+ checks.push({ name: "CLI version", status: "ok", detail: `v${VERSION}` });
848
+ const keySource = process.env.ARTYFAX_API_KEY ? "ARTYFAX_API_KEY env var" : "--api-key flag";
849
+ checks.push({ name: "API key", status: "ok", detail: `configured via ${keySource}` });
850
+ checks.push({ name: "Endpoint", status: "ok", detail: config.endpoint });
851
+ try {
852
+ await apiFetch(config, "/categories");
853
+ checks.push({ name: "API reachable", status: "ok", detail: "connected" });
854
+ } catch (e) {
855
+ checks.push({ name: "API reachable", status: "fail", detail: e.message });
856
+ }
857
+ try {
858
+ const state = await apiFetch(config, "/account/secure-state");
859
+ if (state.salt && state.verify) {
860
+ checks.push({ name: "E2EE", status: "ok", detail: "passphrase configured" });
861
+ } else {
862
+ checks.push({ name: "E2EE", status: "warn", detail: "not configured (set up in web app)" });
863
+ }
864
+ } catch {
865
+ checks.push({ name: "E2EE", status: "warn", detail: "could not check" });
866
+ }
867
+ if (process.env.ARTYFAX_SECURE_PASSPHRASE) {
868
+ checks.push({ name: "Passphrase", status: "ok", detail: "ARTYFAX_SECURE_PASSPHRASE set" });
869
+ } else {
870
+ checks.push({ name: "Passphrase", status: "warn", detail: "not set (will prompt interactively)" });
871
+ }
872
+ const homeDir = process.env.HOME || process.env.USERPROFILE || "";
873
+ const userSkillPath = join(homeDir, ".claude", "skills");
874
+ const hasUserSkill = existsSync(join(userSkillPath, "artyfax"));
875
+ const hasProjectSkill = existsSync(join(process.cwd(), ".claude", "skills", "artyfax"));
876
+ if (hasUserSkill || hasProjectSkill) {
877
+ const where = hasProjectSkill ? "project" : "user";
878
+ checks.push({ name: "Claude skill", status: "ok", detail: `installed (${where}-level)` });
879
+ } else {
880
+ checks.push({ name: "Claude skill", status: "warn", detail: "not installed (run `arty skill install`)" });
881
+ }
882
+ if (json) {
883
+ console.log(JSON.stringify(checks));
884
+ return;
885
+ }
886
+ console.log(`${c.amber("artyfax doctor")} ${c.muted(`v${VERSION}`)}
887
+ `);
888
+ for (const check of checks) {
889
+ const icon = check.status === "ok" ? c.teal("\u2713") : check.status === "warn" ? c.amber("\u26A0") : c.rose("\u2717");
890
+ console.log(` ${icon} ${check.name.padEnd(16)} ${check.status === "fail" ? c.rose(check.detail) : check.detail}`);
891
+ }
892
+ const fails = checks.filter((ch) => ch.status === "fail");
893
+ const warns = checks.filter((ch) => ch.status === "warn");
894
+ console.log();
895
+ if (fails.length > 0) {
896
+ console.log(c.rose(`${fails.length} issue${fails.length === 1 ? "" : "s"} found.`));
897
+ } else if (warns.length > 0) {
898
+ console.log(c.amber(`All good, ${warns.length} optional improvement${warns.length === 1 ? "" : "s"}.`));
899
+ } else {
900
+ console.log(c.teal("Everything looks good."));
901
+ }
902
+ }
903
+
904
+ // src/commands/skill.ts
905
+ async function skillInstall(_project) {
906
+ console.log(c.amber("Skill installation is not available yet."));
907
+ console.log(c.muted("The Artyfax skill content is being designed in design-28."));
908
+ console.log(c.muted("Once ready, `arty skill install` will write skill files to ~/.claude/skills/artyfax"));
909
+ }
910
+ async function skillStatus() {
911
+ console.log(c.amber("Skill status is not available yet."));
912
+ console.log(c.muted("Run `arty doctor` to check basic setup."));
913
+ }
914
+ async function skillUpdate() {
915
+ console.log(c.amber("Skill update is not available yet."));
916
+ }
917
+
918
+ // src/cli.ts
919
+ var VERSION = true ? "0.2.0" : "0.0.0-dev";
920
+ function brandedHelp() {
921
+ return `
922
+ ${c.amber("artyfax")} ${c.muted(`v${VERSION}`)} \u2014 your personal document library
923
+
924
+ ${c.bright("Usage:")} arty <command> [options]
925
+
926
+ ${c.bright("Documents")}
927
+ save <file> Save a document (markdown or HTML)
928
+ read <slug> Read document content (handles E2EE)
929
+ list List documents
930
+ get <slug> Document metadata as JSON
931
+ search <query> Full-text search
932
+ update <slug> <file> Push updated content (auto-encrypts secure docs)
933
+ metadata <slug> Update document metadata
934
+ delete <slug> Delete a document
935
+ open <slug> Open document in browser
936
+ secure <slug> Encrypt a document
937
+ unsecure <slug> Remove encryption
938
+
939
+ ${c.bright("Sub-resources")}
940
+ share create <slug> Create a share link
941
+ share list [slug] List shares
942
+ share revoke <hash> Revoke a share link
943
+ cat list List categories
944
+ tag list List tags
945
+ version list <slug> Version history
946
+ version restore <slug> Restore a previous version
947
+
948
+ ${c.bright("Annotations")}
949
+ note list <slug> List annotations on a document
950
+ note add <slug> Add an annotation
951
+ note search <query> Search annotations
952
+
953
+ ${c.bright("Tools")}
954
+ doctor Verify CLI setup
955
+ skill install Install Artyfax skills for Claude Code
956
+ skill status Check skill installation
957
+ skill update Update installed skills
958
+
959
+ ${c.bright("Global options")}
960
+ --json Machine-readable JSON output
961
+ --yes, -y Skip confirmations
962
+ --api-key <key> API key (prefer ARTYFAX_API_KEY env var)
963
+ --endpoint <url> API endpoint (default: https://artyfax.io)
964
+ --help, -h Show help
965
+ --version, -v Show version
966
+
967
+ ${c.bright("Environment")}
968
+ ARTYFAX_API_KEY API key for authentication
969
+ ARTYFAX_ENDPOINT API endpoint URL
970
+ ARTYFAX_SECURE_PASSPHRASE Passphrase for E2EE (avoids prompt)
971
+ `.trim();
972
+ }
973
+ var COMMAND_HELP = {
974
+ save: `${c.amber("arty save")} \u2014 save a document
975
+
976
+ ${c.bright("Usage:")} arty save <file> [options]
977
+ arty save --url <url> [options]
978
+
979
+ ${c.bright("Options:")}
980
+ --url <url> Save from URL (server-side extraction)
981
+ --secure Encrypt the document
982
+ --category <name> Category (default: inbox)
983
+ --format <md|html> Content format (default: md)
984
+ --tags <t1,t2> Comma-separated tags
985
+ --doc-type <type> Document type (default: original, or article for URLs)
986
+ --theme <name> Theme override
987
+ --parent <id> Parent document ID
988
+ --title <name> Title (default: derived from filename)
989
+ --json JSON output
990
+
991
+ ${c.bright("Examples:")}
992
+ arty save notes.md --category inbox
993
+ arty save --url https://example.com/article --category articles
994
+ arty save report.md --secure --category reports`,
995
+ read: `${c.amber("arty read")} \u2014 read document content
996
+
997
+ ${c.bright("Usage:")} arty read <slug>
998
+
999
+ Outputs the document's markdown source to stdout. Handles E2EE
1000
+ decryption automatically (prompts for passphrase if needed).
1001
+
1002
+ ${c.bright("Examples:")}
1003
+ arty read inbox/my-doc
1004
+ arty read my-doc # partial slug match
1005
+ arty read inbox/my-doc > out.md # pipe to file`,
1006
+ list: `${c.amber("arty list")} \u2014 list documents
1007
+
1008
+ ${c.bright("Usage:")} arty list [options]
1009
+
1010
+ ${c.bright("Options:")}
1011
+ --category <name> Filter by category
1012
+ --limit <n> Max results (default: 20)
1013
+ --offset <n> Skip first N results
1014
+ --archived Include archived documents
1015
+ --parent-id <id> Filter by parent document
1016
+ --json JSON output
1017
+
1018
+ ${c.bright("Examples:")}
1019
+ arty list --category inbox --limit 10
1020
+ arty list --archived --json`,
1021
+ get: `${c.amber("arty get")} \u2014 document metadata
1022
+
1023
+ ${c.bright("Usage:")} arty get <slug>
1024
+
1025
+ Outputs full document metadata as JSON. For scripting and inspection.
1026
+
1027
+ ${c.bright("Examples:")}
1028
+ arty get inbox/my-doc
1029
+ arty get my-doc | jq .category`,
1030
+ search: `${c.amber("arty search")} \u2014 full-text search
1031
+
1032
+ ${c.bright("Usage:")} arty search <query> [--json]
1033
+
1034
+ ${c.bright("Examples:")}
1035
+ arty search "deployment guide"
1036
+ arty search cloudflare --json`,
1037
+ update: `${c.amber("arty update")} \u2014 push updated content
1038
+
1039
+ ${c.bright("Usage:")} arty update <slug> [file]
1040
+ cat file.md | arty update <slug>
1041
+
1042
+ Reads from file or stdin. Auto-encrypts if the target is a secure document.
1043
+
1044
+ ${c.bright("Examples:")}
1045
+ arty update inbox/my-doc updated.md
1046
+ echo "# New content" | arty update inbox/my-doc`,
1047
+ metadata: `${c.amber("arty metadata")} \u2014 update document metadata
1048
+
1049
+ ${c.bright("Usage:")} arty metadata <slug> [options]
1050
+
1051
+ ${c.bright("Options:")}
1052
+ --category <name> Move to category
1053
+ --tags <t1,t2> Set tags (comma-separated)
1054
+ --title <name> Update title
1055
+ --theme <name> Set theme
1056
+ --visibility <v> Set visibility (private/public)
1057
+ --archived Archive the document
1058
+ --json JSON output
1059
+
1060
+ ${c.bright("Examples:")}
1061
+ arty metadata inbox/my-doc --category reports --tags "q1,finance"
1062
+ arty metadata my-doc --archived`,
1063
+ delete: `${c.amber("arty delete")} \u2014 delete a document
1064
+
1065
+ ${c.bright("Usage:")} arty delete <slug> [--yes] [--json]
1066
+
1067
+ Prompts for confirmation unless --yes is passed.
1068
+
1069
+ ${c.bright("Examples:")}
1070
+ arty delete inbox/old-doc
1071
+ arty delete inbox/old-doc --yes # skip confirmation`,
1072
+ open: `${c.amber("arty open")} \u2014 open in browser
1073
+
1074
+ ${c.bright("Usage:")} arty open <slug>
1075
+
1076
+ Opens the document URL in your default browser.`,
1077
+ share: `${c.amber("arty share")} \u2014 manage share links
1078
+
1079
+ ${c.bright("Usage:")} arty share create <slug> Create a share link
1080
+ arty share list [slug] List all shares (or per-document)
1081
+ arty share revoke <hash> Revoke a share link
1082
+
1083
+ ${c.bright("Examples:")}
1084
+ arty share create inbox/my-doc
1085
+ arty share list --json
1086
+ arty share revoke SnP5UJUhP6 --yes`,
1087
+ version: `${c.amber("arty version")} \u2014 version history
1088
+
1089
+ ${c.bright("Usage:")} arty version list <slug> Show version history
1090
+ arty version restore <slug> Restore a previous version
1091
+
1092
+ ${c.bright("Examples:")}
1093
+ arty version list inbox/my-doc
1094
+ arty version restore inbox/my-doc --yes`,
1095
+ note: `${c.amber("arty note")} \u2014 annotations
1096
+
1097
+ ${c.bright("Usage:")} arty note list <slug> List annotations
1098
+ arty note add <slug> Add a note (--text or stdin)
1099
+ arty note search <query> Search annotations
1100
+
1101
+ ${c.bright("Examples:")}
1102
+ arty note list inbox/my-doc
1103
+ arty note add inbox/my-doc --text "Needs review"
1104
+ echo "Reviewed and approved" | arty note add inbox/my-doc
1105
+ arty note search "review"`,
1106
+ doctor: `${c.amber("arty doctor")} \u2014 verify CLI setup
1107
+
1108
+ Checks API key, endpoint, connectivity, E2EE status, and skill installation.
1109
+
1110
+ ${c.bright("Usage:")} arty doctor [--json]`,
1111
+ skill: `${c.amber("arty skill")} \u2014 Claude Code skill management
1112
+
1113
+ ${c.bright("Usage:")} arty skill install [--project] Install Artyfax skill
1114
+ arty skill status Check installation
1115
+ arty skill update Update to latest version`
1116
+ };
1117
+ function parseArgs(args) {
1118
+ const flags = {};
1119
+ const positional = [];
1120
+ let command = "";
1121
+ let subcommand = "";
1122
+ for (let i = 0; i < args.length; i++) {
1123
+ const arg = args[i];
1124
+ if (arg.startsWith("--")) {
1125
+ const key = arg.slice(2);
1126
+ const next = args[i + 1];
1127
+ if (next && !next.startsWith("-")) {
1128
+ flags[key] = next;
1129
+ i++;
1130
+ } else {
1131
+ flags[key] = true;
1132
+ }
1133
+ } else if (arg.startsWith("-")) {
1134
+ flags[arg.slice(1)] = true;
1135
+ } else if (!command) {
1136
+ command = arg;
1137
+ } else if (!subcommand && isSubResource(command)) {
1138
+ subcommand = arg;
1139
+ } else {
1140
+ positional.push(arg);
1141
+ }
1142
+ }
1143
+ return { command, subcommand, positional, flags };
1144
+ }
1145
+ var SUB_RESOURCES = /* @__PURE__ */ new Set(["share", "cat", "tag", "version", "note", "skill"]);
1146
+ function isSubResource(cmd) {
1147
+ return SUB_RESOURCES.has(cmd);
1148
+ }
1149
+ async function main() {
1150
+ const parsed = parseArgs(process.argv.slice(2));
1151
+ const { command, subcommand, positional, flags } = parsed;
1152
+ const json = !!flags.json;
1153
+ const yes = !!(flags.yes || flags.y);
1154
+ if (flags.version || flags.v) {
1155
+ console.log(json ? JSON.stringify({ version: VERSION }) : `artyfax v${VERSION}`);
1156
+ process.exit(0);
1157
+ }
1158
+ if (flags.help || flags.h) {
1159
+ if (command && COMMAND_HELP[command]) {
1160
+ console.log(COMMAND_HELP[command]);
1161
+ } else {
1162
+ console.log(brandedHelp());
1163
+ }
1164
+ process.exit(0);
1165
+ }
1166
+ if (!command) {
1167
+ console.log(brandedHelp());
1168
+ process.exit(0);
1169
+ }
1170
+ const KNOWN_COMMANDS = /* @__PURE__ */ new Set([
1171
+ "save",
1172
+ "read",
1173
+ "list",
1174
+ "get",
1175
+ "search",
1176
+ "delete",
1177
+ "open",
1178
+ "secure",
1179
+ "unsecure",
1180
+ "update",
1181
+ "metadata",
1182
+ "share",
1183
+ "cat",
1184
+ "tag",
1185
+ "version",
1186
+ "note",
1187
+ "skill",
1188
+ "doctor"
1189
+ ]);
1190
+ if (!KNOWN_COMMANDS.has(command)) {
1191
+ error(`Unknown command: ${command}`, `Run \`arty --help\` for available commands`);
1192
+ }
1193
+ if (command === "skill") {
1194
+ switch (subcommand) {
1195
+ case "install":
1196
+ await skillInstall(!!flags.project);
1197
+ break;
1198
+ case "status":
1199
+ await skillStatus();
1200
+ break;
1201
+ case "update":
1202
+ await skillUpdate();
1203
+ break;
1204
+ default:
1205
+ error(`Unknown skill subcommand: ${subcommand || "(none)"}`, "Usage: arty skill <install|status|update>");
1206
+ }
1207
+ process.exit(0);
1208
+ }
1209
+ const config = getConfig(flags);
1210
+ if (command === "doctor") {
1211
+ await doctor(config, json);
1212
+ process.exit(0);
1213
+ }
1214
+ switch (command) {
1215
+ case "save": {
1216
+ const fileOrUrl = positional[0];
1217
+ const url = flags.url;
1218
+ if (!fileOrUrl && !url) {
1219
+ error("Missing file argument", "Usage: arty save <file> [--secure] [--category <name>]");
1220
+ }
1221
+ if (url) {
1222
+ await save(config, {
1223
+ title: flags.title || "",
1224
+ content: "",
1225
+ category: flags.category || "inbox",
1226
+ format: "md",
1227
+ secure: !!flags.secure,
1228
+ url,
1229
+ tags: flags.tags,
1230
+ docType: flags["doc-type"] || "article",
1231
+ theme: flags.theme,
1232
+ parentId: flags.parent,
1233
+ json
1234
+ });
1235
+ break;
1236
+ }
1237
+ const format = flags.format || "md";
1238
+ if (format !== "md" && format !== "html") {
1239
+ error('--format must be "md" or "html"');
1240
+ }
1241
+ let content;
1242
+ try {
1243
+ content = readFileSync2(resolve2(fileOrUrl), "utf-8");
1244
+ } catch {
1245
+ error(`File not found: ${fileOrUrl}`);
1246
+ }
1247
+ const title = flags.title || fileOrUrl.replace(/\.[^.]+$/, "").replace(/[-_]/g, " ");
1248
+ await save(config, {
1249
+ title,
1250
+ content,
1251
+ category: flags.category || "inbox",
1252
+ format,
1253
+ secure: !!flags.secure,
1254
+ tags: flags.tags,
1255
+ docType: flags["doc-type"] || "original",
1256
+ theme: flags.theme,
1257
+ parentId: flags.parent,
1258
+ json
1259
+ });
1260
+ break;
1261
+ }
1262
+ case "read": {
1263
+ const slug = positional[0];
1264
+ if (!slug) error("Missing slug argument", "Usage: arty read <slug>");
1265
+ await read(config, slug, !!flags.secure);
1266
+ break;
1267
+ }
1268
+ case "list": {
1269
+ let limit = 20;
1270
+ if (flags.limit) {
1271
+ limit = parseInt(flags.limit, 10);
1272
+ if (isNaN(limit) || limit < 1) error("--limit must be a positive integer");
1273
+ }
1274
+ let offset = 0;
1275
+ if (flags.offset) {
1276
+ offset = parseInt(flags.offset, 10);
1277
+ if (isNaN(offset) || offset < 0) error("--offset must be a non-negative integer");
1278
+ }
1279
+ await list(config, {
1280
+ category: flags.category,
1281
+ limit,
1282
+ offset,
1283
+ archived: !!flags.archived,
1284
+ parentId: flags["parent-id"],
1285
+ json
1286
+ });
1287
+ break;
1288
+ }
1289
+ case "get": {
1290
+ const slug = positional[0];
1291
+ if (!slug) error("Missing slug argument", "Usage: arty get <slug>");
1292
+ await get(config, slug);
1293
+ break;
1294
+ }
1295
+ case "search": {
1296
+ const query = positional.join(" ");
1297
+ if (!query) error("Missing search query", "Usage: arty search <query>");
1298
+ await search(config, query, json);
1299
+ break;
1300
+ }
1301
+ case "delete": {
1302
+ const slug = positional[0];
1303
+ if (!slug) error("Missing slug argument", "Usage: arty delete <slug>");
1304
+ await del(config, slug, yes, json);
1305
+ break;
1306
+ }
1307
+ case "open": {
1308
+ const slug = positional[0];
1309
+ if (!slug) error("Missing slug argument", "Usage: arty open <slug>");
1310
+ await open(config, slug);
1311
+ break;
1312
+ }
1313
+ case "secure": {
1314
+ const slug = positional[0];
1315
+ if (!slug) error("Missing slug argument", "Usage: arty secure <slug>");
1316
+ await secure(config, slug);
1317
+ break;
1318
+ }
1319
+ case "unsecure": {
1320
+ const slug = positional[0];
1321
+ if (!slug) error("Missing slug argument", "Usage: arty unsecure <slug>");
1322
+ await unsecure(config, slug);
1323
+ break;
1324
+ }
1325
+ case "update": {
1326
+ const slug = positional[0];
1327
+ if (!slug) error("Missing slug argument", "Usage: arty update <slug> [file] (reads from stdin if no file)");
1328
+ await update(config, slug, positional[1], json);
1329
+ break;
1330
+ }
1331
+ case "metadata": {
1332
+ const slug = positional[0];
1333
+ if (!slug) error("Missing slug argument", "Usage: arty metadata <slug> --category <name> --tags <t1,t2>");
1334
+ await metadata(config, slug, {
1335
+ category: flags.category,
1336
+ tags: flags.tags,
1337
+ title: flags.title,
1338
+ theme: flags.theme,
1339
+ visibility: flags.visibility,
1340
+ archived: flags.archived === true ? true : flags.archived === "false" ? false : void 0,
1341
+ json
1342
+ });
1343
+ break;
1344
+ }
1345
+ case "share": {
1346
+ switch (subcommand) {
1347
+ case "create": {
1348
+ const slug = positional[0];
1349
+ if (!slug) error("Missing slug argument", "Usage: arty share create <slug>");
1350
+ await shareCreate(config, slug, json);
1351
+ break;
1352
+ }
1353
+ case "list":
1354
+ await shareList(config, positional[0], json);
1355
+ break;
1356
+ case "revoke": {
1357
+ const hash = positional[0];
1358
+ if (!hash) error("Missing share hash", "Usage: arty share revoke <hash>");
1359
+ await shareRevoke(config, hash, yes, json);
1360
+ break;
1361
+ }
1362
+ default:
1363
+ error(`Unknown share subcommand: ${subcommand || "(none)"}`, "Usage: arty share <create|list|revoke>");
1364
+ }
1365
+ break;
1366
+ }
1367
+ case "cat": {
1368
+ if (subcommand !== "list") error(`Unknown cat subcommand: ${subcommand || "(none)"}`, "Usage: arty cat list");
1369
+ await catList(config, json);
1370
+ break;
1371
+ }
1372
+ case "tag": {
1373
+ if (subcommand !== "list") error(`Unknown tag subcommand: ${subcommand || "(none)"}`, "Usage: arty tag list");
1374
+ await tagList(config, json);
1375
+ break;
1376
+ }
1377
+ case "version": {
1378
+ switch (subcommand) {
1379
+ case "list": {
1380
+ const slug = positional[0];
1381
+ if (!slug) error("Missing slug argument", "Usage: arty version list <slug>");
1382
+ await versionList(config, slug, json);
1383
+ break;
1384
+ }
1385
+ case "restore": {
1386
+ const slug = positional[0];
1387
+ if (!slug) error("Missing slug argument", "Usage: arty version restore <slug>");
1388
+ await versionRestore(config, slug, yes, json);
1389
+ break;
1390
+ }
1391
+ default:
1392
+ error(`Unknown version subcommand: ${subcommand || "(none)"}`, "Usage: arty version <list|restore>");
1393
+ }
1394
+ break;
1395
+ }
1396
+ case "note": {
1397
+ switch (subcommand) {
1398
+ case "list": {
1399
+ const slug = positional[0];
1400
+ if (!slug) error("Missing slug argument", "Usage: arty note list <slug>");
1401
+ await noteList(config, slug, json);
1402
+ break;
1403
+ }
1404
+ case "add": {
1405
+ const slug = positional[0];
1406
+ if (!slug) error("Missing slug argument", 'Usage: arty note add <slug> --text "..."');
1407
+ await noteAdd(config, slug, flags.text, json);
1408
+ break;
1409
+ }
1410
+ case "search": {
1411
+ const query = positional.join(" ");
1412
+ if (!query) error("Missing search query", "Usage: arty note search <query>");
1413
+ await noteSearch(config, query, json);
1414
+ break;
1415
+ }
1416
+ default:
1417
+ error(`Unknown note subcommand: ${subcommand || "(none)"}`, "Usage: arty note <list|add|search>");
1418
+ }
1419
+ break;
1420
+ }
1421
+ default:
1422
+ error(`Unknown command: ${command}`, `Run \`arty --help\` for available commands`);
1423
+ }
1424
+ }
1425
+ main().catch((e) => {
1426
+ console.error(c.rose(e.message || String(e)));
1427
+ process.exit(1);
1428
+ });
1429
+ export {
1430
+ VERSION,
1431
+ parseArgs
1432
+ };