artshelf 0.3.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.
@@ -0,0 +1,1149 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import { appendPreparedRecord, createCleanupPlan, createTrashPurgePlan, dueEntries, executeCleanupPlan, executeTrashPurgePlan, filterRecordsByStatus, findRecords, getRecord, listTrashedRecords, normalizeLedgerPath, prepareRecord, previewCleanupPlan, readLedger, resolveRecord, validateLedger } from "./ledger.js";
4
+ import { listRegisteredLedgers, normalizeRegistryPath, registerLedger } from "./registry.js";
5
+ const VERSION = "0.3.0";
6
+ const BOOLEAN_FLAGS = new Set(["all", "json", "manual-review", "dry-run", "execute", "help", "version", "plain"]);
7
+ const VALUE_FLAGS = new Set([
8
+ "cleanup",
9
+ "kind",
10
+ "label",
11
+ "ledger",
12
+ "name",
13
+ "owner",
14
+ "path",
15
+ "plan-id",
16
+ "older-than",
17
+ "registry",
18
+ "reason",
19
+ "retain-until",
20
+ "scope",
21
+ "status",
22
+ "ttl"
23
+ ]);
24
+ function main(argv) {
25
+ try {
26
+ const parsed = parseArgs(argv);
27
+ if (parsed.command === "--version" || parsed.command === "-v" || boolFlag(parsed, "version")) {
28
+ process.stdout.write(`artshelf ${VERSION}\n`);
29
+ return 0;
30
+ }
31
+ if (parsed.command === "help" || parsed.command === "--help" || parsed.command === "-h" || boolFlag(parsed, "help")) {
32
+ printHelp(parsed.command === "help" ? parsed.positionals[0] : parsed.command);
33
+ return 0;
34
+ }
35
+ switch (parsed.command) {
36
+ case "put":
37
+ return handlePut(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
38
+ case "ledgers":
39
+ return handleLedgers(parsed, boolFlag(parsed, "json"));
40
+ case "list":
41
+ return handleList(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
42
+ case "find":
43
+ return handleFind(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
44
+ case "get":
45
+ return handleGet(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
46
+ case "due":
47
+ return handleDue(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
48
+ case "validate":
49
+ return handleValidate(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
50
+ case "cleanup":
51
+ return handleCleanup(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
52
+ case "trash":
53
+ return handleTrash(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
54
+ case "review":
55
+ return handleReview(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
56
+ case "doctor":
57
+ return handleDoctor(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
58
+ case "status":
59
+ return handleStatus(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
60
+ case "resolve":
61
+ return handleResolve(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
62
+ case undefined:
63
+ printHelp();
64
+ return 0;
65
+ default:
66
+ throw new Error(`Unknown command: ${parsed.command}`);
67
+ }
68
+ }
69
+ catch (error) {
70
+ process.stderr.write(`artshelf: ${error.message}\nRun \`artshelf help\` for usage.\n`);
71
+ return 1;
72
+ }
73
+ }
74
+ function handlePut(parsed, ledgerPath, json) {
75
+ const path = parsed.positionals[0];
76
+ if (!path)
77
+ throw new Error("put requires <path>");
78
+ const record = prepareRecord({
79
+ path,
80
+ reason: requiredStringFlag(parsed, "reason"),
81
+ ttl: stringFlag(parsed, "ttl"),
82
+ retainUntil: stringFlag(parsed, "retain-until"),
83
+ manualReview: boolFlag(parsed, "manual-review"),
84
+ kind: stringFlag(parsed, "kind"),
85
+ cleanup: stringFlag(parsed, "cleanup"),
86
+ owner: stringFlag(parsed, "owner"),
87
+ labels: arrayFlag(parsed, "label")
88
+ });
89
+ const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
90
+ appendPreparedRecord(ledgerPath, record);
91
+ let ledger;
92
+ let registryError;
93
+ try {
94
+ ledger = registerLedger({ ledgerPath, registryPath });
95
+ }
96
+ catch (error) {
97
+ registryError = error.message;
98
+ }
99
+ if (json)
100
+ return printJson({ ok: true, record, ledgerPath, registryPath, ...(ledger ? { ledger } : {}), ...(registryError ? { registryError } : {}) });
101
+ process.stdout.write(`recorded ${record.id}\npath: ${record.path}\nretains until: ${record.retainUntil ?? "manual review"}\nledger: ${ledgerPath}\n`);
102
+ if (registryError)
103
+ process.stdout.write(`registry warning: ${registryError}\n`);
104
+ return 0;
105
+ }
106
+ function handleLedgers(parsed, json) {
107
+ const action = parsed.positionals[0] ?? "list";
108
+ const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
109
+ if (action === "add") {
110
+ const ledgerPath = normalizeLedgerPath(requiredStringFlag(parsed, "ledger"));
111
+ if (!existsSync(ledgerPath))
112
+ throw new Error(`Ledger does not exist: ${ledgerPath}`);
113
+ const entry = registerLedger({
114
+ ledgerPath,
115
+ name: stringFlag(parsed, "name"),
116
+ scope: stringFlag(parsed, "scope"),
117
+ registryPath
118
+ });
119
+ if (json)
120
+ return printJson({ ok: true, registryPath, ledger: entry });
121
+ process.stdout.write(`registered ${entry.name}\nledger: ${entry.path}\nregistry: ${registryPath}\n`);
122
+ return 0;
123
+ }
124
+ if (action === "list") {
125
+ if (boolFlag(parsed, "plain")) {
126
+ const ledgers = listRegisteredLedgers(registryPath);
127
+ if (json)
128
+ return printJson({ ok: true, registryPath, ledgers });
129
+ if (ledgers.length === 0) {
130
+ process.stdout.write(`no registered Artshelf ledgers\nregistry: ${registryPath}\n`);
131
+ return 0;
132
+ }
133
+ for (const ledger of ledgers)
134
+ process.stdout.write(`${ledger.name} ${ledger.scope} ${ledger.path}\n`);
135
+ process.stdout.write(`registry: ${registryPath}\n`);
136
+ return 0;
137
+ }
138
+ const report = buildLedgersReport(registryPath);
139
+ if (json) {
140
+ printJson(report);
141
+ return report.ok ? 0 : 1;
142
+ }
143
+ printLedgersList(report);
144
+ return report.ok ? 0 : 1;
145
+ }
146
+ throw new Error(`Unknown ledgers action: ${action}`);
147
+ }
148
+ function buildLedgersReport(registryPath) {
149
+ let registryOk = true;
150
+ let registryError = null;
151
+ let entries = [];
152
+ try {
153
+ entries = listRegisteredLedgers(registryPath);
154
+ }
155
+ catch (error) {
156
+ registryOk = false;
157
+ registryError = error.message;
158
+ }
159
+ const ledgers = entries.map((entry) => {
160
+ const result = validateRegisteredLedger(entry);
161
+ const status = result.ok ? "ok" : existsSync(entry.path) ? "invalid" : "missing";
162
+ return {
163
+ ...entry,
164
+ status,
165
+ ok: result.ok,
166
+ entries: result.entries,
167
+ errors: result.errors,
168
+ warnings: result.warnings
169
+ };
170
+ });
171
+ const summary = {
172
+ ledgers: ledgers.length,
173
+ ok: ledgers.filter((ledger) => ledger.status === "ok").length,
174
+ stale: ledgers.filter((ledger) => ledger.status === "missing").length,
175
+ invalid: ledgers.filter((ledger) => ledger.status === "invalid").length,
176
+ warnings: ledgers.reduce((count, ledger) => count + ledger.warnings.length, 0)
177
+ };
178
+ return {
179
+ ok: registryOk && summary.stale === 0 && summary.invalid === 0,
180
+ registryPath,
181
+ registryExists: existsSync(registryPath),
182
+ registryOk,
183
+ registryError,
184
+ ledgers,
185
+ summary
186
+ };
187
+ }
188
+ function printLedgersList(report) {
189
+ process.stdout.write(`artshelf ledgers: ${report.ok ? "ok" : "needs attention"}\n`);
190
+ process.stdout.write(`registry: ${report.registryPath}${report.registryExists ? "" : " (absent)"} — ${report.summary.ledgers} ledgers (${report.summary.ok} ok, ${report.summary.stale} stale, ${report.summary.invalid} invalid)\n`);
191
+ if (report.registryError)
192
+ process.stdout.write(`registry error: ${report.registryError}\n`);
193
+ if (report.ledgers.length === 0) {
194
+ process.stdout.write("no registered Artshelf ledgers\n");
195
+ return;
196
+ }
197
+ for (const ledger of report.ledgers) {
198
+ if (ledger.status === "ok") {
199
+ process.stdout.write(`[${ledger.name}] ok ${ledger.scope}: ${ledger.entries} entries, ${ledger.warnings.length} warnings — ${ledger.path}\n`);
200
+ }
201
+ else {
202
+ process.stdout.write(`[${ledger.name}] ${ledger.status} ${ledger.scope}: ${ledger.errors.join("; ")} — ${ledger.path}\n`);
203
+ }
204
+ }
205
+ }
206
+ function handleList(parsed, ledgerPath, json) {
207
+ if (boolFlag(parsed, "all")) {
208
+ const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
209
+ const status = stringFlag(parsed, "status");
210
+ const validation = validateRegisteredLedgersOrThrow(registryPath);
211
+ if (!validation.ok)
212
+ return printRegisteredLedgerValidation(registryPath, validation.results, json);
213
+ const results = validation.results.map(({ ledger }) => ({
214
+ ledger,
215
+ entries: filterRecordsByStatus(readLedger(ledger.path), status)
216
+ }));
217
+ if (json)
218
+ return printJson({ ok: true, registryPath, ...(status ? { status } : {}), ledgers: results });
219
+ printLedgerEntries(results, status);
220
+ process.stdout.write(`registry: ${registryPath}\n`);
221
+ return 0;
222
+ }
223
+ const status = stringFlag(parsed, "status");
224
+ const records = filterRecordsByStatus(readLedger(ledgerPath), status);
225
+ if (json)
226
+ return printJson({ ok: true, ledgerPath, ...(status ? { status } : {}), entries: records });
227
+ if (records.length === 0) {
228
+ process.stdout.write(`no artshelf entries${status ? ` with status ${status}` : ""}\nledger: ${ledgerPath}\n`);
229
+ return 0;
230
+ }
231
+ for (const record of records) {
232
+ process.stdout.write(`${record.id} ${record.kind} ${record.status} ${record.cleanup} ${record.path} :: ${record.reason}\n`);
233
+ }
234
+ process.stdout.write(`ledger: ${ledgerPath}\n`);
235
+ return 0;
236
+ }
237
+ function handleFind(parsed, ledgerPath, json) {
238
+ if (boolFlag(parsed, "all")) {
239
+ const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
240
+ const validation = validateRegisteredLedgersOrThrow(registryPath);
241
+ if (!validation.ok)
242
+ return printRegisteredLedgerValidation(registryPath, validation.results, json);
243
+ const results = validation.results.map(({ ledger }) => ({
244
+ ledger,
245
+ entries: findRecords(readLedger(ledger.path), {
246
+ path: stringFlag(parsed, "path"),
247
+ owner: stringFlag(parsed, "owner"),
248
+ labels: arrayFlag(parsed, "label"),
249
+ status: stringFlag(parsed, "status")
250
+ })
251
+ }));
252
+ if (json)
253
+ return printJson({ ok: true, registryPath, ledgers: results });
254
+ printLedgerEntries(results);
255
+ process.stdout.write(`registry: ${registryPath}\n`);
256
+ return 0;
257
+ }
258
+ const records = findRecords(readLedger(ledgerPath), {
259
+ path: stringFlag(parsed, "path"),
260
+ owner: stringFlag(parsed, "owner"),
261
+ labels: arrayFlag(parsed, "label"),
262
+ status: stringFlag(parsed, "status")
263
+ });
264
+ if (json)
265
+ return printJson({ ok: true, ledgerPath, entries: records });
266
+ if (records.length === 0) {
267
+ process.stdout.write(`no matching artshelf entries\nledger: ${ledgerPath}\n`);
268
+ return 0;
269
+ }
270
+ for (const record of records) {
271
+ process.stdout.write(`${record.id} ${record.kind} ${record.status} ${record.cleanup} ${record.path} :: ${record.reason}\n`);
272
+ }
273
+ process.stdout.write(`ledger: ${ledgerPath}\n`);
274
+ return 0;
275
+ }
276
+ function handleGet(parsed, ledgerPath, json) {
277
+ const id = parsed.positionals[0];
278
+ if (!id)
279
+ throw new Error("get requires <id>");
280
+ if (boolFlag(parsed, "all")) {
281
+ const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
282
+ const validation = validateRegisteredLedgersOrThrow(registryPath);
283
+ if (!validation.ok)
284
+ return printRegisteredLedgerValidation(registryPath, validation.results, json);
285
+ for (const { ledger } of validation.results) {
286
+ const record = readLedger(ledger.path).find((entry) => entry.id === id);
287
+ if (record) {
288
+ if (json)
289
+ return printJson({ ok: true, registryPath, ledger, record });
290
+ process.stdout.write(`${record.id} ${record.kind} ${record.status} ${record.cleanup} ${record.path}\nreason: ${record.reason}\nledger: ${ledger.path}\nregistry: ${registryPath}\n`);
291
+ return 0;
292
+ }
293
+ }
294
+ throw new Error(`Artshelf record not found: ${id}`);
295
+ }
296
+ const record = getRecord(readLedger(ledgerPath), id);
297
+ if (json)
298
+ return printJson({ ok: true, ledgerPath, record });
299
+ process.stdout.write(`${record.id} ${record.kind} ${record.status} ${record.cleanup} ${record.path}\nreason: ${record.reason}\nledger: ${ledgerPath}\n`);
300
+ return 0;
301
+ }
302
+ function handleResolve(parsed, ledgerPath, json) {
303
+ const id = parsed.positionals[0];
304
+ if (!id)
305
+ throw new Error("resolve requires <id>");
306
+ const record = resolveRecord(ledgerPath, {
307
+ id,
308
+ status: requiredStringFlag(parsed, "status"),
309
+ reason: requiredStringFlag(parsed, "reason")
310
+ });
311
+ if (json)
312
+ return printJson({ ok: true, record, ledgerPath });
313
+ process.stdout.write(`resolved ${record.id}\nstatus: ${record.status}\nledger: ${ledgerPath}\n`);
314
+ return 0;
315
+ }
316
+ function handleDue(parsed, ledgerPath, json) {
317
+ if (boolFlag(parsed, "all")) {
318
+ const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
319
+ const validation = validateRegisteredLedgersOrThrow(registryPath);
320
+ if (!validation.ok)
321
+ return printRegisteredLedgerValidation(registryPath, validation.results, json);
322
+ const results = validation.results.map(({ ledger }) => ({ ledger, entries: dueEntries(readLedger(ledger.path)) }));
323
+ if (json)
324
+ return printJson({ ok: true, registryPath, ledgers: results });
325
+ printDueEntries(results);
326
+ process.stdout.write(`registry: ${registryPath}\n`);
327
+ return 0;
328
+ }
329
+ const entries = dueEntries(readLedger(ledgerPath));
330
+ const visible = entries.filter((entry) => entry.dueStatus !== "kept");
331
+ if (json)
332
+ return printJson({ ok: true, ledgerPath, entries });
333
+ if (visible.length === 0) {
334
+ process.stdout.write(`nothing due\nledger: ${ledgerPath}\n`);
335
+ return 0;
336
+ }
337
+ for (const entry of visible) {
338
+ process.stdout.write(`${entry.dueStatus} ${entry.id} ${entry.cleanup} ${entry.path} :: ${entry.reason}\n`);
339
+ }
340
+ process.stdout.write(`ledger: ${ledgerPath}\n`);
341
+ return 0;
342
+ }
343
+ function handleValidate(parsed, ledgerPath, json) {
344
+ if (boolFlag(parsed, "all")) {
345
+ const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
346
+ const results = registeredLedgersOrThrow(registryPath).map((ledger) => ({ ledger, result: validateRegisteredLedger(ledger) }));
347
+ const ok = results.every((entry) => entry.result.ok);
348
+ if (json) {
349
+ printJson({ ok, registryPath, ledgers: results });
350
+ return ok ? 0 : 1;
351
+ }
352
+ for (const entry of results) {
353
+ process.stdout.write(`${entry.result.ok ? "ok" : "invalid"} ${entry.ledger.name}: ${entry.result.entries} entries, ${entry.result.errors.length} errors, ${entry.result.warnings.length} warnings\nledger: ${entry.ledger.path}\n`);
354
+ for (const error of entry.result.errors)
355
+ process.stdout.write(`error: ${error}\n`);
356
+ for (const warning of entry.result.warnings)
357
+ process.stdout.write(`warning: ${warning}\n`);
358
+ }
359
+ process.stdout.write(`registry: ${registryPath}\n`);
360
+ return ok ? 0 : 1;
361
+ }
362
+ const result = validateLedger(ledgerPath);
363
+ if (json)
364
+ return printJson({ ledgerPath, ...result });
365
+ process.stdout.write(`${result.ok ? "ok" : "invalid"}: ${result.entries} entries, ${result.errors.length} errors, ${result.warnings.length} warnings\n`);
366
+ for (const error of result.errors)
367
+ process.stdout.write(`error: ${error}\n`);
368
+ for (const warning of result.warnings)
369
+ process.stdout.write(`warning: ${warning}\n`);
370
+ process.stdout.write(`ledger: ${ledgerPath}\n`);
371
+ return result.ok ? 0 : 1;
372
+ }
373
+ function handleCleanup(parsed, ledgerPath, json) {
374
+ const dryRun = boolFlag(parsed, "dry-run");
375
+ const execute = boolFlag(parsed, "execute");
376
+ if (dryRun && execute)
377
+ throw new Error("cleanup accepts either --dry-run or --execute, not both");
378
+ if (boolFlag(parsed, "all") && execute)
379
+ throw new Error("cleanup --all is dry-run only; execute requires an explicit --ledger and reviewed --plan-id");
380
+ if (dryRun) {
381
+ if (boolFlag(parsed, "all")) {
382
+ const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
383
+ const ledgers = registeredLedgersOrThrow(registryPath);
384
+ const validations = ledgers.map((ledger) => ({ ledger, result: validateRegisteredLedger(ledger) }));
385
+ const ok = validations.every((entry) => entry.result.ok);
386
+ if (!ok) {
387
+ if (json) {
388
+ printJson({ ok, registryPath, ledgers: validations });
389
+ return 1;
390
+ }
391
+ for (const entry of validations.filter((item) => !item.result.ok)) {
392
+ process.stdout.write(`invalid ${entry.ledger.name}: ${entry.result.errors.join("; ")}\nledger: ${entry.ledger.path}\n`);
393
+ }
394
+ process.stdout.write(`registry: ${registryPath}\n`);
395
+ return 1;
396
+ }
397
+ const plans = ledgers.map((ledger) => ({ ledger, plan: createCleanupPlan(ledger.path) }));
398
+ if (json)
399
+ return printJson({ ok: true, registryPath, plans });
400
+ printPlans(plans);
401
+ process.stdout.write(`registry: ${registryPath}\n`);
402
+ return 0;
403
+ }
404
+ const plan = createCleanupPlan(ledgerPath);
405
+ if (json)
406
+ return printJson({ ok: true, plan });
407
+ printPlan(plan, ledgerPath);
408
+ return 0;
409
+ }
410
+ if (execute) {
411
+ const planId = requiredStringFlag(parsed, "plan-id");
412
+ const receipt = executeCleanupPlan(ledgerPath, planId);
413
+ if (json)
414
+ return printJson({ ok: true, receipt });
415
+ process.stdout.write(`receipt ${receipt.planId}: ${receipt.results.length} results\nreceipt: ${receipt.receiptPath}\nledger: ${ledgerPath}\n`);
416
+ return 0;
417
+ }
418
+ throw new Error("cleanup requires --dry-run or --execute");
419
+ }
420
+ function handleTrash(parsed, ledgerPath, json) {
421
+ const action = parsed.positionals[0];
422
+ if (!action)
423
+ throw new Error("trash requires a subcommand: list or purge");
424
+ if (action === "list") {
425
+ return handleTrashList(parsed, ledgerPath, json);
426
+ }
427
+ if (action === "purge") {
428
+ return handleTrashPurge(parsed, ledgerPath, json);
429
+ }
430
+ if (action === "help") {
431
+ printHelp("trash");
432
+ return 0;
433
+ }
434
+ throw new Error(`Unknown trash subcommand: ${action}`);
435
+ }
436
+ function handleTrashList(parsed, ledgerPath, json) {
437
+ if (boolFlag(parsed, "all")) {
438
+ const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
439
+ const validation = validateRegisteredLedgersOrThrow(registryPath);
440
+ if (!validation.ok)
441
+ return printRegisteredLedgerValidation(registryPath, validation.results, json);
442
+ const results = validation.results.map(({ ledger }) => ({ ledger, entries: listTrashedRecords(ledger.path) }));
443
+ if (json)
444
+ return printJson({ ok: true, registryPath, ledgers: results });
445
+ printTrashListEntries(results);
446
+ process.stdout.write(`registry: ${registryPath}\n`);
447
+ return 0;
448
+ }
449
+ const entries = listTrashedRecords(ledgerPath);
450
+ if (json)
451
+ return printJson({ ok: true, ledgerPath, entries });
452
+ if (entries.length === 0) {
453
+ process.stdout.write(`no trashed records\nledger: ${ledgerPath}\n`);
454
+ return 0;
455
+ }
456
+ for (const entry of entries) {
457
+ process.stdout.write(`${entry.id} age ${entry.age} target ${entry.targetPath} cleaned ${entry.cleanedAt} receipt ${entry.receiptPath} plan ${entry.cleanupPlanId}\n`);
458
+ }
459
+ process.stdout.write(`ledger: ${ledgerPath}\n`);
460
+ return 0;
461
+ }
462
+ function handleTrashPurge(parsed, ledgerPath, json) {
463
+ const execute = boolFlag(parsed, "execute");
464
+ const dryRun = boolFlag(parsed, "dry-run");
465
+ if (dryRun && execute)
466
+ throw new Error("trash purge accepts either --dry-run or --execute, not both");
467
+ if (boolFlag(parsed, "all")) {
468
+ throw new Error("trash purge --all is not supported; scope the purge to one --ledger and review the plan id before execute");
469
+ }
470
+ if (!dryRun && !execute)
471
+ throw new Error("trash purge requires either --dry-run or --execute");
472
+ if (execute) {
473
+ const planId = requiredStringFlag(parsed, "plan-id");
474
+ const receipt = executeTrashPurgePlan(ledgerPath, planId);
475
+ if (json)
476
+ return printJson({ ok: true, receipt });
477
+ process.stdout.write(`trash receipt ${receipt.purgePlanId}: ${receipt.results.length} results\nreceipt: ${receipt.receiptPath}\nledger: ${ledgerPath}\n`);
478
+ return 0;
479
+ }
480
+ const olderThan = requiredStringFlag(parsed, "older-than");
481
+ const plan = createTrashPurgePlan(ledgerPath, olderThan);
482
+ if (json)
483
+ return printJson({ ok: true, plan });
484
+ if (plan.entries.length === 0) {
485
+ process.stdout.write(`trash purge plan ${plan.purgePlanId}: no matching trashed records\nledger: ${ledgerPath}\n`);
486
+ return 0;
487
+ }
488
+ process.stdout.write(`trash purge plan ${plan.purgePlanId}: ${plan.entries.length} entries, ${plan.skipped.length} skipped\n`);
489
+ process.stdout.write(`plan: ${plan.planPath ?? "not-created"}\nledger: ${ledgerPath}\n`);
490
+ return 0;
491
+ }
492
+ function handleReview(parsed, ledgerPath, json) {
493
+ if (boolFlag(parsed, "all")) {
494
+ const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
495
+ const results = registeredLedgersOrThrow(registryPath).map((ledger) => reviewLedger(ledger));
496
+ const ok = results.every((entry) => entry.validate.ok);
497
+ const summary = summarizeReview(results);
498
+ const nextAction = reviewNextAction(summary);
499
+ if (json) {
500
+ printJson({ ok, registryPath, summary, nextAction, ledgers: results });
501
+ return ok ? 0 : 1;
502
+ }
503
+ printReviewAll(results, summary, nextAction, registryPath);
504
+ return ok ? 0 : 1;
505
+ }
506
+ const result = reviewLedger({ name: "current", path: ledgerPath, scope: "other", createdAt: "", updatedAt: "" }, false);
507
+ if (json) {
508
+ printJson({ ok: result.validate.ok, ledger: result });
509
+ return result.validate.ok ? 0 : 1;
510
+ }
511
+ printReview([result]);
512
+ return result.validate.ok ? 0 : 1;
513
+ }
514
+ function handleDoctor(parsed, ledgerPath, json) {
515
+ const report = buildDoctorReport(ledgerPath, normalizeRegistryPath(stringFlag(parsed, "registry")));
516
+ if (json) {
517
+ printJson(report);
518
+ return report.ok ? 0 : 1;
519
+ }
520
+ printDoctor(report);
521
+ return report.ok ? 0 : 1;
522
+ }
523
+ function buildDoctorReport(ledgerPath, registryPath) {
524
+ const errors = [];
525
+ let registryOk = true;
526
+ let registryError = null;
527
+ let entries = [];
528
+ try {
529
+ entries = listRegisteredLedgers(registryPath);
530
+ }
531
+ catch (error) {
532
+ registryOk = false;
533
+ registryError = error.message;
534
+ errors.push(`registry could not be read: ${registryPath} (${registryError})`);
535
+ }
536
+ const ledgers = entries.map((entry) => {
537
+ const result = validateRegisteredLedger(entry);
538
+ const status = result.ok ? "ok" : existsSync(entry.path) ? "invalid" : "missing";
539
+ if (!result.ok) {
540
+ for (const message of result.errors)
541
+ errors.push(`${entry.name}: ${message}`);
542
+ }
543
+ return {
544
+ name: entry.name,
545
+ path: entry.path,
546
+ scope: entry.scope,
547
+ status,
548
+ ok: result.ok,
549
+ entries: result.entries,
550
+ errors: result.errors,
551
+ warnings: result.warnings
552
+ };
553
+ });
554
+ const summary = {
555
+ ledgers: ledgers.length,
556
+ ok: ledgers.filter((ledger) => ledger.status === "ok").length,
557
+ stale: ledgers.filter((ledger) => ledger.status === "missing").length,
558
+ invalid: ledgers.filter((ledger) => ledger.status === "invalid").length,
559
+ warnings: ledgers.reduce((count, ledger) => count + ledger.warnings.length, 0)
560
+ };
561
+ return {
562
+ ok: registryOk && summary.stale === 0 && summary.invalid === 0,
563
+ version: VERSION,
564
+ node: process.version,
565
+ ledgerPath,
566
+ ledgerExists: existsSync(ledgerPath),
567
+ registryPath,
568
+ registryExists: existsSync(registryPath),
569
+ registryOk,
570
+ registryError,
571
+ ledgers,
572
+ summary,
573
+ cleanupSafety: {
574
+ executeRequiresLedgerAndPlanId: true,
575
+ globalExecuteRefused: true,
576
+ deleteRefusedInV1: true,
577
+ dryRunBeforeMutation: true
578
+ },
579
+ errors
580
+ };
581
+ }
582
+ function printDoctor(report) {
583
+ process.stdout.write(`artshelf ${report.version} (node ${report.node})\n`);
584
+ process.stdout.write(`health: ${report.ok ? "ok" : "needs attention"}\n`);
585
+ process.stdout.write(`ledger: ${report.ledgerPath}${report.ledgerExists ? "" : " (absent)"}\n`);
586
+ process.stdout.write(`registry: ${report.registryPath}${report.registryExists ? "" : " (absent)"}\n`);
587
+ if (report.registryError)
588
+ process.stdout.write(`registry error: ${report.registryError}\n`);
589
+ process.stdout.write(`registered ledgers: ${report.summary.ledgers} (${report.summary.ok} ok, ${report.summary.stale} stale, ${report.summary.invalid} invalid)\n`);
590
+ for (const ledger of report.ledgers) {
591
+ process.stdout.write(` ${ledger.status} ${ledger.name} ${ledger.path}\n`);
592
+ for (const message of ledger.errors)
593
+ process.stdout.write(` error: ${message}\n`);
594
+ }
595
+ process.stdout.write("cleanup safety: execute requires a reviewed plan id against a single ledger; --all execute is refused; cleanup=delete is refused; physical trash purge requires a separate reviewed plan\n");
596
+ if (!report.ok) {
597
+ for (const message of report.errors)
598
+ process.stdout.write(`error: ${message}\n`);
599
+ }
600
+ }
601
+ function handleStatus(parsed, ledgerPath, json) {
602
+ if (boolFlag(parsed, "all")) {
603
+ const report = buildStatusReport(normalizeRegistryPath(stringFlag(parsed, "registry")));
604
+ if (json) {
605
+ printJson(report);
606
+ return report.ok ? 0 : 1;
607
+ }
608
+ printStatusAll(report);
609
+ return report.ok ? 0 : 1;
610
+ }
611
+ const ledger = statusLedger({ name: "current", path: ledgerPath, scope: "other", createdAt: "", updatedAt: "" }, false);
612
+ if (json) {
613
+ printJson({ ok: ledger.ok, ledger });
614
+ return ledger.ok ? 0 : 1;
615
+ }
616
+ printStatusSingle(ledger);
617
+ return ledger.ok ? 0 : 1;
618
+ }
619
+ function buildStatusReport(registryPath) {
620
+ let registryOk = true;
621
+ let registryError = null;
622
+ let entries = [];
623
+ try {
624
+ entries = listRegisteredLedgers(registryPath);
625
+ }
626
+ catch (error) {
627
+ registryOk = false;
628
+ registryError = error.message;
629
+ }
630
+ const ledgers = entries.map((entry) => statusLedger(entry));
631
+ const totals = {
632
+ ledgers: ledgers.length,
633
+ ok: ledgers.filter((ledger) => ledger.status === "ok").length,
634
+ stale: ledgers.filter((ledger) => ledger.status === "missing").length,
635
+ invalid: ledgers.filter((ledger) => ledger.status === "invalid").length,
636
+ active: sumStatusCounts(ledgers, "active"),
637
+ due: sumStatusCounts(ledgers, "due"),
638
+ manualReview: sumStatusCounts(ledgers, "manualReview"),
639
+ missingPath: sumStatusCounts(ledgers, "missingPath"),
640
+ kept: sumStatusCounts(ledgers, "kept"),
641
+ pendingCleanup: sumStatusCounts(ledgers, "pendingCleanup")
642
+ };
643
+ return {
644
+ ok: registryOk && totals.stale === 0 && totals.invalid === 0,
645
+ registryPath,
646
+ registryExists: existsSync(registryPath),
647
+ registryOk,
648
+ registryError,
649
+ ledgers,
650
+ totals
651
+ };
652
+ }
653
+ function statusLedger(ledger, registered = true) {
654
+ const validate = registered ? validateRegisteredLedger(ledger) : validateLedger(ledger.path);
655
+ if (!validate.ok) {
656
+ return {
657
+ name: ledger.name,
658
+ path: ledger.path,
659
+ scope: ledger.scope,
660
+ status: existsSync(ledger.path) ? "invalid" : "missing",
661
+ ok: false,
662
+ counts: emptyStatusCounts(),
663
+ errors: validate.errors
664
+ };
665
+ }
666
+ const records = readLedger(ledger.path);
667
+ const due = dueEntries(records);
668
+ const counts = {
669
+ active: records.filter((record) => record.status === "active").length,
670
+ due: due.filter((entry) => entry.dueStatus === "due").length,
671
+ manualReview: due.filter((entry) => entry.dueStatus === "manual-review").length,
672
+ missingPath: due.filter((entry) => entry.dueStatus === "missing-path").length,
673
+ kept: due.filter((entry) => entry.dueStatus === "kept").length,
674
+ pendingCleanup: previewCleanupPlan(ledger.path).entries.length
675
+ };
676
+ return {
677
+ name: ledger.name,
678
+ path: ledger.path,
679
+ scope: ledger.scope,
680
+ status: "ok",
681
+ ok: true,
682
+ counts,
683
+ errors: []
684
+ };
685
+ }
686
+ function emptyStatusCounts() {
687
+ return { active: 0, due: 0, manualReview: 0, missingPath: 0, kept: 0, pendingCleanup: 0 };
688
+ }
689
+ function sumStatusCounts(ledgers, key) {
690
+ return ledgers.reduce((total, ledger) => total + ledger.counts[key], 0);
691
+ }
692
+ function formatStatusCounts(counts) {
693
+ return `active ${counts.active} · due ${counts.due} · manual-review ${counts.manualReview} · missing ${counts.missingPath} · kept ${counts.kept} · pending ${counts.pendingCleanup}`;
694
+ }
695
+ function printStatusAll(report) {
696
+ process.stdout.write(`artshelf status: ${report.ok ? "ok" : "needs attention"}\n`);
697
+ process.stdout.write(`registry: ${report.registryPath}${report.registryExists ? "" : " (absent)"} — ${report.totals.ledgers} ledgers (${report.totals.ok} ok, ${report.totals.stale} stale, ${report.totals.invalid} invalid)\n`);
698
+ if (report.registryError)
699
+ process.stdout.write(`registry error: ${report.registryError}\n`);
700
+ for (const ledger of report.ledgers) {
701
+ if (ledger.status === "ok") {
702
+ process.stdout.write(`[${ledger.name}] ${formatStatusCounts(ledger.counts)}\n`);
703
+ }
704
+ else {
705
+ process.stdout.write(`[${ledger.name}] ${ledger.status}: ${ledger.errors.join("; ")}\n`);
706
+ }
707
+ }
708
+ process.stdout.write(`total: ${formatStatusCounts(report.totals)}\n`);
709
+ }
710
+ function printStatusSingle(ledger) {
711
+ process.stdout.write(`artshelf status: ${ledger.ok ? "ok" : ledger.status}\n`);
712
+ process.stdout.write(`ledger: ${ledger.path}\n`);
713
+ if (ledger.ok) {
714
+ process.stdout.write(`${formatStatusCounts(ledger.counts)}\n`);
715
+ }
716
+ else {
717
+ for (const message of ledger.errors)
718
+ process.stdout.write(`error: ${message}\n`);
719
+ }
720
+ }
721
+ function parseArgs(argv) {
722
+ const [command, ...rest] = argv;
723
+ const flags = new Map();
724
+ const positionals = [];
725
+ for (let index = 0; index < rest.length; index += 1) {
726
+ const arg = rest[index];
727
+ if (!arg)
728
+ continue;
729
+ if (!arg.startsWith("--")) {
730
+ positionals.push(arg);
731
+ continue;
732
+ }
733
+ const name = arg.slice(2);
734
+ if (BOOLEAN_FLAGS.has(name)) {
735
+ flags.set(name, true);
736
+ continue;
737
+ }
738
+ if (!VALUE_FLAGS.has(name))
739
+ throw new Error(`Unknown flag: --${name}`);
740
+ const value = rest[index + 1];
741
+ if (!value || value.startsWith("--"))
742
+ throw new Error(`Missing value for --${name}`);
743
+ index += 1;
744
+ if (name === "label") {
745
+ const current = flags.get(name);
746
+ flags.set(name, [...(Array.isArray(current) ? current : []), value]);
747
+ }
748
+ else {
749
+ flags.set(name, value);
750
+ }
751
+ }
752
+ return { command, positionals, flags };
753
+ }
754
+ function requiredStringFlag(parsed, name) {
755
+ const value = stringFlag(parsed, name);
756
+ if (!value)
757
+ throw new Error(`Missing required --${name}`);
758
+ return value;
759
+ }
760
+ function stringFlag(parsed, name) {
761
+ const value = parsed.flags.get(name);
762
+ return typeof value === "string" ? value : undefined;
763
+ }
764
+ function boolFlag(parsed, name) {
765
+ return parsed.flags.get(name) === true;
766
+ }
767
+ function arrayFlag(parsed, name) {
768
+ const value = parsed.flags.get(name);
769
+ return Array.isArray(value) ? value : [];
770
+ }
771
+ function printJson(value) {
772
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
773
+ return 0;
774
+ }
775
+ function registeredLedgersOrThrow(registryPath) {
776
+ const ledgers = listRegisteredLedgers(registryPath);
777
+ if (ledgers.length === 0)
778
+ throw new Error("No registered Artshelf ledgers. Run `artshelf ledgers add --ledger <path>` first.");
779
+ return ledgers;
780
+ }
781
+ function validateRegisteredLedgersOrThrow(registryPath) {
782
+ const results = registeredLedgersOrThrow(registryPath).map((ledger) => ({ ledger, result: validateRegisteredLedger(ledger) }));
783
+ return { ok: results.every((entry) => entry.result.ok), results };
784
+ }
785
+ function printRegisteredLedgerValidation(registryPath, results, json) {
786
+ if (json) {
787
+ printJson({ ok: false, registryPath, ledgers: results });
788
+ return 1;
789
+ }
790
+ for (const entry of results.filter((item) => !item.result.ok)) {
791
+ process.stdout.write(`invalid ${entry.ledger.name}: ${entry.result.errors.join("; ")}\nledger: ${entry.ledger.path}\n`);
792
+ }
793
+ process.stdout.write(`registry: ${registryPath}\n`);
794
+ return 1;
795
+ }
796
+ function validateRegisteredLedger(ledger) {
797
+ if (!existsSync(ledger.path)) {
798
+ return {
799
+ ok: false,
800
+ errors: [`registered ledger is missing: ${ledger.path}`],
801
+ warnings: [],
802
+ entries: 0
803
+ };
804
+ }
805
+ return validateLedger(ledger.path);
806
+ }
807
+ function reviewLedger(ledger, registered = true) {
808
+ const validate = registered ? validateRegisteredLedger(ledger) : validateLedger(ledger.path);
809
+ if (!validate.ok) {
810
+ return {
811
+ ledger,
812
+ validate,
813
+ due: [],
814
+ plan: emptyReviewPlan(ledger.path)
815
+ };
816
+ }
817
+ return {
818
+ ledger,
819
+ validate,
820
+ due: dueEntries(readLedger(ledger.path)),
821
+ plan: previewCleanupPlan(ledger.path)
822
+ };
823
+ }
824
+ function emptyReviewPlan(ledgerPath) {
825
+ return {
826
+ planId: "not-created",
827
+ generatedAt: "",
828
+ ledgerPath,
829
+ entries: [],
830
+ skipped: [],
831
+ planPath: null
832
+ };
833
+ }
834
+ function printLedgerEntries(results, status) {
835
+ const total = results.reduce((count, result) => count + result.entries.length, 0);
836
+ if (total === 0) {
837
+ process.stdout.write(`no artshelf entries${status ? ` with status ${status}` : ""}\n`);
838
+ return;
839
+ }
840
+ for (const result of results) {
841
+ if (result.entries.length === 0)
842
+ continue;
843
+ process.stdout.write(`\n[${result.ledger.name}] ${result.ledger.path}\n`);
844
+ for (const record of result.entries) {
845
+ process.stdout.write(`${record.id} ${record.kind} ${record.status} ${record.cleanup} ${record.path} :: ${record.reason}\n`);
846
+ }
847
+ }
848
+ }
849
+ function printDueEntries(results) {
850
+ const visible = results.flatMap((result) => result.entries.filter((entry) => entry.dueStatus !== "kept").map((entry) => ({ ledger: result.ledger, entry })));
851
+ if (visible.length === 0) {
852
+ process.stdout.write("nothing due\n");
853
+ return;
854
+ }
855
+ for (const item of visible) {
856
+ process.stdout.write(`${item.entry.dueStatus} ${item.entry.id} ${item.entry.cleanup} ${item.entry.path} :: ${item.entry.reason}\nledger: ${item.ledger.path}\n`);
857
+ }
858
+ }
859
+ function printPlans(results) {
860
+ for (const result of results) {
861
+ process.stdout.write(`plan ${result.plan.planId} [${result.ledger.name}]: ${result.plan.entries.length} entries, ${result.plan.skipped.length} skipped\n`);
862
+ process.stdout.write(`plan: ${result.plan.planPath ?? "not created"}\nledger: ${result.ledger.path}\n`);
863
+ }
864
+ }
865
+ function printPlan(plan, ledgerPath) {
866
+ process.stdout.write(`plan ${plan.planId}: ${plan.entries.length} entries, ${plan.skipped.length} skipped\n`);
867
+ process.stdout.write(`plan: ${plan.planPath ?? "not created"}\nledger: ${ledgerPath}\n`);
868
+ }
869
+ function printTrashListEntries(results) {
870
+ const total = results.reduce((count, result) => count + result.entries.length, 0);
871
+ if (total === 0) {
872
+ process.stdout.write("no trashed records\n");
873
+ return;
874
+ }
875
+ for (const result of results) {
876
+ if (result.entries.length === 0)
877
+ continue;
878
+ process.stdout.write(`\n[${result.ledger.name}] ${result.ledger.path}\n`);
879
+ for (const entry of result.entries) {
880
+ process.stdout.write(`trash ${entry.id} ${entry.age} ${entry.cleanedAt} ${entry.targetPath} -> ${entry.receiptPath} (${entry.cleanupPlanId})\n`);
881
+ }
882
+ }
883
+ }
884
+ function summarizeReview(results) {
885
+ const summary = {
886
+ ledgers: results.length,
887
+ ok: 0,
888
+ invalid: 0,
889
+ stale: 0,
890
+ affected: 0,
891
+ due: 0,
892
+ manualReview: 0,
893
+ missingPath: 0,
894
+ executable: 0,
895
+ skipped: 0,
896
+ previewPlanIds: []
897
+ };
898
+ for (const result of results) {
899
+ if (result.validate.ok) {
900
+ summary.ok += 1;
901
+ }
902
+ else if (existsSync(result.ledger.path)) {
903
+ summary.invalid += 1;
904
+ }
905
+ else {
906
+ summary.stale += 1;
907
+ }
908
+ const due = result.due.filter((entry) => entry.dueStatus === "due").length;
909
+ const manualReview = result.due.filter((entry) => entry.dueStatus === "manual-review").length;
910
+ const missingPath = result.due.filter((entry) => entry.dueStatus === "missing-path").length;
911
+ summary.due += due;
912
+ summary.manualReview += manualReview;
913
+ summary.missingPath += missingPath;
914
+ summary.executable += result.plan.entries.length;
915
+ summary.skipped += result.plan.skipped.length;
916
+ if (result.plan.planId !== "not-created")
917
+ summary.previewPlanIds.push(result.plan.planId);
918
+ if (!result.validate.ok || due + manualReview + missingPath > 0 || result.plan.entries.length > 0) {
919
+ summary.affected += 1;
920
+ }
921
+ }
922
+ return summary;
923
+ }
924
+ function reviewNextAction(summary) {
925
+ const broken = summary.invalid + summary.stale;
926
+ if (broken > 0) {
927
+ return `repair ${broken} broken ledger(s) above (re-register or fix the file), then re-run \`artshelf review --all\``;
928
+ }
929
+ if (summary.executable > 0) {
930
+ return "run `artshelf cleanup --dry-run --all` to generate plans, then `artshelf cleanup --execute --plan-id <id> --ledger <path>` for each reviewed plan";
931
+ }
932
+ if (summary.missingPath > 0) {
933
+ return "inspect missing-path entries and `artshelf resolve` the ones no longer needed; nothing is auto-executable";
934
+ }
935
+ return "nothing to do — no broken ledgers and no due, manual-review, missing-path, or executable cleanup entries";
936
+ }
937
+ function printReviewAll(results, summary, nextAction, registryPath) {
938
+ const needsAttention = summary.invalid + summary.stale + summary.executable + summary.due + summary.manualReview + summary.missingPath > 0;
939
+ process.stdout.write(`artshelf review --all: ${needsAttention ? "needs attention" : "all clear"}\n`);
940
+ process.stdout.write(`registry: ${registryPath} — ${summary.ledgers} ledgers (${summary.ok} ok, ${summary.invalid} invalid, ${summary.stale} stale)\n`);
941
+ printReview(results);
942
+ process.stdout.write(`triage: due ${summary.due} · manual-review ${summary.manualReview} · missing ${summary.missingPath} · executable ${summary.executable} · skipped ${summary.skipped}\n`);
943
+ process.stdout.write(`next: ${nextAction}\n`);
944
+ }
945
+ function printReview(results) {
946
+ for (const result of results) {
947
+ const visibleDue = result.due.filter((entry) => entry.dueStatus !== "kept");
948
+ process.stdout.write(`[${result.ledger.name}] ${result.validate.ok ? "ok" : "invalid"}: ${result.validate.entries} entries, ${result.validate.errors.length} errors, ${result.validate.warnings.length} warnings\n`);
949
+ process.stdout.write(`due/manual/missing: ${visibleDue.length}; plan ${result.plan.planId}: ${result.plan.entries.length} entries, ${result.plan.skipped.length} skipped\n`);
950
+ process.stdout.write(`ledger: ${result.ledger.path}\n`);
951
+ }
952
+ }
953
+ function printHelp(command) {
954
+ if (command === "put") {
955
+ process.stdout.write(`Usage:
956
+ artshelf put <path> --reason <text> (--ttl <ttl>|--retain-until <date>|--manual-review) [options]
957
+
958
+ Options:
959
+ --kind scratch|backup|run-artifact|evidence|cache|quarantine|other
960
+ --cleanup trash|review|delete (cleanup=delete is refused; trash purge needs a reviewed plan)
961
+ --owner <name>
962
+ --label <label> Repeatable
963
+ --ledger <path>
964
+ --registry <path>
965
+ --json
966
+ `);
967
+ return;
968
+ }
969
+ if (command === "cleanup") {
970
+ process.stdout.write(`Usage:
971
+ artshelf cleanup --dry-run [--ledger <path>] [--json]
972
+ artshelf cleanup --dry-run --all [--registry <path>] [--json]
973
+ artshelf cleanup --execute --plan-id <id> [--ledger <path>] [--json]
974
+
975
+ Cleanup execution is approval-only. There is no daemon, no auto-execute, and no
976
+ global execute path: review a dry-run plan, then execute that one reviewed plan id.
977
+ Cleanup is ledger-first. Execute never computes a fresh live set; it only uses a reviewed plan id.
978
+ cleanup=delete records cleanup-refused instead of deleting files; physical trash purge needs a separate reviewed plan.
979
+ Dry-run writes and registers a plan only when executable cleanup entries exist; no-op dry-runs report not-created.
980
+ Matching dry-runs reuse the existing plan id and refresh its Artshelf-owned plan artifact.
981
+ Execute writes and registers an Artshelf-owned receipt artifact.
982
+ Global --all mode is dry-run only.
983
+ `);
984
+ return;
985
+ }
986
+ if (command === "trash") {
987
+ process.stdout.write(`Usage:
988
+ artshelf trash list [--ledger <path>] [--all] [--json]
989
+ artshelf trash purge --older-than <ttl> --dry-run [--ledger <path>] [--json]
990
+ artshelf trash purge --execute --plan-id <id> [--ledger <path>] [--json]
991
+
992
+ Trash is approval-first. Use list to inspect what is currently in Artshelf trash and
993
+ dry-run purge to generate a reviewed plan id for age-based deletion. Purge
994
+ requires either --dry-run or --execute. Execute requires a reviewed plan id, and
995
+ trash purge is always scoped to one --ledger; --all is not supported for purge
996
+ (only for trash list).
997
+ Trash receipt artifacts are registered when purge executes. Completed receipts are
998
+ refused on repeat execute; started receipts from interrupted purges may be resumed
999
+ and reconciled. Purged records are resolved and no longer reappear as trashed.
1000
+ `);
1001
+ return;
1002
+ }
1003
+ if (command === "ledgers") {
1004
+ process.stdout.write(`Usage:
1005
+ artshelf ledgers list [--plain] [--registry <path>] [--json]
1006
+ artshelf ledgers add --ledger <path> [--name <name>] [--scope repo|user|other] [--registry <path>] [--json]
1007
+
1008
+ The ledger registry is a global index of known ledgers. It gives Artshelf one read-only entry point without moving project records into one global ledger.
1009
+ By default \`list\` validates each registered ledger and reports ok/missing/invalid status, entry counts, and warning/error counts so agents can spot stale registry entries without a separate validate pass; it exits non-zero when the registry or any registered ledger is broken.
1010
+ Use \`--plain\` for the fast path that lists registered ledgers without reading them.
1011
+ `);
1012
+ return;
1013
+ }
1014
+ if (command === "list") {
1015
+ process.stdout.write(`Usage:
1016
+ artshelf list [--status <status>] [--ledger <path>] [--json]
1017
+ artshelf list --all [--status <status>] [--registry <path>] [--json]
1018
+
1019
+ Statuses:
1020
+ active, review-required, trashed, cleanup-refused, resolved
1021
+ `);
1022
+ return;
1023
+ }
1024
+ if (command === "find") {
1025
+ process.stdout.write(`Usage:
1026
+ artshelf find (--path <path>|--owner <name>|--label <label>|--status <status>) [options]
1027
+ artshelf find --all (--path <path>|--owner <name>|--label <label>|--status <status>) [options]
1028
+
1029
+ Options:
1030
+ --path <path> Match an exact artifact path after path normalization
1031
+ --owner <name>
1032
+ --label <label> Repeatable; all labels must match
1033
+ --status <status>
1034
+ --ledger <path>
1035
+ --registry <path>
1036
+ --json
1037
+
1038
+ Find is read-only. Use it before put when an integration needs idempotent artifact registration.
1039
+ `);
1040
+ return;
1041
+ }
1042
+ if (command === "get") {
1043
+ process.stdout.write(`Usage:
1044
+ artshelf get <id> [--ledger <path>] [--json]
1045
+ artshelf get <id> --all [--registry <path>] [--json]
1046
+
1047
+ Get is read-only and returns one ledger record by Artshelf id.
1048
+ `);
1049
+ return;
1050
+ }
1051
+ if (command === "resolve") {
1052
+ process.stdout.write(`Usage:
1053
+ artshelf resolve <id> --status resolved --reason <text> [--ledger <path>] [--json]
1054
+
1055
+ Resolve marks a handled, missing, or no-longer-needed record as manually resolved.
1056
+ Resolved records stay in the audit trail but no longer participate in due or cleanup planning.
1057
+ `);
1058
+ return;
1059
+ }
1060
+ if (command === "review") {
1061
+ process.stdout.write(`Usage:
1062
+ artshelf review [--ledger <path>] [--json]
1063
+ artshelf review --all [--registry <path>] [--json]
1064
+
1065
+ Review runs validate, due, and cleanup plan preview without moving files or writing a plan.
1066
+ With --all, review adds aggregate triage counts and the next safe action.
1067
+ `);
1068
+ return;
1069
+ }
1070
+ if (command === "doctor") {
1071
+ process.stdout.write(`Usage:
1072
+ artshelf doctor [--registry <path>] [--ledger <path>] [--json]
1073
+
1074
+ Doctor reports whether Artshelf is healthy on this machine: CLI version, selected
1075
+ or default ledger path, selected or global registry path, registered ledger health
1076
+ (stale/missing/invalid), and the cleanup safety posture. Execute is scoped to one
1077
+ selected or default ledger and still requires a reviewed plan id; --all execute
1078
+ and cleanup=delete are refused, while physical trash purge requires a separate
1079
+ reviewed purge plan.
1080
+
1081
+ Run it after install, when --all commands behave unexpectedly, or on a schedule to
1082
+ catch stale registry entries. Doctor is read-only. A healthy machine exits 0; a
1083
+ broken registry or registered ledger exits non-zero with actionable errors.
1084
+ `);
1085
+ return;
1086
+ }
1087
+ if (command === "status") {
1088
+ process.stdout.write(`Usage:
1089
+ artshelf status [--ledger <path>] [--json]
1090
+ artshelf status --all [--registry <path>] [--json]
1091
+
1092
+ Status is the lightweight daily "what is going on?" view. Without --all, it
1093
+ reports counts for the selected or default ledger only. With --all, it adds
1094
+ registry health, total ledgers, and aggregated counts across registered ledgers.
1095
+ Counts include active artifacts, kept, due, manual-review, missing-path, and
1096
+ pending cleanup entries.
1097
+
1098
+ Human output is short enough to paste into a chat; \`artshelf status --all --json\`
1099
+ is suitable for cron and reporting. Status is read-only: it never creates plans
1100
+ or receipts and never mutates records. A healthy selected ledger exits 0; with
1101
+ --all, a broken registry or any stale or invalid registered ledger exits non-zero.
1102
+ `);
1103
+ return;
1104
+ }
1105
+ process.stdout.write(`Artshelf ${VERSION}
1106
+
1107
+ Usage:
1108
+ artshelf put <path> --reason <text> (--ttl <ttl>|--retain-until <date>|--manual-review)
1109
+ artshelf ledgers list [--plain] [--json]
1110
+ artshelf ledgers add --ledger <path> [--name <name>] [--json]
1111
+ artshelf list [--json]
1112
+ artshelf list --all [--json]
1113
+ artshelf list --status active [--json]
1114
+ artshelf find --path <path> [--json]
1115
+ artshelf find --all --owner <name> [--json]
1116
+ artshelf get <id> [--json]
1117
+ artshelf get <id> --all [--json]
1118
+ artshelf due [--json]
1119
+ artshelf due --all [--json]
1120
+ artshelf validate [--json]
1121
+ artshelf validate --all [--json]
1122
+ artshelf review [--json]
1123
+ artshelf review --all [--json]
1124
+ artshelf doctor [--json]
1125
+ artshelf status [--json]
1126
+ artshelf status --all [--json]
1127
+ artshelf cleanup --dry-run [--json]
1128
+ artshelf cleanup --dry-run --all [--json]
1129
+ artshelf cleanup --execute --plan-id <id> [--json]
1130
+ artshelf trash list [--all] [--ledger <path>] [--json]
1131
+ artshelf trash purge --older-than <ttl> --dry-run [--ledger <path>] [--json]
1132
+ artshelf trash purge --execute --plan-id <id> [--ledger <path>] [--json]
1133
+ artshelf resolve <id> --status resolved --reason <text> [--json]
1134
+
1135
+ Global options:
1136
+ --ledger <path> Use an explicit JSONL ledger
1137
+ --registry <path> Use an explicit ledger registry
1138
+ --all Read all registered ledgers for supported commands
1139
+ --json Emit machine-readable JSON
1140
+ --help Show help
1141
+ --version Show version
1142
+
1143
+ Examples:
1144
+ artshelf put tmp/run-output --reason "debug parser output" --ttl 3d --kind scratch
1145
+ artshelf cleanup --dry-run --json
1146
+ artshelf cleanup --execute --plan-id plan_20260601_120000_ab12
1147
+ `);
1148
+ }
1149
+ process.exitCode = main(process.argv.slice(2));