artifacty 0.10.8 → 0.11.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.
package/src/cli.js CHANGED
@@ -3,6 +3,8 @@ import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { generateToken } from "./lib/token.js";
6
+ import { parseSseFrames } from "./lib/sse.js";
7
+ import { groupArtifactIds, paginationJson } from "./lib/listing.js";
6
8
 
7
9
  const PACKAGE_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
8
10
 
@@ -31,17 +33,31 @@ async function main() {
31
33
  }
32
34
 
33
35
  const {
36
+ addComment,
37
+ addRelation,
34
38
  archiveArtifact,
35
39
  checkStoreIntegrity,
36
40
  createArtifact,
41
+ createSavedView,
37
42
  createStore,
43
+ deleteSavedView,
38
44
  getArtifact,
39
45
  importUsersFromCsv,
40
46
  listAuditEvents,
41
47
  listArtifactsPage,
48
+ listComments,
49
+ listRelations,
50
+ listSavedViews,
51
+ rebuildEmbeddingIndex,
42
52
  rebuildSearchIndex,
53
+ removeRelation,
54
+ resolveComment,
55
+ resolveSavedView,
43
56
  restoreArtifact,
44
- updateArtifact
57
+ setArtifactVisibility,
58
+ setReviewStatus,
59
+ updateArtifact,
60
+ VersionConflictError
45
61
  } = await import("./lib/storage.js");
46
62
  const { exportStore, importStore, defaultBackupPath } = await import("./lib/backup.js");
47
63
  const { convertAgentArtifact } = await import("./lib/converters.js");
@@ -50,6 +66,8 @@ async function main() {
50
66
  const { serviceCommand } = await import("./lib/service.js");
51
67
  const { backgroundStatus, startBackgroundServer, stopBackgroundServer } = await import("./lib/background.js");
52
68
  const { runDoctor } = await import("./lib/doctor.js");
69
+ const { getRetentionPolicy, normalizeRetentionPolicy, runRetention, setRetentionPolicy } = await import("./lib/retention.js");
70
+ const { parseArchiveAfterDaysPairs } = await import("./lib/retention-form.js");
53
71
  const { startServer } = await import("./server.js");
54
72
  const store = createStore({ home: options.home });
55
73
 
@@ -146,6 +164,7 @@ async function main() {
146
164
  tags: options.tag || [],
147
165
  metadata: options.metadata ? JSON.parse(options.metadata) : {},
148
166
  allowSecrets: options.allowSecrets,
167
+ visibility: options.visibility,
149
168
  audit: cliAuditContext()
150
169
  });
151
170
  printJson(await withUrls(store, artifact));
@@ -223,43 +242,146 @@ async function main() {
223
242
  throw new Error("update requires an artifact id");
224
243
  }
225
244
  const content = await readContent(options);
226
- const artifact = await updateArtifact(store, id, {
227
- title: options.title,
228
- content,
229
- format: options.format,
230
- artifactType: options.artifactType,
231
- schemaVersion: options.schemaVersion,
232
- sourceAgent: options.source || "cli",
233
- tags: options.tag || [],
234
- metadata: options.metadata ? JSON.parse(options.metadata) : {},
235
- allowSecrets: options.allowSecrets,
236
- audit: cliAuditContext()
237
- });
238
- printJson(await withUrls(store, artifact));
245
+ try {
246
+ const artifact = await updateArtifact(store, id, {
247
+ title: options.title,
248
+ content,
249
+ format: options.format,
250
+ artifactType: options.artifactType,
251
+ schemaVersion: options.schemaVersion,
252
+ sourceAgent: options.source || "cli",
253
+ tags: options.tag || [],
254
+ metadata: options.metadata ? JSON.parse(options.metadata) : {},
255
+ allowSecrets: options.allowSecrets,
256
+ expectedVersion: options.expectedVersion,
257
+ audit: cliAuditContext()
258
+ });
259
+ const finalArtifact = options.visibility
260
+ ? await setArtifactVisibility(store, id, options.visibility, { audit: cliAuditContext() })
261
+ : artifact;
262
+ printJson(await withUrls(store, finalArtifact));
263
+ } catch (error) {
264
+ if (error instanceof VersionConflictError) {
265
+ printJson({
266
+ error: error.message,
267
+ code: error.code,
268
+ latestVersion: error.latestVersion
269
+ });
270
+ process.exitCode = 1;
271
+ return;
272
+ }
273
+ throw error;
274
+ }
239
275
  return;
240
276
  }
241
277
 
242
278
  if (command === "list") {
243
- const page = await listArtifactsPage(store, {
279
+ let baseFilters = {};
280
+ if (options.view) {
281
+ const view = await resolveSavedView(store, options.view, {});
282
+ if (view) {
283
+ baseFilters = view.filters;
284
+ }
285
+ }
286
+ const explicit = {
244
287
  query: options.query,
245
288
  tag: Array.isArray(options.tag) ? options.tag[0] : options.tag,
246
289
  sourceAgent: options.source,
247
- includeArchived: options.includeArchived,
290
+ artifactType: options.type,
291
+ publisher: options.publisher,
292
+ createdAfter: options.createdAfter,
293
+ createdBefore: options.createdBefore,
294
+ reviewStatus: options.reviewStatus,
295
+ relatedTo: options.relatedTo,
296
+ relation: options.relation,
297
+ mode: options.mode,
298
+ includeArchived: options.includeArchived
299
+ };
300
+ const filters = { ...baseFilters };
301
+ for (const [key, value] of Object.entries(explicit)) {
302
+ if (value !== undefined) {
303
+ filters[key] = value;
304
+ }
305
+ }
306
+ const page = await listArtifactsPage(store, {
307
+ ...filters,
248
308
  limit: options.limit,
249
309
  offset: options.offset
250
310
  });
251
- printJson({
311
+ const result = {
252
312
  artifacts: page.artifacts,
253
313
  pagination: paginationJson(page),
254
314
  search: page.search
255
- });
315
+ };
316
+ if (options.groupBy) {
317
+ result.groups = groupArtifactIds(page.artifacts, options.groupBy);
318
+ }
319
+ printJson(result);
320
+ return;
321
+ }
322
+
323
+ if (command === "views") {
324
+ const subcommand = options._[0];
325
+
326
+ if (subcommand === "save") {
327
+ const name = options._[1];
328
+ if (!name) {
329
+ throw new Error("views save requires a name");
330
+ }
331
+ const filterSource = {
332
+ query: options.query,
333
+ tag: Array.isArray(options.tag) ? options.tag[0] : options.tag,
334
+ sourceAgent: options.source,
335
+ artifactType: options.type,
336
+ publisher: options.publisher,
337
+ createdAfter: options.createdAfter,
338
+ createdBefore: options.createdBefore,
339
+ reviewStatus: options.reviewStatus,
340
+ relatedTo: options.relatedTo,
341
+ relation: options.relation,
342
+ mode: options.mode
343
+ };
344
+ const filters = {};
345
+ for (const [key, value] of Object.entries(filterSource)) {
346
+ if (value !== undefined) {
347
+ filters[key] = value;
348
+ }
349
+ }
350
+ if (options.includeArchived) {
351
+ filters.includeArchived = true;
352
+ }
353
+ const view = await createSavedView(store, {
354
+ name,
355
+ filters,
356
+ shared: Boolean(options.shared),
357
+ audit: cliAuditContext()
358
+ });
359
+ printJson(view);
360
+ return;
361
+ }
362
+
363
+ if (subcommand === "delete") {
364
+ const id = options._[1];
365
+ if (!id) {
366
+ throw new Error("views delete requires a view id");
367
+ }
368
+ const view = await deleteSavedView(store, id, { audit: cliAuditContext() });
369
+ printJson(view);
370
+ return;
371
+ }
372
+
373
+ const views = await listSavedViews(store, {});
374
+ printJson({ views });
256
375
  return;
257
376
  }
258
377
 
259
378
  if ((command === "index" || command === "search") && options._[0] === "rebuild") {
260
379
  const result = await rebuildSearchIndex(store);
380
+ if (options.embeddings) {
381
+ result.embeddings = await rebuildEmbeddingIndex(store);
382
+ }
261
383
  printJson(result);
262
- if (!result.fts5) {
384
+ if (!result.fts5 || (options.embeddings && result.embeddings.configured && !result.embeddings.ok)) {
263
385
  process.exitCode = 1;
264
386
  }
265
387
  return;
@@ -274,6 +396,46 @@ async function main() {
274
396
  return;
275
397
  }
276
398
 
399
+ if (command === "retention") {
400
+ const subcommand = options._[0];
401
+
402
+ if (subcommand === "show") {
403
+ printJson(await getRetentionPolicy(store));
404
+ return;
405
+ }
406
+
407
+ if (subcommand === "set") {
408
+ const current = await getRetentionPolicy(store);
409
+ const byType = parseArchiveAfterDaysPairs(options.archiveAfterDaysFor || [], {
410
+ base: current.archiveAfterDays.byType,
411
+ onInvalid: (entry) => {
412
+ throw new Error(`--archive-after-days-for expects type=days, got: ${entry}`);
413
+ }
414
+ });
415
+ const next = normalizeRetentionPolicy({
416
+ archiveAfterDays: {
417
+ default: options.archiveAfterDays !== undefined ? options.archiveAfterDays : current.archiveAfterDays.default,
418
+ byType
419
+ },
420
+ purgeArchivedAfterDays: options.purgeArchivedAfterDays !== undefined ? options.purgeArchivedAfterDays : current.purgeArchivedAfterDays,
421
+ auditRetentionDays: options.auditRetentionDays !== undefined ? options.auditRetentionDays : current.auditRetentionDays,
422
+ eventRetentionRows: options.eventRetentionRows !== undefined ? options.eventRetentionRows : current.eventRetentionRows,
423
+ keepTags: options.keepTag !== undefined ? options.keepTag : current.keepTags
424
+ });
425
+ const saved = await setRetentionPolicy(store, next, { audit: cliAuditContext() });
426
+ printJson(saved);
427
+ return;
428
+ }
429
+
430
+ if (subcommand === "run") {
431
+ const result = await runRetention(store, { dryRun: Boolean(options.dryRun), allowPurge: options.allowPurge });
432
+ printJson(result);
433
+ return;
434
+ }
435
+
436
+ throw new Error("retention requires a subcommand: show, set, or run");
437
+ }
438
+
277
439
  if (command === "archive" || command === "restore") {
278
440
  const id = options._[0];
279
441
  if (!id) {
@@ -286,6 +448,17 @@ async function main() {
286
448
  return;
287
449
  }
288
450
 
451
+ if (command === "visibility") {
452
+ const id = options._[0];
453
+ const visibility = options._[1];
454
+ if (!id || !visibility) {
455
+ throw new Error("visibility requires an artifact id and a value: private or team");
456
+ }
457
+ const artifact = await setArtifactVisibility(store, id, visibility, { audit: cliAuditContext() });
458
+ printJson(await withUrls(store, artifact));
459
+ return;
460
+ }
461
+
289
462
  if (command === "audit") {
290
463
  const events = await listAuditEvents(store, {
291
464
  artifactId: options.artifact,
@@ -297,12 +470,15 @@ async function main() {
297
470
 
298
471
  if (command === "export" || command === "backup") {
299
472
  const file = command === "backup" ? options.file || defaultBackupPath(store) : requireOption(options, "file");
300
- printJson(await exportStore(store, file));
473
+ printJson(await exportStore(store, file, { scope: options.full ? "full" : "artifacts" }));
301
474
  return;
302
475
  }
303
476
 
304
477
  if (command === "import-store") {
305
- printJson(await importStore(store, requireOption(options, "file")));
478
+ printJson(await importStore(store, requireOption(options, "file"), {
479
+ confirm: options.confirm,
480
+ forceUsers: Boolean(options.forceUsers)
481
+ }));
306
482
  return;
307
483
  }
308
484
 
@@ -350,13 +526,304 @@ async function main() {
350
526
  process.stdout.write(artifact.content);
351
527
  return;
352
528
  }
529
+ if (options.relations) {
530
+ printJson(artifact.relations);
531
+ return;
532
+ }
353
533
  printJson(await withUrls(store, artifact));
354
534
  return;
355
535
  }
356
536
 
537
+ if (command === "link" || command === "unlink") {
538
+ const [fromId, relation, toId] = options._;
539
+ if (!fromId || !relation || !toId) {
540
+ throw new Error(`${command} requires: <from> <relation> <to>`);
541
+ }
542
+ const result = command === "link"
543
+ ? await addRelation(store, { fromId, toId, relation, audit: cliAuditContext() })
544
+ : await removeRelation(store, { fromId, toId, relation, audit: cliAuditContext() });
545
+ printJson(result);
546
+ return;
547
+ }
548
+
549
+ if (command === "relations") {
550
+ const id = options._[0];
551
+ if (!id) {
552
+ throw new Error("relations requires an artifact id");
553
+ }
554
+ printJson(await listRelations(store, id, {
555
+ direction: options.direction,
556
+ relation: options.relation
557
+ }));
558
+ return;
559
+ }
560
+
561
+ if (command === "comment") {
562
+ const id = options._[0];
563
+ if (!id) {
564
+ throw new Error("comment requires an artifact id");
565
+ }
566
+ if (!options.body) {
567
+ throw new Error("comment requires --body <text>");
568
+ }
569
+ const comment = await addComment(store, id, {
570
+ version: options.version,
571
+ body: options.body,
572
+ parentId: options.parent,
573
+ anchor: Number.isFinite(options.line) ? { line: options.line } : undefined,
574
+ sourceAgent: "cli",
575
+ audit: cliAuditContext()
576
+ });
577
+ printJson(comment);
578
+ return;
579
+ }
580
+
581
+ if (command === "comments") {
582
+ const id = options._[0];
583
+ if (!id) {
584
+ throw new Error("comments requires an artifact id");
585
+ }
586
+ printJson(await listComments(store, id, {
587
+ version: options.version,
588
+ status: options.status
589
+ }));
590
+ return;
591
+ }
592
+
593
+ if (command === "resolve-comment") {
594
+ const [id, commentId] = options._;
595
+ if (!id || !commentId) {
596
+ throw new Error("resolve-comment requires: <id> <commentId>");
597
+ }
598
+ printJson(await resolveComment(store, id, commentId, { audit: cliAuditContext() }));
599
+ return;
600
+ }
601
+
602
+ if (command === "review-status") {
603
+ const [id, status] = options._;
604
+ if (!id || !status) {
605
+ throw new Error("review-status requires: <id> none|pending|changes-requested|approved");
606
+ }
607
+ printJson(await withUrls(store, await setReviewStatus(store, id, status, { audit: cliAuditContext() })));
608
+ return;
609
+ }
610
+
611
+ if (command === "diff") {
612
+ const id = options._[0];
613
+ if (!id) {
614
+ throw new Error("diff requires an artifact id");
615
+ }
616
+ const { createStructuredDiff, createLineDiff, diffFormatFor, renderUnifiedDiffText, resolveDiffView } = await import("./lib/diff.js");
617
+ const latest = await getArtifact(store, id);
618
+ const defaultFrom = Math.max(1, latest.latestVersion - 1);
619
+ const fromNumber = Number.isFinite(options.from) ? options.from : defaultFrom;
620
+ const toNumber = Number.isFinite(options.to) ? options.to : latest.latestVersion;
621
+ const from = await getArtifact(store, id, { version: fromNumber });
622
+ const to = await getArtifact(store, id, { version: toNumber });
623
+ const diffFormat = diffFormatFor(latest.artifactType, to.version.format);
624
+ const useStructured = options.structured ||
625
+ resolveDiffView({ artifactType: latest.artifactType, format: to.version.format, requestedView: undefined }) === "structured";
626
+
627
+ if (options.json) {
628
+ if (useStructured) {
629
+ printJson({
630
+ id: latest.id,
631
+ from: from.version.version,
632
+ to: to.version.version,
633
+ view: "structured",
634
+ format: diffFormat,
635
+ structuredDiff: createStructuredDiff(from.content, to.content, { format: diffFormat })
636
+ });
637
+ return;
638
+ }
639
+ printJson({
640
+ id: latest.id,
641
+ from: from.version.version,
642
+ to: to.version.version,
643
+ view: "lines",
644
+ format: diffFormat,
645
+ diffRows: createLineDiff(from.content, to.content)
646
+ });
647
+ return;
648
+ }
649
+
650
+ if (useStructured) {
651
+ const structuredDiff = createStructuredDiff(from.content, to.content, { format: diffFormat });
652
+ process.stdout.write(`${renderUnifiedDiffText(structuredDiff)}\n`);
653
+ return;
654
+ }
655
+
656
+ const rows = createLineDiff(from.content, to.content);
657
+ for (const row of rows) {
658
+ if (row.type === "same") {
659
+ process.stdout.write(` ${row.text}\n`);
660
+ } else if (row.type === "added") {
661
+ process.stdout.write(`+ ${row.text}\n`);
662
+ } else {
663
+ process.stdout.write(`- ${row.text}\n`);
664
+ }
665
+ }
666
+ return;
667
+ }
668
+
669
+ if (command === "watch") {
670
+ return runWatchCommand(store, options);
671
+ }
672
+
357
673
  throw new Error(`Unknown command: ${command}`);
358
674
  }
359
675
 
676
+ // `artifacty watch` connects to a running server's SSE endpoint
677
+ // (/api/events; see src/server.js handleEventStream) and prints one line
678
+ // per matching event, optionally piping each event to --exec. Reconnects
679
+ // with Last-Event-ID on drop so a brief server restart does not lose
680
+ // events. --once resolves after the first match, making this usable as a
681
+ // shell-friendly, no-dependency alternative to the artifacty_wait MCP tool.
682
+ async function runWatchCommand(store, options) {
683
+ const { resolvePublicBaseUrl } = await import("./lib/server-state.js");
684
+ const baseUrl = await resolvePublicBaseUrl(store, { url: options.url });
685
+ const token = options.apiToken || process.env.ARTIFACTY_API_TOKEN || "";
686
+ const filter = {};
687
+ if (options.tag) filter.tag = Array.isArray(options.tag) ? options.tag[options.tag.length - 1] : options.tag;
688
+ if (options.artifact) filter.artifactId = options.artifact;
689
+ if (options.type) filter.type = options.type;
690
+
691
+ let lastEventId = null;
692
+ // SIGINT exits immediately (process.exit is synchronous-enough that the
693
+ // while loop below never observes another iteration), so there is no
694
+ // "stopped" state for the loop condition to check.
695
+ process.on("SIGINT", () => {
696
+ process.exit(0);
697
+ });
698
+
699
+ for (;;) {
700
+ const matched = await watchOnce({ baseUrl, token, filter, lastEventId, options });
701
+ if (matched.stop) {
702
+ return;
703
+ }
704
+ if (matched.lastEventId) {
705
+ lastEventId = matched.lastEventId;
706
+ }
707
+ await new Promise((resolve) => setTimeout(resolve, 1000));
708
+ }
709
+ }
710
+
711
+ async function watchOnce({ baseUrl, token, filter, lastEventId, options }) {
712
+ const url = new URL("/api/events", baseUrl);
713
+ for (const [key, value] of Object.entries(filter)) {
714
+ url.searchParams.set(key, value);
715
+ }
716
+ const headers = { accept: "text/event-stream" };
717
+ if (token) {
718
+ headers.authorization = `Bearer ${token}`;
719
+ }
720
+ if (lastEventId) {
721
+ headers["last-event-id"] = String(lastEventId);
722
+ }
723
+
724
+ let response;
725
+ try {
726
+ response = await fetch(url, { headers });
727
+ } catch (error) {
728
+ process.stderr.write(`artifacty watch: connection failed: ${error.message}\n`);
729
+ return {};
730
+ }
731
+ if (!response.ok || !response.body) {
732
+ process.stderr.write(`artifacty watch: server returned ${response.status}\n`);
733
+ return {};
734
+ }
735
+
736
+ const reader = response.body.getReader();
737
+ const decoder = new TextDecoder();
738
+ let buffer = "";
739
+ let seenLastEventId = lastEventId;
740
+ let readyPrinted = false;
741
+
742
+ try {
743
+ while (true) {
744
+ const { value, done } = await reader.read();
745
+ if (done) {
746
+ break;
747
+ }
748
+ buffer += decoder.decode(value, { stream: true });
749
+ // handleEventStream (src/server.js) writes ": connected\n\n" as the
750
+ // very first frame once the SSE subscription is actually live, before
751
+ // any replayed or new events. A caller scripting against `watch`
752
+ // (tests included) needs a deterministic point to know the stream is
753
+ // subscribed rather than sleeping and hoping; print the ready marker
754
+ // once we've seen it, before parsing frames as events (parseSseFrames
755
+ // filters comment-only frames like this one out anyway).
756
+ if (!readyPrinted && buffer.includes(": connected\n\n")) {
757
+ readyPrinted = true;
758
+ printWatchReady(options);
759
+ }
760
+ const { frames, rest } = parseSseFrames(buffer);
761
+ buffer = rest;
762
+ for (const parsed of frames) {
763
+ if (parsed.id) {
764
+ seenLastEventId = parsed.id;
765
+ }
766
+ const event = JSON.parse(parsed.data);
767
+ await emitWatchEvent(event, options);
768
+ if (options.once) {
769
+ await reader.cancel().catch(() => {});
770
+ return { stop: true };
771
+ }
772
+ }
773
+ }
774
+ } catch (error) {
775
+ process.stderr.write(`artifacty watch: stream error: ${error.message}\n`);
776
+ }
777
+ return { lastEventId: seenLastEventId };
778
+ }
779
+
780
+ function printWatchReady(options) {
781
+ if (options.json) {
782
+ process.stderr.write(`${JSON.stringify({ ready: true })}\n`);
783
+ } else {
784
+ process.stderr.write("# connected\n");
785
+ }
786
+ }
787
+
788
+ async function emitWatchEvent(event, options) {
789
+ if (options.json) {
790
+ process.stdout.write(`${JSON.stringify(event)}\n`);
791
+ } else {
792
+ process.stdout.write(`${event.type} ${event.artifactId || ""} v${event.version ?? ""}\n`);
793
+ }
794
+ if (options.exec) {
795
+ await execWatchCommand(options.exec, event);
796
+ }
797
+ }
798
+
799
+ async function execWatchCommand(command, event) {
800
+ const { spawn } = await import("node:child_process");
801
+ await new Promise((resolve) => {
802
+ const child = spawn(command, {
803
+ shell: true,
804
+ stdio: ["pipe", "inherit", "inherit"],
805
+ env: {
806
+ ...process.env,
807
+ ARTIFACTY_EVENT_TYPE: event.type || "",
808
+ ARTIFACTY_EVENT_ARTIFACT_ID: event.artifactId || "",
809
+ ARTIFACTY_EVENT_VERSION: event.version !== undefined && event.version !== null ? String(event.version) : ""
810
+ }
811
+ });
812
+ child.stdin.write(`${JSON.stringify(event)}\n`);
813
+ child.stdin.end();
814
+ child.on("error", (error) => {
815
+ process.stderr.write(`artifacty watch: --exec failed: ${error.message}\n`);
816
+ resolve();
817
+ });
818
+ child.on("exit", (code) => {
819
+ if (code !== 0) {
820
+ process.stderr.write(`artifacty watch: --exec exited with code ${code}\n`);
821
+ }
822
+ resolve();
823
+ });
824
+ });
825
+ }
826
+
360
827
  function parseArgs(args) {
361
828
  const options = {};
362
829
  const positional = [];
@@ -369,7 +836,7 @@ function parseArgs(args) {
369
836
  }
370
837
 
371
838
  const key = arg.slice(2);
372
- if (key === "raw" || key === "dry-run" || key === "trust" || key === "include-archived" || key === "allow-secrets" || key === "generate-token" || key === "detach" || key === "foreground" || key === "force" || key === "skip-mcp" || key === "mcp-http" || key === "no-password-reset") {
839
+ if (key === "raw" || key === "dry-run" || key === "trust" || key === "include-archived" || key === "allow-secrets" || key === "generate-token" || key === "detach" || key === "foreground" || key === "force" || key === "skip-mcp" || key === "mcp-http" || key === "no-password-reset" || key === "relations" || key === "structured" || key === "json" || key === "once" || key === "full" || key === "force-users" || key === "allow-purge" || key === "shared" || key === "embeddings") {
373
840
  options[toCamelCase(key)] = true;
374
841
  continue;
375
842
  }
@@ -381,7 +848,11 @@ function parseArgs(args) {
381
848
 
382
849
  if (key === "tag") {
383
850
  options.tag = [...(options.tag || []), value];
384
- } else if (key === "port" || key === "limit" || key === "offset" || key === "version" || key === "schema-version" || key === "timeout" || key === "bytes") {
851
+ } else if (key === "keep-tag") {
852
+ options.keepTag = [...(options.keepTag || []), value];
853
+ } else if (key === "archive-after-days-for") {
854
+ options.archiveAfterDaysFor = [...(options.archiveAfterDaysFor || []), value];
855
+ } else if (key === "port" || key === "limit" || key === "offset" || key === "version" || key === "schema-version" || key === "timeout" || key === "bytes" || key === "expected-version" || key === "from" || key === "to" || key === "archive-after-days" || key === "purge-archived-after-days" || key === "audit-retention-days" || key === "event-retention-rows" || key === "line") {
385
856
  options[toCamelCase(key)] = Number(value);
386
857
  } else {
387
858
  options[toCamelCase(key)] = value;
@@ -426,17 +897,6 @@ async function withUrls(store, artifact) {
426
897
  };
427
898
  }
428
899
 
429
- function paginationJson(page) {
430
- return {
431
- total: page.total,
432
- limit: page.limit,
433
- offset: page.offset,
434
- hasMore: page.hasMore,
435
- nextOffset: page.nextOffset,
436
- previousOffset: page.previousOffset
437
- };
438
- }
439
-
440
900
  function requireOption(options, name) {
441
901
  if (!options[name]) {
442
902
  throw new Error(`Missing required option --${name}`);
@@ -465,32 +925,79 @@ Usage:
465
925
  artifacty status [--home ~/.artifacty]
466
926
  artifacty stop [--home ~/.artifacty] [--timeout 30000] [--force]
467
927
  artifacty doctor [--home ~/.artifacty] [--skip-mcp] [--timeout 5000]
468
- artifacty publish --title <title> (--file <path> | --content <text>) [--format html|markdown|text|json|code|svg|mermaid|react] [--source agent] [--tag tag]
469
- artifacty import --agent claude|codex|gemini|copilot|cursor|auto (--file <path> | --content <text>) [--title <title>] [--format html|markdown|text|json|code|svg|mermaid|react] [--tag tag]
928
+ artifacty publish --title <title> (--file <path> | --content <text>) [--format html|markdown|text|json|code|svg|mermaid|react|notebook] [--source agent] [--tag tag] [--visibility private|team]
929
+ artifacty import --agent claude|codex|gemini|copilot|cursor|auto (--file <path> | --content <text>) [--title <title>] [--format html|markdown|text|json|code|svg|mermaid|react|notebook] [--tag tag]
470
930
  artifacty install claude|codex|gemini|copilot|cursor|all [--dry-run] [--config <path>] [--server-path <path>] [--url http://127.0.0.1:8787] [--mcp-url http://127.0.0.1:8787/mcp] [--api-token token] [--transport local|bridge] [--timeout 30000]
471
931
  artifacty check [--server-path <path>] [--timeout 5000]
472
- artifacty update <id> (--file <path> | --content <text>) [--title <title>] [--format html|markdown|text|json|code|svg|mermaid|react]
932
+ artifacty update <id> (--file <path> | --content <text>) [--title <title>] [--format html|markdown|text|json|code|svg|mermaid|react|notebook] [--expected-version N] [--visibility private|team]
473
933
  artifacty archive <id>
474
934
  artifacty restore <id>
935
+ artifacty visibility <id> private|team
475
936
  artifacty audit [--artifact <id>] [--limit 100]
476
- artifacty index rebuild
937
+ artifacty index rebuild [--embeddings]
477
938
  artifacty integrity
478
- artifacty export --file <path>
479
- artifacty backup [--file <path>]
480
- artifacty import-store --file <path>
939
+ artifacty retention show
940
+ artifacty retention set [--archive-after-days N] [--archive-after-days-for type=N] [--purge-archived-after-days N] [--audit-retention-days N] [--event-retention-rows N] [--keep-tag tag]
941
+ artifacty retention run [--dry-run] [--allow-purge]
942
+ artifacty export --file <path> [--full]
943
+ artifacty backup [--file <path>] [--full]
944
+ artifacty import-store --file <path> [--confirm replace-all] [--force-users]
481
945
  artifacty users import --file <path.csv> [--no-password-reset]
482
946
  artifacty service plist|unit|task|install|uninstall [--platform macos|linux|windows] [--dry-run] [--path <path>] [--mcp-http]
483
- artifacty list [--query text] [--tag tag] [--source agent] [--limit 50] [--offset 0] [--include-archived]
484
- artifacty show <id> [--version n] [--raw]
947
+ artifacty list [--query text] [--tag tag] [--source agent] [--type <artifactType>] [--publisher <id>] [--created-after <iso-date>] [--created-before <iso-date>] [--review-status none|pending|changes-requested|approved] [--related-to <id>] [--relation derived-from|supersedes|reviews|references|part-of] [--mode keyword|semantic|hybrid] [--view <name|id>] [--group-by artifactType|sourceAgent|day] [--limit 50] [--offset 0] [--include-archived]
948
+ artifacty views
949
+ artifacty views save <name> [--shared] [--query text] [--tag tag] [--source agent] [--type <artifactType>] [--publisher <id>] [--created-after <iso-date>] [--created-before <iso-date>] [--review-status none|pending|changes-requested|approved] [--related-to <id>] [--relation derived-from|supersedes|reviews|references|part-of] [--include-archived]
950
+ artifacty views delete <id>
951
+ artifacty show <id> [--version n] [--raw] [--relations]
952
+ artifacty link <from> <relation> <to>
953
+ artifacty unlink <from> <relation> <to>
954
+ artifacty relations <id> [--direction out|in|both] [--relation derived-from|supersedes|reviews|references|part-of]
955
+ artifacty comment <id> --body "..." [--version n] [--line n] [--parent commentId]
956
+ artifacty comments <id> [--version n] [--status open|resolved]
957
+ artifacty resolve-comment <id> <commentId>
958
+ artifacty review-status <id> none|pending|changes-requested|approved
959
+ artifacty diff <id> [--from N] [--to M] [--structured] [--json]
960
+ artifacty watch [--tag tag] [--artifact <id>] [--type artifact.updated] [--json] [--once] [--exec "<cmd>"]
485
961
 
486
962
  Environment:
487
963
  ARTIFACTY_HOME Storage directory. Defaults to ~/.artifacty
488
964
  ARTIFACTY_URL Public URL override. Otherwise CLI/MCP read the last running server URL
965
+ ARTIFACTY_HOST Bind host for the HTTP server. Defaults to 127.0.0.1
966
+ ARTIFACTY_PORT Bind port for the HTTP server. Defaults to 8787
489
967
  ARTIFACTY_MCP_URL Central MCP HTTP endpoint used by bridge mode
490
968
  ARTIFACTY_MCP_MODE local or bridge. bridge forwards stdio MCP to ARTIFACTY_MCP_URL
969
+ ARTIFACTY_MCP_HTTP Set true to also expose MCP over HTTP on the running server
970
+ ARTIFACTY_MCP_TIMEOUT_MS Bridge-mode MCP HTTP request timeout in ms. Defaults to 30000
491
971
  ARTIFACTY_API_TOKEN Required token for HTTP API and LAN mode
972
+ ARTIFACTY_LOCALE Default UI locale (e.g. en, ko) when a request does not specify one
973
+ ARTIFACTY_EMBEDDINGS_URL openai-compatible embeddings endpoint base URL, enables semantic/hybrid search
974
+ ARTIFACTY_EMBEDDINGS_MODEL Embeddings model name (defaults per provider)
975
+ ARTIFACTY_EMBEDDINGS_API_KEY API key for the openai-compatible provider, never logged or stored
976
+ ARTIFACTY_EMBEDDINGS_COMMAND Local command that reads JSON lines on stdin and writes vectors on stdout
977
+ ARTIFACTY_EMBEDDINGS_MAX_CHARS Max content characters embedded per artifact (default 8000)
978
+ ARTIFACTY_EMBEDDINGS_TIMEOUT_MS Timeout in ms for the local embeddings command. Defaults to 30000
979
+ ARTIFACTY_EMBEDDINGS_MAX_CANDIDATES Max embedding rows scored per semantic/hybrid search. Defaults to 20000
980
+ ARTIFACTY_EMBEDDINGS_SYNC Set true to index embeddings synchronously (tests only)
492
981
  ARTIFACTY_SHARE_MODE Use lan or team before binding outside localhost
982
+ ARTIFACTY_TEAM_WRITE Set owner to restrict writes on team-visibility artifacts to the owner or an admin
493
983
  ARTIFACTY_ALLOW_SECRETS Set true only to intentionally store detected secrets
984
+ ARTIFACTY_ENABLE_REACT_RENDERER Set true to execute React artifacts in a sandboxed frame instead of source-only
985
+ ARTIFACTY_RETENTION_INTERVAL_MS Background retention sweep interval in ms. Defaults to 3600000 (1 hour)
986
+ ARTIFACTY_RETENTION_ALLOW_PURGE Set true to allow retention sweeps to hard-delete archived artifacts
987
+ ARTIFACTY_WEBHOOK_TIMEOUT_MS Per-attempt webhook delivery timeout in ms. Defaults to 10000
988
+ ARTIFACTY_WEBHOOK_ALLOW_PRIVATE Set true to allow webhook URLs that resolve to private/loopback addresses
989
+ ARTIFACTY_EVENT_POLL_MS SSE poll interval in ms for new events. Defaults to 1000
990
+ ARTIFACTY_EVENT_HISTORY Max in-memory event history retained for replay. Defaults to 10000
991
+ ARTIFACTY_SSE_MAX_CLIENTS Max concurrent SSE connections. Defaults to 64
992
+ ARTIFACTY_MAX_WAITS Max concurrent artifacty_wait MCP long-polls. Defaults to 64
993
+ ARTIFACTY_MAX_DIFF_ENTRIES Max diff entries computed per artifacty diff. Defaults to 5000
994
+ ARTIFACTY_MAX_INLINE_DIAGRAMS Max inline Mermaid diagrams rendered per Markdown artifact. Defaults to 20
995
+ ARTIFACTY_MAX_COMMENTS_PER_ARTIFACT Max comments retained per artifact. Defaults to 2000
996
+ ARTIFACTY_MAX_SAVED_VIEWS_PER_USER Max saved views retained per user. Defaults to 100
997
+ ARTIFACTY_RATE_LIMIT always or off. Overrides the default (disabled on loopback, enabled otherwise)
998
+ ARTIFACTY_RATE_WRITE_PER_MIN Rate limit for mutating routes. Defaults to 120/min
999
+ ARTIFACTY_RATE_AUTH_PER_MIN Rate limit for /login per address. Defaults to 10/min
1000
+ ARTIFACTY_RATE_SEARCH_PER_MIN Rate limit for GET /api/artifacts?q= searches. Defaults to 300/min
494
1001
  `);
495
1002
  }
496
1003