artshelf 0.10.0 → 0.10.2

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 (38) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +8 -6
  3. package/SPEC.md +16 -7
  4. package/dist/src/adapters/process.js +7 -0
  5. package/dist/src/adapters/update.js +143 -0
  6. package/dist/src/cli.js +44 -1831
  7. package/dist/src/commands/cleanup.js +52 -0
  8. package/dist/src/commands/doctor.js +79 -0
  9. package/dist/src/commands/due.js +32 -0
  10. package/dist/src/commands/find.js +44 -0
  11. package/dist/src/commands/get.js +31 -0
  12. package/dist/src/commands/index.js +69 -0
  13. package/dist/src/commands/ledgers.js +111 -0
  14. package/dist/src/commands/list.js +36 -0
  15. package/dist/src/commands/put.js +36 -0
  16. package/dist/src/commands/resolve.js +17 -0
  17. package/dist/src/commands/review.js +38 -0
  18. package/dist/src/commands/shared.js +160 -0
  19. package/dist/src/commands/status.js +101 -0
  20. package/dist/src/commands/trash.js +78 -0
  21. package/dist/src/commands/update.js +75 -0
  22. package/dist/src/commands/validate.js +35 -0
  23. package/dist/src/config/env.js +24 -0
  24. package/dist/src/config/package.js +17 -0
  25. package/dist/src/config/paths.js +5 -0
  26. package/dist/src/renderers/attention.js +3 -0
  27. package/dist/src/renderers/doctor.js +64 -0
  28. package/dist/src/renderers/json.js +10 -0
  29. package/dist/src/renderers/review.js +159 -0
  30. package/dist/src/renderers/status.js +112 -0
  31. package/dist/src/shared/cli-types.js +1 -0
  32. package/dist/src/shared/errors.js +4 -0
  33. package/dist/src/shared/flags.js +41 -0
  34. package/dist/src/shared/help-text.js +355 -0
  35. package/docs/agent-usage.html +1 -1
  36. package/docs/agent-usage.md +2 -2
  37. package/docs/reference.html +12 -6
  38. package/package.json +1 -1
package/dist/src/cli.js CHANGED
@@ -1,40 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { spawnSync } from "node:child_process";
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
- import { homedir } from "node:os";
5
- import { dirname, join } from "node:path";
6
- import { appendPreparedRecord, createCleanupPlan, createTrashPurgePlan, dueEntries, executeCleanupPlan, executeTrashPurgePlan, filterRecordsByStatus, findRecords, getRecord, listTrashedRecords, normalizeLedgerPath, prepareRecord, previewCleanupPlan, readLedger, resolveRecord, validateLedger } from "./ledger.js";
7
- import { listRegisteredLedgers, normalizeRegistryPath, registerLedger } from "./registry.js";
8
- const VERSION = readPackageVersion();
9
- const PACKAGE_NAME = "artshelf";
10
- const NPM_REGISTRY_URL = process.env.ARTSHELF_NPM_REGISTRY_URL ?? `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
11
- const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
12
- const BOOLEAN_FLAGS = new Set(["all", "json", "agent", "manual-review", "dry-run", "execute", "help", "version", "plain"]);
13
- const VALUE_FLAGS = new Set([
14
- "cleanup",
15
- "kind",
16
- "label",
17
- "ledger",
18
- "name",
19
- "owner",
20
- "path",
21
- "plan-id",
22
- "older-than",
23
- "registry",
24
- "reason",
25
- "retain-until",
26
- "scope",
27
- "status",
28
- "ttl"
29
- ]);
30
- function readPackageVersion() {
31
- const packageJsonPath = decodeURIComponent(new URL("../../package.json", import.meta.url).pathname);
32
- const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
33
- if (typeof packageJson.version !== "string") {
34
- throw new Error("package.json version must be a string");
35
- }
36
- return packageJson.version;
37
- }
2
+ import { maybeNotifyAvailableUpdate, runCommand } from "./commands/index.js";
3
+ import { VERSION } from "./config/package.js";
4
+ import { formatCliError } from "./shared/errors.js";
5
+ import { BOOLEAN_FLAGS, boolFlag, VALUE_FLAGS } from "./shared/flags.js";
6
+ import { renderHelp, resolveHelpKey } from "./shared/help-text.js";
38
7
  async function main(argv) {
39
8
  try {
40
9
  const parsed = parseArgs(argv);
@@ -45,1833 +14,77 @@ async function main(argv) {
45
14
  return maybeNotifyUpdateAndReturn(0, parsed);
46
15
  }
47
16
  if (parsed.command === "help" || parsed.command === "--help" || parsed.command === "-h" || boolFlag(parsed, "help")) {
48
- printHelp(resolveHelpKey(parsed));
17
+ process.stdout.write(renderHelp(resolveHelpKey(parsed), VERSION));
49
18
  return maybeNotifyUpdateAndReturn(0, parsed);
50
19
  }
51
- switch (parsed.command) {
52
- case "put":
53
- status = handlePut(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
54
- break;
55
- case "ledgers":
56
- status = handleLedgers(parsed, boolFlag(parsed, "json"));
57
- break;
58
- case "list":
59
- status = handleList(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
60
- break;
61
- case "find":
62
- status = handleFind(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
63
- break;
64
- case "get":
65
- status = handleGet(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
66
- break;
67
- case "due":
68
- status = handleDue(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
69
- break;
70
- case "validate":
71
- status = handleValidate(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
72
- break;
73
- case "cleanup":
74
- status = handleCleanup(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
75
- break;
76
- case "trash":
77
- status = handleTrash(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
78
- break;
79
- case "review":
80
- status = handleReview(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
81
- break;
82
- case "doctor":
83
- status = handleDoctor(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
84
- break;
85
- case "status":
86
- status = handleStatus(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
87
- break;
88
- case "resolve":
89
- status = handleResolve(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
90
- break;
91
- case "update":
92
- shouldCheckForUpdate = false;
93
- status = await handleUpdate(parsed, boolFlag(parsed, "json"));
94
- break;
95
- case undefined:
96
- printHelp();
97
- status = 0;
98
- break;
99
- default:
100
- throw new Error(`Unknown command: ${parsed.command}`);
20
+ if (parsed.command === undefined) {
21
+ process.stdout.write(renderHelp("", VERSION));
22
+ status = 0;
23
+ }
24
+ else {
25
+ const result = await runCommand(parsed);
26
+ status = result.status;
27
+ shouldCheckForUpdate = result.shouldCheckForUpdate;
101
28
  }
102
29
  if (!shouldCheckForUpdate)
103
30
  return status;
104
31
  return maybeNotifyUpdateAndReturn(status, parsed);
105
32
  }
106
33
  catch (error) {
107
- process.stderr.write(`artshelf: ${error.message}\nRun \`artshelf help\` for usage.\n`);
34
+ process.stderr.write(formatCliError(error));
108
35
  return 1;
109
36
  }
110
37
  }
111
- async function maybeNotifyUpdateAndReturn(status, parsed) {
112
- await maybeNotifyAvailableUpdate(parsed);
113
- return status;
114
- }
115
- function handlePut(parsed, ledgerPath, json) {
116
- const path = parsed.positionals[0];
117
- if (!path)
118
- throw new Error("put requires <path>");
119
- const record = prepareRecord({
120
- path,
121
- reason: requiredStringFlag(parsed, "reason"),
122
- ttl: stringFlag(parsed, "ttl"),
123
- retainUntil: stringFlag(parsed, "retain-until"),
124
- manualReview: boolFlag(parsed, "manual-review"),
125
- kind: stringFlag(parsed, "kind"),
126
- cleanup: stringFlag(parsed, "cleanup"),
127
- owner: stringFlag(parsed, "owner"),
128
- labels: arrayFlag(parsed, "label")
129
- });
130
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
131
- appendPreparedRecord(ledgerPath, record);
132
- let ledger;
133
- let registryError;
134
- try {
135
- ledger = registerLedger({ ledgerPath, registryPath });
136
- }
137
- catch (error) {
138
- registryError = error.message;
139
- }
140
- if (json)
141
- return printJson({ ok: true, record, ledgerPath, registryPath, ...(ledger ? { ledger } : {}), ...(registryError ? { registryError } : {}) });
142
- process.stdout.write(`recorded ${record.id}\npath: ${record.path}\nretains until: ${record.retainUntil ?? "manual review"}\nledger: ${ledgerPath}\n`);
143
- if (registryError)
144
- process.stdout.write(`registry warning: ${registryError}\n`);
145
- return 0;
146
- }
147
- function handleLedgers(parsed, json) {
148
- const action = parsed.positionals[0] ?? "list";
149
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
150
- if (action === "help") {
151
- printHelp("ledgers");
152
- return 0;
153
- }
154
- if (action === "add") {
155
- const ledgerPath = normalizeLedgerPath(requiredStringFlag(parsed, "ledger"));
156
- if (!existsSync(ledgerPath))
157
- throw new Error(`Ledger does not exist: ${ledgerPath}`);
158
- const entry = registerLedger({
159
- ledgerPath,
160
- name: stringFlag(parsed, "name"),
161
- scope: stringFlag(parsed, "scope"),
162
- registryPath
163
- });
164
- if (json)
165
- return printJson({ ok: true, registryPath, ledger: entry });
166
- process.stdout.write(`registered ${entry.name}\nledger: ${entry.path}\nregistry: ${registryPath}\n`);
167
- return 0;
168
- }
169
- if (action === "list") {
170
- if (boolFlag(parsed, "plain")) {
171
- const ledgers = listRegisteredLedgers(registryPath);
172
- if (json)
173
- return printJson({ ok: true, registryPath, ledgers });
174
- if (ledgers.length === 0) {
175
- process.stdout.write(`no registered Artshelf ledgers\nregistry: ${registryPath}\n`);
176
- return 0;
177
- }
178
- for (const ledger of ledgers)
179
- process.stdout.write(`${ledger.name} ${ledger.scope} ${ledger.path}\n`);
180
- process.stdout.write(`registry: ${registryPath}\n`);
181
- return 0;
182
- }
183
- const report = buildLedgersReport(registryPath);
184
- if (json) {
185
- printJson(report);
186
- return report.ok ? 0 : 1;
187
- }
188
- printLedgersList(report);
189
- return report.ok ? 0 : 1;
190
- }
191
- throw new Error(`Unknown ledgers action: ${action}`);
192
- }
193
- function buildLedgersReport(registryPath) {
194
- let registryOk = true;
195
- let registryError = null;
196
- let entries = [];
197
- try {
198
- entries = listRegisteredLedgers(registryPath);
199
- }
200
- catch (error) {
201
- registryOk = false;
202
- registryError = error.message;
203
- }
204
- const ledgers = entries.map((entry) => {
205
- const result = validateRegisteredLedger(entry);
206
- const status = result.ok ? "ok" : existsSync(entry.path) ? "invalid" : "missing";
207
- return {
208
- ...entry,
209
- status,
210
- ok: result.ok,
211
- entries: result.entries,
212
- errors: result.errors,
213
- warnings: result.warnings
214
- };
215
- });
216
- const summary = {
217
- ledgers: ledgers.length,
218
- ok: ledgers.filter((ledger) => ledger.status === "ok").length,
219
- stale: ledgers.filter((ledger) => ledger.status === "missing").length,
220
- invalid: ledgers.filter((ledger) => ledger.status === "invalid").length,
221
- warnings: ledgers.reduce((count, ledger) => count + ledger.warnings.length, 0)
222
- };
223
- return {
224
- ok: registryOk && summary.stale === 0 && summary.invalid === 0,
225
- registryPath,
226
- registryExists: existsSync(registryPath),
227
- registryOk,
228
- registryError,
229
- ledgers,
230
- summary
231
- };
232
- }
233
- function printLedgersList(report) {
234
- process.stdout.write(`artshelf ledgers: ${report.ok ? "ok" : "needs attention"}\n`);
235
- 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`);
236
- if (report.registryError)
237
- process.stdout.write(`registry error: ${report.registryError}\n`);
238
- if (report.ledgers.length === 0) {
239
- process.stdout.write("no registered Artshelf ledgers\n");
240
- return;
241
- }
242
- for (const ledger of report.ledgers) {
243
- if (ledger.status === "ok") {
244
- process.stdout.write(`[${ledger.name}] ok ${ledger.scope}: ${ledger.entries} entries, ${ledger.warnings.length} warnings — ${ledger.path}\n`);
245
- }
246
- else {
247
- process.stdout.write(`[${ledger.name}] ${ledger.status} ${ledger.scope}: ${ledger.errors.join("; ")} — ${ledger.path}\n`);
248
- }
249
- }
250
- }
251
- function handleList(parsed, ledgerPath, json) {
252
- if (boolFlag(parsed, "all")) {
253
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
254
- const status = stringFlag(parsed, "status");
255
- const validation = validateRegisteredLedgersOrThrow(registryPath);
256
- if (!validation.ok)
257
- return printRegisteredLedgerValidation(registryPath, validation.results, json);
258
- const results = validation.results.map(({ ledger }) => ({
259
- ledger,
260
- entries: filterRecordsByStatus(readLedger(ledger.path), status)
261
- }));
262
- if (json)
263
- return printJson({ ok: true, registryPath, ...(status ? { status } : {}), ledgers: results });
264
- printLedgerEntries(results, status);
265
- process.stdout.write(`registry: ${registryPath}\n`);
266
- return 0;
267
- }
268
- const status = stringFlag(parsed, "status");
269
- const records = filterRecordsByStatus(readLedger(ledgerPath), status);
270
- if (json)
271
- return printJson({ ok: true, ledgerPath, ...(status ? { status } : {}), entries: records });
272
- if (records.length === 0) {
273
- process.stdout.write(`no artshelf entries${status ? ` with status ${status}` : ""}\nledger: ${ledgerPath}\n`);
274
- return 0;
275
- }
276
- for (const record of records) {
277
- process.stdout.write(`${record.id} ${record.kind} ${record.status} ${record.cleanup} ${record.path} :: ${record.reason}\n`);
278
- }
279
- process.stdout.write(`ledger: ${ledgerPath}\n`);
280
- return 0;
281
- }
282
- function handleFind(parsed, ledgerPath, json) {
283
- if (boolFlag(parsed, "all")) {
284
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
285
- const validation = validateRegisteredLedgersOrThrow(registryPath);
286
- if (!validation.ok)
287
- return printRegisteredLedgerValidation(registryPath, validation.results, json);
288
- const results = validation.results.map(({ ledger }) => ({
289
- ledger,
290
- entries: findRecords(readLedger(ledger.path), {
291
- path: stringFlag(parsed, "path"),
292
- owner: stringFlag(parsed, "owner"),
293
- labels: arrayFlag(parsed, "label"),
294
- status: stringFlag(parsed, "status")
295
- })
296
- }));
297
- if (json)
298
- return printJson({ ok: true, registryPath, ledgers: results });
299
- printLedgerEntries(results);
300
- process.stdout.write(`registry: ${registryPath}\n`);
301
- return 0;
302
- }
303
- const records = findRecords(readLedger(ledgerPath), {
304
- path: stringFlag(parsed, "path"),
305
- owner: stringFlag(parsed, "owner"),
306
- labels: arrayFlag(parsed, "label"),
307
- status: stringFlag(parsed, "status")
308
- });
309
- if (json)
310
- return printJson({ ok: true, ledgerPath, entries: records });
311
- if (records.length === 0) {
312
- process.stdout.write(`no matching artshelf entries\nledger: ${ledgerPath}\n`);
313
- return 0;
314
- }
315
- for (const record of records) {
316
- process.stdout.write(`${record.id} ${record.kind} ${record.status} ${record.cleanup} ${record.path} :: ${record.reason}\n`);
317
- }
318
- process.stdout.write(`ledger: ${ledgerPath}\n`);
319
- return 0;
320
- }
321
- function handleGet(parsed, ledgerPath, json) {
322
- const id = parsed.positionals[0];
323
- if (!id)
324
- throw new Error("get requires <id>");
325
- if (boolFlag(parsed, "all")) {
326
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
327
- const validation = validateRegisteredLedgersOrThrow(registryPath);
328
- if (!validation.ok)
329
- return printRegisteredLedgerValidation(registryPath, validation.results, json);
330
- for (const { ledger } of validation.results) {
331
- const record = readLedger(ledger.path).find((entry) => entry.id === id);
332
- if (record) {
333
- if (json)
334
- return printJson({ ok: true, registryPath, ledger, record });
335
- process.stdout.write(`${record.id} ${record.kind} ${record.status} ${record.cleanup} ${record.path}\nreason: ${record.reason}\nledger: ${ledger.path}\nregistry: ${registryPath}\n`);
336
- return 0;
337
- }
338
- }
339
- throw new Error(`Artshelf record not found: ${id}`);
340
- }
341
- const record = getRecord(readLedger(ledgerPath), id);
342
- if (json)
343
- return printJson({ ok: true, ledgerPath, record });
344
- process.stdout.write(`${record.id} ${record.kind} ${record.status} ${record.cleanup} ${record.path}\nreason: ${record.reason}\nledger: ${ledgerPath}\n`);
345
- return 0;
346
- }
347
- function handleResolve(parsed, ledgerPath, json) {
348
- const id = parsed.positionals[0];
349
- if (!id)
350
- throw new Error("resolve requires <id>");
351
- const record = resolveRecord(ledgerPath, {
352
- id,
353
- status: requiredStringFlag(parsed, "status"),
354
- reason: requiredStringFlag(parsed, "reason")
355
- });
356
- if (json)
357
- return printJson({ ok: true, record, ledgerPath });
358
- process.stdout.write(`resolved ${record.id}\nstatus: ${record.status}\nledger: ${ledgerPath}\n`);
359
- return 0;
360
- }
361
- function handleDue(parsed, ledgerPath, json) {
362
- if (boolFlag(parsed, "all")) {
363
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
364
- const validation = validateRegisteredLedgersOrThrow(registryPath);
365
- if (!validation.ok)
366
- return printRegisteredLedgerValidation(registryPath, validation.results, json);
367
- const results = validation.results.map(({ ledger }) => ({ ledger, entries: dueEntries(readLedger(ledger.path)) }));
368
- if (json)
369
- return printJson({ ok: true, registryPath, ledgers: results });
370
- printDueEntries(results);
371
- process.stdout.write(`registry: ${registryPath}\n`);
372
- return 0;
373
- }
374
- const entries = dueEntries(readLedger(ledgerPath));
375
- const visible = entries.filter((entry) => entry.dueStatus !== "kept");
376
- if (json)
377
- return printJson({ ok: true, ledgerPath, entries });
378
- if (visible.length === 0) {
379
- process.stdout.write(`nothing due\nledger: ${ledgerPath}\n`);
380
- return 0;
381
- }
382
- for (const entry of visible) {
383
- process.stdout.write(`${entry.dueStatus} ${entry.id} ${entry.cleanup} ${entry.path} :: ${entry.reason}\n`);
384
- }
385
- process.stdout.write(`ledger: ${ledgerPath}\n`);
386
- return 0;
387
- }
388
- function handleValidate(parsed, ledgerPath, json) {
389
- if (boolFlag(parsed, "all")) {
390
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
391
- const results = registeredLedgersOrThrow(registryPath).map((ledger) => ({ ledger, result: validateRegisteredLedger(ledger) }));
392
- const ok = results.every((entry) => entry.result.ok);
393
- if (json) {
394
- printJson({ ok, registryPath, ledgers: results });
395
- return ok ? 0 : 1;
396
- }
397
- for (const entry of results) {
398
- 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`);
399
- for (const error of entry.result.errors)
400
- process.stdout.write(`error: ${error}\n`);
401
- for (const warning of entry.result.warnings)
402
- process.stdout.write(`warning: ${warning}\n`);
403
- }
404
- process.stdout.write(`registry: ${registryPath}\n`);
405
- return ok ? 0 : 1;
406
- }
407
- const result = validateLedger(ledgerPath);
408
- if (json)
409
- return printJson({ ledgerPath, ...result });
410
- process.stdout.write(`${result.ok ? "ok" : "invalid"}: ${result.entries} entries, ${result.errors.length} errors, ${result.warnings.length} warnings\n`);
411
- for (const error of result.errors)
412
- process.stdout.write(`error: ${error}\n`);
413
- for (const warning of result.warnings)
414
- process.stdout.write(`warning: ${warning}\n`);
415
- process.stdout.write(`ledger: ${ledgerPath}\n`);
416
- return result.ok ? 0 : 1;
417
- }
418
- function handleCleanup(parsed, ledgerPath, json) {
419
- const dryRun = boolFlag(parsed, "dry-run");
420
- const execute = boolFlag(parsed, "execute");
421
- if (dryRun && execute)
422
- throw new Error("cleanup accepts either --dry-run or --execute, not both");
423
- if (boolFlag(parsed, "all") && execute)
424
- throw new Error("cleanup --all is dry-run only; execute requires an explicit --ledger and reviewed --plan-id");
425
- if (dryRun) {
426
- if (boolFlag(parsed, "all")) {
427
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
428
- const ledgers = registeredLedgersOrThrow(registryPath);
429
- const validations = ledgers.map((ledger) => ({ ledger, result: validateRegisteredLedger(ledger) }));
430
- const ok = validations.every((entry) => entry.result.ok);
431
- if (!ok) {
432
- if (json) {
433
- printJson({ ok, registryPath, ledgers: validations });
434
- return 1;
435
- }
436
- for (const entry of validations.filter((item) => !item.result.ok)) {
437
- process.stdout.write(`invalid ${entry.ledger.name}: ${entry.result.errors.join("; ")}\nledger: ${entry.ledger.path}\n`);
438
- }
439
- process.stdout.write(`registry: ${registryPath}\n`);
440
- return 1;
441
- }
442
- const plans = ledgers.map((ledger) => ({ ledger, plan: createCleanupPlan(ledger.path) }));
443
- if (json)
444
- return printJson({ ok: true, registryPath, plans });
445
- printPlans(plans);
446
- process.stdout.write(`registry: ${registryPath}\n`);
447
- return 0;
448
- }
449
- const plan = createCleanupPlan(ledgerPath);
450
- if (json)
451
- return printJson({ ok: true, plan });
452
- printPlan(plan, ledgerPath);
453
- return 0;
454
- }
455
- if (execute) {
456
- const planId = requiredStringFlag(parsed, "plan-id");
457
- const receipt = executeCleanupPlan(ledgerPath, planId);
458
- if (json)
459
- return printJson({ ok: true, receipt });
460
- process.stdout.write(`receipt ${receipt.planId}: ${receipt.results.length} results\nreceipt: ${receipt.receiptPath}\nledger: ${ledgerPath}\n`);
461
- return 0;
462
- }
463
- throw new Error("cleanup requires --dry-run or --execute");
464
- }
465
- function handleTrash(parsed, ledgerPath, json) {
466
- const action = parsed.positionals[0];
467
- if (!action)
468
- throw new Error("trash requires a subcommand: list or purge");
469
- if (action === "list") {
470
- return handleTrashList(parsed, ledgerPath, json);
471
- }
472
- if (action === "purge") {
473
- return handleTrashPurge(parsed, ledgerPath, json);
474
- }
475
- if (action === "help") {
476
- printHelp("trash");
477
- return 0;
478
- }
479
- throw new Error(`Unknown trash subcommand: ${action}`);
480
- }
481
- function handleTrashList(parsed, ledgerPath, json) {
482
- if (boolFlag(parsed, "all")) {
483
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
484
- const validation = validateRegisteredLedgersOrThrow(registryPath);
485
- if (!validation.ok)
486
- return printRegisteredLedgerValidation(registryPath, validation.results, json);
487
- const results = validation.results.map(({ ledger }) => ({ ledger, entries: listTrashedRecords(ledger.path) }));
488
- if (json)
489
- return printJson({ ok: true, registryPath, ledgers: results });
490
- printTrashListEntries(results);
491
- process.stdout.write(`registry: ${registryPath}\n`);
492
- return 0;
493
- }
494
- const entries = listTrashedRecords(ledgerPath);
495
- if (json)
496
- return printJson({ ok: true, ledgerPath, entries });
497
- if (entries.length === 0) {
498
- process.stdout.write(`no trashed records\nledger: ${ledgerPath}\n`);
499
- return 0;
500
- }
501
- for (const entry of entries) {
502
- process.stdout.write(`${entry.id} age ${entry.age} target ${entry.targetPath} cleaned ${entry.cleanedAt} receipt ${entry.receiptPath} plan ${entry.cleanupPlanId}\n`);
503
- }
504
- process.stdout.write(`ledger: ${ledgerPath}\n`);
505
- return 0;
506
- }
507
- function handleTrashPurge(parsed, ledgerPath, json) {
508
- const execute = boolFlag(parsed, "execute");
509
- const dryRun = boolFlag(parsed, "dry-run");
510
- if (dryRun && execute)
511
- throw new Error("trash purge accepts either --dry-run or --execute, not both");
512
- if (boolFlag(parsed, "all")) {
513
- throw new Error("trash purge --all is not supported; scope the purge to one --ledger and review the plan id before execute");
514
- }
515
- if (!dryRun && !execute)
516
- throw new Error("trash purge requires either --dry-run or --execute");
517
- if (execute) {
518
- const planId = requiredStringFlag(parsed, "plan-id");
519
- const receipt = executeTrashPurgePlan(ledgerPath, planId);
520
- if (json)
521
- return printJson({ ok: true, receipt });
522
- process.stdout.write(`trash receipt ${receipt.purgePlanId}: ${receipt.results.length} results\nreceipt: ${receipt.receiptPath}\nledger: ${ledgerPath}\n`);
523
- return 0;
524
- }
525
- const olderThan = requiredStringFlag(parsed, "older-than");
526
- const plan = createTrashPurgePlan(ledgerPath, olderThan);
527
- if (json)
528
- return printJson({ ok: true, plan });
529
- if (plan.entries.length === 0) {
530
- process.stdout.write(`trash purge plan ${plan.purgePlanId}: no matching trashed records\nledger: ${ledgerPath}\n`);
531
- return 0;
532
- }
533
- process.stdout.write(`trash purge plan ${plan.purgePlanId}: ${plan.entries.length} entries, ${plan.skipped.length} skipped\n`);
534
- process.stdout.write(`plan: ${plan.planPath ?? "not-created"}\nledger: ${ledgerPath}\n`);
535
- return 0;
536
- }
537
- function handleReview(parsed, ledgerPath, json) {
538
- const agent = boolFlag(parsed, "agent");
539
- if (boolFlag(parsed, "all")) {
540
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
541
- const results = registeredLedgersOrThrow(registryPath).map((ledger) => reviewLedger(ledger));
542
- const ok = results.every((entry) => entry.validate.ok);
543
- const summary = summarizeReview(results);
544
- if (agent) {
545
- printCompactJson(buildReviewAgentPacketAll(results, summary, registryPath));
546
- return ok ? 0 : 1;
547
- }
548
- const nextAction = reviewNextAction(summary, "all");
549
- if (json) {
550
- printJson({ ok, registryPath, summary, nextAction, ledgers: results });
551
- return ok ? 0 : 1;
552
- }
553
- printReviewAll(results, summary, nextAction, registryPath);
554
- return ok ? 0 : 1;
555
- }
556
- const result = reviewLedger({ name: "current", path: ledgerPath, scope: "other", createdAt: "", updatedAt: "" }, false);
557
- if (agent) {
558
- printCompactJson(buildReviewAgentPacketSingle(result, ledgerPath));
559
- return result.validate.ok ? 0 : 1;
560
- }
561
- if (json) {
562
- printJson({ ok: result.validate.ok, ledger: result });
563
- return result.validate.ok ? 0 : 1;
564
- }
565
- printReview([result]);
566
- return result.validate.ok ? 0 : 1;
567
- }
568
- function handleDoctor(parsed, ledgerPath, json) {
569
- const report = buildDoctorReport(ledgerPath, normalizeRegistryPath(stringFlag(parsed, "registry")));
570
- if (boolFlag(parsed, "agent")) {
571
- printCompactJson(buildDoctorAgentPacket(report));
572
- return report.ok ? 0 : 1;
573
- }
574
- if (json) {
575
- printJson(report);
576
- return report.ok ? 0 : 1;
577
- }
578
- printDoctor(report);
579
- return report.ok ? 0 : 1;
580
- }
581
- function buildDoctorReport(ledgerPath, registryPath) {
582
- const errors = [];
583
- let registryOk = true;
584
- let registryError = null;
585
- let entries = [];
586
- try {
587
- entries = listRegisteredLedgers(registryPath);
588
- }
589
- catch (error) {
590
- registryOk = false;
591
- registryError = error.message;
592
- errors.push(`registry could not be read: ${registryPath} (${registryError})`);
593
- }
594
- const ledgers = entries.map((entry) => {
595
- const result = validateRegisteredLedger(entry);
596
- const status = result.ok ? "ok" : existsSync(entry.path) ? "invalid" : "missing";
597
- if (!result.ok) {
598
- for (const message of result.errors)
599
- errors.push(`${entry.name}: ${message}`);
600
- }
601
- return {
602
- name: entry.name,
603
- path: entry.path,
604
- scope: entry.scope,
605
- status,
606
- ok: result.ok,
607
- entries: result.entries,
608
- errors: result.errors,
609
- warnings: result.warnings
610
- };
611
- });
612
- const summary = {
613
- ledgers: ledgers.length,
614
- ok: ledgers.filter((ledger) => ledger.status === "ok").length,
615
- stale: ledgers.filter((ledger) => ledger.status === "missing").length,
616
- invalid: ledgers.filter((ledger) => ledger.status === "invalid").length,
617
- warnings: ledgers.reduce((count, ledger) => count + ledger.warnings.length, 0)
618
- };
619
- return {
620
- ok: registryOk && summary.stale === 0 && summary.invalid === 0,
621
- version: VERSION,
622
- node: process.version,
623
- ledgerPath,
624
- ledgerExists: existsSync(ledgerPath),
625
- registryPath,
626
- registryExists: existsSync(registryPath),
627
- registryOk,
628
- registryError,
629
- ledgers,
630
- summary,
631
- cleanupSafety: {
632
- executeRequiresLedgerAndPlanId: true,
633
- globalExecuteRefused: true,
634
- deleteRefusedInV1: true,
635
- dryRunBeforeMutation: true
636
- },
637
- errors
638
- };
639
- }
640
- // Actionable categories only — ok ledgers are healthy states, never attention.
641
- // Order is fixed so the packet is byte-for-byte deterministic. Warnings surface
642
- // even when health is ok (they never fail the machine), mirroring status attention.
643
- const DOCTOR_ATTENTION_CATEGORIES = ["stale", "invalid", "warnings"];
644
- function doctorAttention(summary) {
645
- return DOCTOR_ATTENTION_CATEGORIES.filter((key) => summary[key] > 0);
646
- }
647
- function doctorNextAction(blockers, summary) {
648
- if (blockers.length > 0) {
649
- return `repair ${blockers.length} registry/ledger issue(s) above, then re-run \`artshelf doctor\``;
650
- }
651
- if (summary.warnings > 0) {
652
- return `healthy, but ${summary.warnings} warning(s) noted — run \`artshelf validate --all\` to inspect; nothing is auto-executed`;
653
- }
654
- return "artshelf is healthy on this machine — cleanup safety enforced; no action needed";
655
- }
656
- function buildDoctorAgentPacket(report) {
657
- const blockers = [];
658
- if (report.registryError)
659
- blockers.push(`registry unreadable: ${report.registryError}`);
660
- for (const ledger of report.ledgers) {
661
- if (ledger.status !== "ok") {
662
- blockers.push(`${ledger.name} ${ledger.status}${ledger.errors.length ? `: ${ledger.errors[0]}` : ""}`);
663
- }
664
- }
665
- return {
666
- schemaVersion: 1,
667
- command: "doctor",
668
- health: report.ok ? "ok" : "attention",
669
- version: report.version,
670
- node: report.node,
671
- ledgerPath: report.ledgerPath,
672
- registry: { path: report.registryPath, exists: report.registryExists, ok: report.registryOk, error: report.registryError },
673
- ledgers: {
674
- total: report.summary.ledgers,
675
- ok: report.summary.ok,
676
- stale: report.summary.stale,
677
- invalid: report.summary.invalid,
678
- warnings: report.summary.warnings
679
- },
680
- attention: doctorAttention(report.summary),
681
- blockers,
682
- cleanupSafety: report.cleanupSafety,
683
- nextAction: doctorNextAction(blockers, report.summary),
684
- verification: `artshelf doctor --agent --registry ${report.registryPath}`
685
- };
686
- }
687
- // Human render (NGX-396): a scannable left-column glyph so attention state is
688
- // obvious at a glance — ✓ clear, ⚠ needs attention. Plain Unicode (no ANSI
689
- // color) keeps redirected/piped human output clean, and the `--agent`/`--json`
690
- // renders never carry glyphs (those stay machine contracts).
691
- const HUMAN_OK_GLYPH = "✓";
692
- const HUMAN_ATTENTION_GLYPH = "⚠";
693
- function attentionGlyph(needsAttention) {
694
- return needsAttention ? HUMAN_ATTENTION_GLYPH : HUMAN_OK_GLYPH;
695
- }
696
- function printDoctor(report) {
697
- process.stdout.write(`artshelf ${report.version} (node ${report.node})\n`);
698
- process.stdout.write(`${attentionGlyph(!report.ok)} health: ${report.ok ? "ok" : "needs attention"}\n`);
699
- process.stdout.write(`ledger: ${report.ledgerPath}${report.ledgerExists ? "" : " (absent)"}\n`);
700
- process.stdout.write(`registry: ${report.registryPath}${report.registryExists ? "" : " (absent)"}\n`);
701
- if (report.registryError)
702
- process.stdout.write(`registry error: ${report.registryError}\n`);
703
- process.stdout.write(`registered ledgers: ${report.summary.ledgers} (${report.summary.ok} ok, ${report.summary.stale} stale, ${report.summary.invalid} invalid)\n`);
704
- for (const ledger of report.ledgers) {
705
- process.stdout.write(` ${attentionGlyph(ledger.status !== "ok")} ${ledger.status} ${ledger.name} ${ledger.path}\n`);
706
- for (const message of ledger.errors)
707
- process.stdout.write(` error: ${message}\n`);
708
- }
709
- 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");
710
- if (!report.ok) {
711
- for (const message of report.errors)
712
- process.stdout.write(`error: ${message}\n`);
713
- }
714
- }
715
- function handleStatus(parsed, ledgerPath, json) {
716
- const agent = boolFlag(parsed, "agent");
717
- if (boolFlag(parsed, "all")) {
718
- const report = buildStatusReport(normalizeRegistryPath(stringFlag(parsed, "registry")));
719
- if (agent) {
720
- printCompactJson(buildStatusAgentPacketAll(report));
721
- return report.ok ? 0 : 1;
722
- }
723
- if (json) {
724
- printJson(report);
725
- return report.ok ? 0 : 1;
726
- }
727
- printStatusAll(report);
728
- return report.ok ? 0 : 1;
729
- }
730
- const ledger = statusLedger({ name: "current", path: ledgerPath, scope: "other", createdAt: "", updatedAt: "" }, false);
731
- if (agent) {
732
- printCompactJson(buildStatusAgentPacketSingle(ledger, ledgerPath));
733
- return ledger.ok ? 0 : 1;
734
- }
735
- if (json) {
736
- printJson({ ok: ledger.ok, ledger });
737
- return ledger.ok ? 0 : 1;
738
- }
739
- printStatusSingle(ledger);
740
- return ledger.ok ? 0 : 1;
741
- }
742
- function buildStatusReport(registryPath) {
743
- let registryOk = true;
744
- let registryError = null;
745
- let entries = [];
746
- try {
747
- entries = listRegisteredLedgers(registryPath);
748
- }
749
- catch (error) {
750
- registryOk = false;
751
- registryError = error.message;
752
- }
753
- const ledgers = entries.map((entry) => statusLedger(entry));
754
- const totals = {
755
- ledgers: ledgers.length,
756
- ok: ledgers.filter((ledger) => ledger.status === "ok").length,
757
- stale: ledgers.filter((ledger) => ledger.status === "missing").length,
758
- invalid: ledgers.filter((ledger) => ledger.status === "invalid").length,
759
- active: sumStatusCounts(ledgers, "active"),
760
- due: sumStatusCounts(ledgers, "due"),
761
- manualReview: sumStatusCounts(ledgers, "manualReview"),
762
- missingPath: sumStatusCounts(ledgers, "missingPath"),
763
- kept: sumStatusCounts(ledgers, "kept"),
764
- pendingCleanup: sumStatusCounts(ledgers, "pendingCleanup")
765
- };
766
- return {
767
- ok: registryOk && totals.stale === 0 && totals.invalid === 0,
768
- registryPath,
769
- registryExists: existsSync(registryPath),
770
- registryOk,
771
- registryError,
772
- ledgers,
773
- totals
774
- };
775
- }
776
- function statusLedger(ledger, registered = true) {
777
- const validate = registered ? validateRegisteredLedger(ledger) : validateLedger(ledger.path);
778
- if (!validate.ok) {
779
- return {
780
- name: ledger.name,
781
- path: ledger.path,
782
- scope: ledger.scope,
783
- status: existsSync(ledger.path) ? "invalid" : "missing",
784
- ok: false,
785
- counts: emptyStatusCounts(),
786
- errors: validate.errors
787
- };
788
- }
789
- const records = readLedger(ledger.path);
790
- const due = dueEntries(records);
791
- const counts = {
792
- active: records.filter((record) => record.status === "active").length,
793
- due: due.filter((entry) => entry.dueStatus === "due").length,
794
- manualReview: due.filter((entry) => entry.dueStatus === "manual-review").length,
795
- missingPath: due.filter((entry) => entry.dueStatus === "missing-path").length,
796
- kept: due.filter((entry) => entry.dueStatus === "kept").length,
797
- pendingCleanup: previewCleanupPlan(ledger.path).entries.length
798
- };
799
- return {
800
- name: ledger.name,
801
- path: ledger.path,
802
- scope: ledger.scope,
803
- status: "ok",
804
- ok: true,
805
- counts,
806
- errors: []
807
- };
808
- }
809
- function emptyStatusCounts() {
810
- return { active: 0, due: 0, manualReview: 0, missingPath: 0, kept: 0, pendingCleanup: 0 };
811
- }
812
- function sumStatusCounts(ledgers, key) {
813
- return ledgers.reduce((total, ledger) => total + ledger.counts[key], 0);
814
- }
815
- function formatStatusCounts(counts) {
816
- return `active ${counts.active} · due ${counts.due} · manual-review ${counts.manualReview} · missing ${counts.missingPath} · kept ${counts.kept} · pending ${counts.pendingCleanup}`;
817
- }
818
- // Actionable categories only — active and kept are healthy states, never
819
- // attention. Order is fixed so the packet is byte-for-byte deterministic.
820
- const STATUS_ATTENTION_CATEGORIES = ["due", "manualReview", "missingPath", "pendingCleanup"];
821
- function statusAttention(counts) {
822
- return STATUS_ATTENTION_CATEGORIES.filter((key) => counts[key] > 0);
823
- }
824
- function statusCommand(scope, command, ledgerPath) {
825
- if (scope === "all")
826
- return `artshelf ${command} --all`;
827
- return ledgerPath ? `artshelf ${command} --ledger ${ledgerPath}` : `artshelf ${command}`;
828
- }
829
- function statusNextAction(blockers, counts, scope, ledgerPath) {
830
- if (blockers.length > 0) {
831
- const verify = statusCommand(scope, "status", ledgerPath);
832
- return `repair ${blockers.length} broken ledger(s) above, then re-run \`${verify}\``;
833
- }
834
- const review = statusCommand(scope, "review", ledgerPath);
835
- if (counts.pendingCleanup > 0 || counts.due > 0) {
836
- return `run \`${review}\` to preview cleanup plans; nothing is auto-executed`;
837
- }
838
- if (counts.manualReview > 0) {
839
- return `run \`${review}\` to inspect manual-review records; nothing is auto-executed`;
840
- }
841
- if (counts.missingPath > 0) {
842
- return "inspect missing-path records and `artshelf resolve` the ones no longer needed; nothing is auto-executable";
843
- }
844
- return "nothing due — no broken ledgers and no due, manual-review, missing-path, or pending cleanup entries";
845
- }
846
- function buildStatusAgentPacketAll(report) {
847
- const blockers = [];
848
- if (report.registryError)
849
- blockers.push(`registry unreadable: ${report.registryError}`);
850
- for (const ledger of report.ledgers) {
851
- if (ledger.status !== "ok") {
852
- blockers.push(`${ledger.name} ${ledger.status}${ledger.errors.length ? `: ${ledger.errors[0]}` : ""}`);
853
- }
854
- }
855
- const counts = {
856
- active: report.totals.active,
857
- due: report.totals.due,
858
- manualReview: report.totals.manualReview,
859
- missingPath: report.totals.missingPath,
860
- kept: report.totals.kept,
861
- pendingCleanup: report.totals.pendingCleanup
862
- };
863
- return {
864
- schemaVersion: 1,
865
- command: "status",
866
- scope: "all",
867
- health: report.ok ? "ok" : "attention",
868
- registry: { path: report.registryPath, exists: report.registryExists, ok: report.registryOk, error: report.registryError },
869
- ledgers: { total: report.totals.ledgers, ok: report.totals.ok, stale: report.totals.stale, invalid: report.totals.invalid },
870
- counts,
871
- attention: statusAttention(counts),
872
- blockers,
873
- nextAction: statusNextAction(blockers, counts, "all"),
874
- verification: `artshelf status --all --agent --registry ${report.registryPath}`
875
- };
876
- }
877
- function buildStatusAgentPacketSingle(ledger, ledgerPath) {
878
- const blockers = ledger.ok
879
- ? []
880
- : [`${ledger.status}${ledger.errors.length ? `: ${ledger.errors[0]}` : ""}`];
881
- return {
882
- schemaVersion: 1,
883
- command: "status",
884
- scope: "single",
885
- health: ledger.ok ? "ok" : "attention",
886
- ledgerPath,
887
- counts: ledger.counts,
888
- attention: statusAttention(ledger.counts),
889
- blockers,
890
- nextAction: statusNextAction(blockers, ledger.counts, "single", ledgerPath),
891
- verification: `artshelf status --agent --ledger ${ledgerPath}`
892
- };
893
- }
894
- function printStatusAll(report) {
895
- const anyActionable = report.ledgers.some((ledger) => statusAttention(ledger.counts).length > 0);
896
- process.stdout.write(`${attentionGlyph(!report.ok || anyActionable)} artshelf status: ${report.ok ? "ok" : "needs attention"}\n`);
897
- 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`);
898
- if (report.registryError)
899
- process.stdout.write(`registry error: ${report.registryError}\n`);
900
- for (const ledger of report.ledgers) {
901
- if (ledger.status === "ok") {
902
- process.stdout.write(`${attentionGlyph(statusAttention(ledger.counts).length > 0)} [${ledger.name}] ${formatStatusCounts(ledger.counts)}\n`);
903
- }
904
- else {
905
- process.stdout.write(`${HUMAN_ATTENTION_GLYPH} [${ledger.name}] ${ledger.status}: ${ledger.errors.join("; ")}\n`);
906
- }
907
- }
908
- process.stdout.write(`total: ${formatStatusCounts(report.totals)}\n`);
909
- }
910
- function printStatusSingle(ledger) {
911
- const needsAttention = !ledger.ok || statusAttention(ledger.counts).length > 0;
912
- process.stdout.write(`${attentionGlyph(needsAttention)} artshelf status: ${ledger.ok ? "ok" : ledger.status}\n`);
913
- process.stdout.write(`ledger: ${ledger.path}\n`);
914
- if (ledger.ok) {
915
- process.stdout.write(`${formatStatusCounts(ledger.counts)}\n`);
916
- }
917
- else {
918
- for (const message of ledger.errors)
919
- process.stdout.write(`error: ${message}\n`);
920
- }
921
- }
922
- async function handleUpdate(parsed, json) {
923
- if (parsed.positionals.length > 0)
924
- throw new Error("update does not accept positional arguments");
925
- const info = await getUpdateInfo({ force: true });
926
- if (!info)
927
- throw new Error("Could not check npm for the latest Artshelf version");
928
- if (!info.updateAvailable) {
929
- if (json)
930
- return printJson({ ok: true, updated: false, current: info.current, latest: info.latest });
931
- process.stdout.write(`artshelf is already up to date: v${info.current}\n`);
932
- return 0;
933
- }
934
- if (process.env.ARTSHELF_UPDATE_DRY_RUN === "1") {
935
- if (json) {
936
- return printJson({
937
- ok: true,
938
- updated: false,
939
- dryRun: true,
940
- current: info.current,
941
- latest: info.latest,
942
- command: ["npm", "install", "-g", `${PACKAGE_NAME}@latest`]
943
- });
944
- }
945
- process.stdout.write(`A new version of artshelf is available: v${info.current} -> v${info.latest}\n`);
946
- process.stdout.write(`Dry run: would run "npm install -g ${PACKAGE_NAME}@latest"\n`);
947
- return 0;
948
- }
949
- if (!json) {
950
- process.stdout.write(`A new version of artshelf is available: v${info.current} -> v${info.latest}\n`);
951
- process.stdout.write(`Updating with "npm install -g ${PACKAGE_NAME}@latest"...\n`);
952
- }
953
- const result = json
954
- ? spawnSync("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], { encoding: "utf8" })
955
- : spawnSync("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], { stdio: "inherit" });
956
- const status = result.status ?? 1;
957
- const spawnError = result.error instanceof Error ? result.error.message : "";
958
- if (json) {
959
- const stderr = typeof result.stderr === "string" ? result.stderr : "";
960
- printJson({
961
- ok: status === 0,
962
- updated: status === 0,
963
- current: info.current,
964
- latest: info.latest,
965
- stdout: typeof result.stdout === "string" ? result.stdout : "",
966
- stderr: appendOutputMessage(stderr, spawnError)
967
- });
968
- return status;
969
- }
970
- if (spawnError)
971
- process.stderr.write(`Update failed: ${spawnError}\n`);
972
- if (status === 0)
973
- process.stdout.write(`artshelf updated to v${info.latest}\n`);
974
- return status;
975
- }
976
- function appendOutputMessage(output, message) {
977
- if (!message)
978
- return output;
979
- if (!output)
980
- return message;
981
- return `${output}${output.endsWith("\n") ? "" : "\n"}${message}`;
982
- }
983
- async function maybeNotifyAvailableUpdate(parsed) {
984
- if (process.env.ARTSHELF_NO_UPDATE_CHECK === "1")
985
- return;
986
- if (parsed.command === "update")
987
- return;
988
- const info = await getUpdateInfo({ force: false });
989
- if (!info?.updateAvailable)
990
- return;
991
- process.stderr.write(`A new version of artshelf is available: v${info.current} -> v${info.latest}\n`);
992
- process.stderr.write(`Run "artshelf update" to update npm installs\n`);
993
- }
994
- async function getUpdateInfo(options) {
995
- const latest = await getLatestVersion(options);
996
- if (!latest)
997
- return null;
998
- return {
999
- current: VERSION,
1000
- latest,
1001
- updateAvailable: compareVersions(latest, VERSION) > 0
1002
- };
1003
- }
1004
- async function getLatestVersion(options) {
1005
- const override = process.env.ARTSHELF_LATEST_VERSION;
1006
- if (override)
1007
- return normalizeVersion(override);
1008
- if (!options.force) {
1009
- const cached = readUpdateCache();
1010
- if (cached)
1011
- return cached.latest;
1012
- }
1013
- const latest = await fetchLatestNpmVersion();
1014
- writeUpdateCache(latest);
1015
- return latest;
1016
- }
1017
- function readUpdateCache() {
1018
- const ttl = Number(process.env.ARTSHELF_UPDATE_CHECK_TTL_MS ?? UPDATE_CHECK_TTL_MS);
1019
- if (ttl < 0)
1020
- return null;
1021
- const cachePath = updateCachePath();
1022
- if (!existsSync(cachePath))
1023
- return null;
1024
- try {
1025
- const cache = JSON.parse(readFileSync(cachePath, "utf8"));
1026
- if (cache.latest !== null && typeof cache.latest !== "string")
1027
- return null;
1028
- if (typeof cache.checkedAt !== "number")
1029
- return null;
1030
- if (Date.now() - cache.checkedAt > ttl)
1031
- return null;
1032
- return { latest: cache.latest === null ? null : normalizeVersion(cache.latest) };
1033
- }
1034
- catch {
1035
- return null;
1036
- }
1037
- }
1038
- function writeUpdateCache(latest) {
1039
- try {
1040
- const cachePath = updateCachePath();
1041
- const dir = dirname(cachePath);
1042
- if (dir) {
1043
- mkdirSync(dir, { recursive: true });
1044
- writeFileSync(cachePath, `${JSON.stringify({ latest, checkedAt: Date.now() }, null, 2)}\n`);
1045
- }
1046
- }
1047
- catch {
1048
- // Update checks should never affect normal CLI behavior.
1049
- }
1050
- }
1051
- async function fetchLatestNpmVersion() {
1052
- const controller = new AbortController();
1053
- const timeout = setTimeout(() => controller.abort(), 750);
1054
- try {
1055
- const response = await fetch(NPM_REGISTRY_URL, {
1056
- signal: controller.signal,
1057
- headers: { accept: "application/json", "user-agent": `artshelf/${VERSION}` }
1058
- });
1059
- if (!response.ok)
1060
- return null;
1061
- const body = await response.json();
1062
- if (!body || typeof body !== "object" || typeof body.version !== "string")
1063
- return null;
1064
- return normalizeVersion(body.version);
1065
- }
1066
- catch {
1067
- return null;
1068
- }
1069
- finally {
1070
- clearTimeout(timeout);
1071
- }
1072
- }
1073
- function updateCachePath() {
1074
- return process.env.ARTSHELF_UPDATE_CACHE ?? join(homedir(), ".artshelf", "update-check.json");
1075
- }
1076
- function normalizeVersion(version) {
1077
- return version.trim().replace(/^v/i, "");
1078
- }
1079
- function compareVersions(left, right) {
1080
- const a = parseVersion(left);
1081
- const b = parseVersion(right);
1082
- for (let index = 0; index < Math.max(a.numbers.length, b.numbers.length); index += 1) {
1083
- const diff = (a.numbers[index] ?? 0) - (b.numbers[index] ?? 0);
1084
- if (diff !== 0)
1085
- return diff;
1086
- }
1087
- if (a.prerelease === b.prerelease)
1088
- return 0;
1089
- if (!a.prerelease)
1090
- return 1;
1091
- if (!b.prerelease)
1092
- return -1;
1093
- return a.prerelease.localeCompare(b.prerelease);
1094
- }
1095
- function parseVersion(version) {
1096
- const [main = "", prerelease = ""] = normalizeVersion(version).split("-", 2);
1097
- return {
1098
- numbers: main.split(".").map((part) => {
1099
- const parsed = Number.parseInt(part, 10);
1100
- return Number.isFinite(parsed) ? parsed : 0;
1101
- }),
1102
- prerelease
1103
- };
1104
- }
1105
38
  function parseArgs(argv) {
1106
39
  const [command, ...rest] = argv;
1107
- const flags = new Map();
1108
40
  const positionals = [];
41
+ const flags = new Map();
1109
42
  for (let index = 0; index < rest.length; index += 1) {
1110
- const arg = rest[index];
1111
- if (!arg)
43
+ const token = rest[index];
44
+ if (!token)
1112
45
  continue;
1113
- if (arg === "-h") {
46
+ if (token === "-h") {
1114
47
  flags.set("help", true);
1115
48
  continue;
1116
49
  }
1117
- if (arg === "-v") {
50
+ if (token === "-v") {
1118
51
  flags.set("version", true);
1119
52
  continue;
1120
53
  }
1121
- if (!arg.startsWith("--")) {
1122
- positionals.push(arg);
1123
- continue;
1124
- }
1125
- const name = arg.slice(2);
1126
- if (BOOLEAN_FLAGS.has(name)) {
1127
- flags.set(name, true);
54
+ if (token.startsWith("--")) {
55
+ const name = token.slice(2);
56
+ if (BOOLEAN_FLAGS.has(name)) {
57
+ flags.set(name, true);
58
+ continue;
59
+ }
60
+ if (!VALUE_FLAGS.has(name))
61
+ throw new Error(`Unknown flag: --${name}`);
62
+ const value = rest[index + 1];
63
+ if (!value || value.startsWith("--"))
64
+ throw new Error(`Missing value for --${name}`);
65
+ index += 1;
66
+ if (name === "label") {
67
+ const previous = flags.get(name);
68
+ flags.set(name, [...(Array.isArray(previous) ? previous : []), value]);
69
+ }
70
+ else {
71
+ flags.set(name, value);
72
+ }
1128
73
  continue;
1129
74
  }
1130
- if (!VALUE_FLAGS.has(name))
1131
- throw new Error(`Unknown flag: --${name}`);
1132
- const value = rest[index + 1];
1133
- if (!value || value.startsWith("--"))
1134
- throw new Error(`Missing value for --${name}`);
1135
- index += 1;
1136
- if (name === "label") {
1137
- const current = flags.get(name);
1138
- flags.set(name, [...(Array.isArray(current) ? current : []), value]);
1139
- }
1140
- else {
1141
- flags.set(name, value);
1142
- }
75
+ positionals.push(token);
1143
76
  }
1144
77
  return { command, positionals, flags };
1145
78
  }
1146
- function requiredStringFlag(parsed, name) {
1147
- const value = stringFlag(parsed, name);
1148
- if (!value)
1149
- throw new Error(`Missing required --${name}`);
1150
- return value;
1151
- }
1152
- function stringFlag(parsed, name) {
1153
- const value = parsed.flags.get(name);
1154
- return typeof value === "string" ? value : undefined;
1155
- }
1156
- function boolFlag(parsed, name) {
1157
- return parsed.flags.get(name) === true;
1158
- }
1159
- function arrayFlag(parsed, name) {
1160
- const value = parsed.flags.get(name);
1161
- return Array.isArray(value) ? value : [];
1162
- }
1163
- function printJson(value) {
1164
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
1165
- return 0;
1166
- }
1167
- // Agent/compact surface: a single minified JSON line. The default `--json`
1168
- // stays pretty-printed for audit/debug; agent packets optimize for tokens.
1169
- function printCompactJson(value) {
1170
- process.stdout.write(`${JSON.stringify(value)}\n`);
1171
- return 0;
1172
- }
1173
- function registeredLedgersOrThrow(registryPath) {
1174
- const ledgers = listRegisteredLedgers(registryPath);
1175
- if (ledgers.length === 0)
1176
- throw new Error("No registered Artshelf ledgers. Run `artshelf ledgers add --ledger <path>` first.");
1177
- return ledgers;
1178
- }
1179
- function validateRegisteredLedgersOrThrow(registryPath) {
1180
- const results = registeredLedgersOrThrow(registryPath).map((ledger) => ({ ledger, result: validateRegisteredLedger(ledger) }));
1181
- return { ok: results.every((entry) => entry.result.ok), results };
1182
- }
1183
- function printRegisteredLedgerValidation(registryPath, results, json) {
1184
- if (json) {
1185
- printJson({ ok: false, registryPath, ledgers: results });
1186
- return 1;
1187
- }
1188
- for (const entry of results.filter((item) => !item.result.ok)) {
1189
- process.stdout.write(`invalid ${entry.ledger.name}: ${entry.result.errors.join("; ")}\nledger: ${entry.ledger.path}\n`);
1190
- }
1191
- process.stdout.write(`registry: ${registryPath}\n`);
1192
- return 1;
1193
- }
1194
- function validateRegisteredLedger(ledger) {
1195
- if (!existsSync(ledger.path)) {
1196
- return {
1197
- ok: false,
1198
- errors: [`registered ledger is missing: ${ledger.path}`],
1199
- warnings: [],
1200
- entries: 0
1201
- };
1202
- }
1203
- return validateLedger(ledger.path);
1204
- }
1205
- function reviewLedger(ledger, registered = true) {
1206
- const validate = registered ? validateRegisteredLedger(ledger) : validateLedger(ledger.path);
1207
- if (!validate.ok) {
1208
- return {
1209
- ledger,
1210
- validate,
1211
- due: [],
1212
- plan: emptyReviewPlan(ledger.path)
1213
- };
1214
- }
1215
- return {
1216
- ledger,
1217
- validate,
1218
- due: dueEntries(readLedger(ledger.path)),
1219
- plan: previewCleanupPlan(ledger.path)
1220
- };
1221
- }
1222
- function emptyReviewPlan(ledgerPath) {
1223
- return {
1224
- planId: "not-created",
1225
- generatedAt: "",
1226
- ledgerPath,
1227
- entries: [],
1228
- skipped: [],
1229
- planPath: null
1230
- };
1231
- }
1232
- function printLedgerEntries(results, status) {
1233
- const total = results.reduce((count, result) => count + result.entries.length, 0);
1234
- if (total === 0) {
1235
- process.stdout.write(`no artshelf entries${status ? ` with status ${status}` : ""}\n`);
1236
- return;
1237
- }
1238
- for (const result of results) {
1239
- if (result.entries.length === 0)
1240
- continue;
1241
- process.stdout.write(`\n[${result.ledger.name}] ${result.ledger.path}\n`);
1242
- for (const record of result.entries) {
1243
- process.stdout.write(`${record.id} ${record.kind} ${record.status} ${record.cleanup} ${record.path} :: ${record.reason}\n`);
1244
- }
1245
- }
1246
- }
1247
- function printDueEntries(results) {
1248
- const visible = results.flatMap((result) => result.entries.filter((entry) => entry.dueStatus !== "kept").map((entry) => ({ ledger: result.ledger, entry })));
1249
- if (visible.length === 0) {
1250
- process.stdout.write("nothing due\n");
1251
- return;
1252
- }
1253
- for (const item of visible) {
1254
- process.stdout.write(`${item.entry.dueStatus} ${item.entry.id} ${item.entry.cleanup} ${item.entry.path} :: ${item.entry.reason}\nledger: ${item.ledger.path}\n`);
1255
- }
1256
- }
1257
- function printPlans(results) {
1258
- for (const result of results) {
1259
- process.stdout.write(`plan ${result.plan.planId} [${result.ledger.name}]: ${result.plan.entries.length} entries, ${result.plan.skipped.length} skipped\n`);
1260
- process.stdout.write(`plan: ${result.plan.planPath ?? "not created"}\nledger: ${result.ledger.path}\n`);
1261
- }
1262
- }
1263
- function printPlan(plan, ledgerPath) {
1264
- process.stdout.write(`plan ${plan.planId}: ${plan.entries.length} entries, ${plan.skipped.length} skipped\n`);
1265
- process.stdout.write(`plan: ${plan.planPath ?? "not created"}\nledger: ${ledgerPath}\n`);
1266
- }
1267
- function printTrashListEntries(results) {
1268
- const total = results.reduce((count, result) => count + result.entries.length, 0);
1269
- if (total === 0) {
1270
- process.stdout.write("no trashed records\n");
1271
- return;
1272
- }
1273
- for (const result of results) {
1274
- if (result.entries.length === 0)
1275
- continue;
1276
- process.stdout.write(`\n[${result.ledger.name}] ${result.ledger.path}\n`);
1277
- for (const entry of result.entries) {
1278
- process.stdout.write(`trash ${entry.id} ${entry.age} ${entry.cleanedAt} ${entry.targetPath} -> ${entry.receiptPath} (${entry.cleanupPlanId})\n`);
1279
- }
1280
- }
1281
- }
1282
- function summarizeReview(results) {
1283
- const summary = {
1284
- ledgers: results.length,
1285
- ok: 0,
1286
- invalid: 0,
1287
- stale: 0,
1288
- affected: 0,
1289
- due: 0,
1290
- manualReview: 0,
1291
- missingPath: 0,
1292
- executable: 0,
1293
- skipped: 0,
1294
- previewPlanIds: []
1295
- };
1296
- for (const result of results) {
1297
- if (result.validate.ok) {
1298
- summary.ok += 1;
1299
- }
1300
- else if (existsSync(result.ledger.path)) {
1301
- summary.invalid += 1;
1302
- }
1303
- else {
1304
- summary.stale += 1;
1305
- }
1306
- const due = result.due.filter((entry) => entry.dueStatus === "due").length;
1307
- const manualReview = result.due.filter((entry) => entry.dueStatus === "manual-review").length;
1308
- const missingPath = result.due.filter((entry) => entry.dueStatus === "missing-path").length;
1309
- summary.due += due;
1310
- summary.manualReview += manualReview;
1311
- summary.missingPath += missingPath;
1312
- summary.executable += result.plan.entries.length;
1313
- summary.skipped += result.plan.skipped.length;
1314
- if (result.plan.planId !== "not-created")
1315
- summary.previewPlanIds.push(result.plan.planId);
1316
- if (!result.validate.ok || due + manualReview + missingPath > 0 || result.plan.entries.length > 0) {
1317
- summary.affected += 1;
1318
- }
1319
- }
1320
- return summary;
1321
- }
1322
- function reviewNextAction(summary, scope, ledgerPath) {
1323
- const broken = summary.invalid + summary.stale;
1324
- const review = statusCommand(scope, "review", ledgerPath);
1325
- if (broken > 0) {
1326
- const repair = scope === "all" ? "re-register or fix the file" : "fix the file";
1327
- return `repair ${broken} broken ledger(s) above (${repair}), then re-run \`${review}\``;
1328
- }
1329
- if (summary.executable > 0) {
1330
- const dryRun = scope === "all" ? "artshelf cleanup --dry-run --all" : `artshelf cleanup --dry-run${ledgerPath ? ` --ledger ${ledgerPath}` : ""}`;
1331
- return `run \`${dryRun}\` to generate plans, then \`artshelf cleanup --execute --plan-id <id> --ledger <path>\` for each reviewed plan`;
1332
- }
1333
- if (summary.missingPath > 0) {
1334
- return "inspect missing-path entries and `artshelf resolve` the ones no longer needed; nothing is auto-executable";
1335
- }
1336
- return "nothing to do — no broken ledgers and no due, manual-review, missing-path, or executable cleanup entries";
1337
- }
1338
- function printReviewAll(results, summary, nextAction, registryPath) {
1339
- const needsAttention = summary.invalid + summary.stale + summary.executable + summary.due + summary.manualReview + summary.missingPath > 0;
1340
- process.stdout.write(`${attentionGlyph(needsAttention)} artshelf review --all: ${needsAttention ? "needs attention" : "all clear"}\n`);
1341
- process.stdout.write(`registry: ${registryPath} — ${summary.ledgers} ledgers (${summary.ok} ok, ${summary.invalid} invalid, ${summary.stale} stale)\n`);
1342
- printReview(results);
1343
- process.stdout.write(`triage: due ${summary.due} · manual-review ${summary.manualReview} · missing ${summary.missingPath} · executable ${summary.executable} · skipped ${summary.skipped}\n`);
1344
- process.stdout.write(`next: ${nextAction}\n`);
1345
- }
1346
- function printReview(results) {
1347
- for (const result of results) {
1348
- const visibleDue = result.due.filter((entry) => entry.dueStatus !== "kept");
1349
- const needsAttention = !result.validate.ok || visibleDue.length > 0 || result.plan.entries.length > 0;
1350
- process.stdout.write(`${attentionGlyph(needsAttention)} [${result.ledger.name}] ${result.validate.ok ? "ok" : "invalid"}: ${result.validate.entries} entries, ${result.validate.errors.length} errors, ${result.validate.warnings.length} warnings\n`);
1351
- process.stdout.write(`due/manual/missing: ${visibleDue.length}; plan ${result.plan.planId}: ${result.plan.entries.length} entries, ${result.plan.skipped.length} skipped\n`);
1352
- process.stdout.write(`ledger: ${result.ledger.path}\n`);
1353
- }
1354
- }
1355
- // review is read-only, so every safety guarantee holds unconditionally.
1356
- const REVIEW_SAFETY = {
1357
- dryRunOnly: true,
1358
- executeAllRefused: true,
1359
- noExecuteRan: true,
1360
- noResolveRan: true,
1361
- noDeleteRan: true
1362
- };
1363
- // Classify each registered ledger's records into decision groups. Order is
1364
- // fixed (registry order, then a stable per-ledger sub-order) so the packet is
1365
- // byte-for-byte deterministic.
1366
- function buildReviewDecisions(results, scope) {
1367
- const readyForApproval = [];
1368
- const needsReviewFirst = [];
1369
- const blocked = [];
1370
- const review = scope === "all" ? "artshelf review --all" : "artshelf review";
1371
- for (const result of results) {
1372
- const { ledger, validate, due } = result;
1373
- if (!validate.ok) {
1374
- const status = existsSync(ledger.path) ? "invalid" : "missing";
1375
- const repair = scope === "all" ? `re-register or fix ${ledger.path}` : `fix ${ledger.path}`;
1376
- blocked.push({
1377
- label: `Repair ${ledger.name} ledger (${status})`,
1378
- itemIds: [],
1379
- actionType: "fix-registry",
1380
- approvalTarget: null,
1381
- reason: validate.errors[0] ?? `${scope === "all" ? "registered ledger" : "ledger"} is ${status}`,
1382
- nextStep: `${repair}, then re-run \`${review}\``
1383
- });
1384
- continue;
1385
- }
1386
- const missingPath = due.filter((entry) => entry.dueStatus === "missing-path");
1387
- const trashSafe = due.filter((entry) => entry.dueStatus === "due" && entry.cleanup === "trash");
1388
- const inspectItems = due.filter((entry) => entry.dueStatus === "manual-review" ||
1389
- (entry.dueStatus === "due" && (entry.cleanup === "review" || entry.cleanup === "delete")));
1390
- // Ready for approval: missing-path records resolve ledger-only with an exact,
1391
- // plan-less approval target. Resolution updates the ledger and never touches
1392
- // files, so it is the one action review can hand an agent directly.
1393
- if (missingPath.length > 0) {
1394
- const ids = missingPath.map((entry) => entry.id).sort();
1395
- readyForApproval.push({
1396
- label: `Resolve ${ids.length} missing-path record(s) in ${ledger.name}`,
1397
- itemIds: ids,
1398
- actionType: "resolve-missing",
1399
- approvalTarget: `approve artshelf resolve missing ledger ${ledger.path} ids ${ids.join(" ")}`,
1400
- reason: "the recorded path is already missing",
1401
- nextStep: "confirm the artifact is no longer needed, then approve the ledger-only resolve"
1402
- });
1403
- }
1404
- // Trash-safe records are cleanup-eligible, but review never mints a plan, so
1405
- // they carry no approval target: the next step is the dry-run that produces
1406
- // the reviewed plan id to approve.
1407
- if (trashSafe.length > 0) {
1408
- const ids = trashSafe.map((entry) => entry.id).sort();
1409
- needsReviewFirst.push({
1410
- label: `Plan cleanup for ${ids.length} trash-eligible artifact(s) in ${ledger.name}`,
1411
- itemIds: ids,
1412
- actionType: "cleanup",
1413
- approvalTarget: null,
1414
- reason: "disposable artifacts are due but no reviewed cleanup plan exists yet",
1415
- nextStep: `run \`artshelf cleanup --dry-run --ledger ${ledger.path} --json\`, then approve \`approve artshelf cleanup ledger ${ledger.path} plan <plan-id>\``
1416
- });
1417
- }
1418
- // manual-review and cleanup=review records need a human decision before any
1419
- // cleanup; cleanup=delete is refused outright. None carry an approval target.
1420
- if (inspectItems.length > 0) {
1421
- const ids = inspectItems.map((entry) => entry.id).sort();
1422
- const hasDelete = inspectItems.some((entry) => entry.cleanup === "delete");
1423
- needsReviewFirst.push({
1424
- label: `Inspect ${ids.length} record(s) in ${ledger.name} before cleanup`,
1425
- itemIds: ids,
1426
- actionType: "inspect",
1427
- approvalTarget: null,
1428
- reason: hasDelete
1429
- ? "records need manual review; cleanup=delete is refused and never deletes files"
1430
- : "records are held for manual review before any cleanup",
1431
- nextStep: "inspect each path, then keep, change retention, resolve, or set cleanup=trash and plan a cleanup"
1432
- });
1433
- }
1434
- }
1435
- return { readyForApproval, needsReviewFirst, blocked };
1436
- }
1437
- function reviewCounts(summary) {
1438
- return {
1439
- due: summary.due,
1440
- manualReview: summary.manualReview,
1441
- missingPath: summary.missingPath,
1442
- executable: summary.executable,
1443
- skipped: summary.skipped
1444
- };
1445
- }
1446
- function buildReviewAgentPacketAll(results, summary, registryPath) {
1447
- const groups = buildReviewDecisions(results, "all");
1448
- return {
1449
- schemaVersion: 1,
1450
- command: "review",
1451
- scope: "all",
1452
- health: summary.invalid + summary.stale > 0 ? "attention" : "ok",
1453
- registry: { path: registryPath, exists: existsSync(registryPath) },
1454
- ledgers: { total: summary.ledgers, ok: summary.ok, stale: summary.stale, invalid: summary.invalid },
1455
- counts: reviewCounts(summary),
1456
- decisionSummary: {
1457
- readyForApproval: groups.readyForApproval.length,
1458
- needsReviewFirst: groups.needsReviewFirst.length,
1459
- blocked: groups.blocked.length
1460
- },
1461
- readyForApproval: groups.readyForApproval,
1462
- needsReviewFirst: groups.needsReviewFirst,
1463
- blocked: groups.blocked,
1464
- safety: REVIEW_SAFETY,
1465
- nextAction: reviewNextAction(summary, "all"),
1466
- verification: `artshelf review --all --agent --registry ${registryPath}`
1467
- };
1468
- }
1469
- function buildReviewAgentPacketSingle(result, ledgerPath) {
1470
- const summary = summarizeReview([result]);
1471
- const groups = buildReviewDecisions([result], "single");
1472
- return {
1473
- schemaVersion: 1,
1474
- command: "review",
1475
- scope: "single",
1476
- health: summary.invalid + summary.stale > 0 ? "attention" : "ok",
1477
- ledgerPath,
1478
- counts: reviewCounts(summary),
1479
- decisionSummary: {
1480
- readyForApproval: groups.readyForApproval.length,
1481
- needsReviewFirst: groups.needsReviewFirst.length,
1482
- blocked: groups.blocked.length
1483
- },
1484
- readyForApproval: groups.readyForApproval,
1485
- needsReviewFirst: groups.needsReviewFirst,
1486
- blocked: groups.blocked,
1487
- safety: REVIEW_SAFETY,
1488
- nextAction: reviewNextAction(summary, "single", ledgerPath),
1489
- verification: `artshelf review --agent --ledger ${ledgerPath}`
1490
- };
1491
- }
1492
- const COMMAND_GROUPS = [
1493
- {
1494
- group: "Create",
1495
- commands: [{ name: "put", summary: "Record an artifact with a reason and retention" }]
1496
- },
1497
- {
1498
- group: "Inspect",
1499
- commands: [
1500
- { name: "list", summary: "List ledger records" },
1501
- { name: "find", summary: "Find records by path, owner, label, or status" },
1502
- { name: "get", summary: "Show one record by id" },
1503
- { name: "due", summary: "Show due, manual-review, and missing-path records" },
1504
- { name: "status", summary: "Summarize ledger and registry counts" }
1505
- ]
1506
- },
1507
- {
1508
- group: "Review",
1509
- commands: [
1510
- { name: "validate", summary: "Check ledger shape and report warnings" },
1511
- { name: "review", summary: "Preview validate, due, and cleanup plans (read-only)" }
1512
- ]
1513
- },
1514
- {
1515
- group: "Clean",
1516
- commands: [
1517
- { name: "cleanup", summary: "Plan and execute approved cleanups" },
1518
- { name: "trash", summary: "Inspect and purge Artshelf trash" },
1519
- { name: "resolve", summary: "Mark a record manually resolved" }
1520
- ]
1521
- },
1522
- {
1523
- group: "System",
1524
- commands: [
1525
- { name: "ledgers", summary: "Manage the ledger registry" },
1526
- { name: "doctor", summary: "Report Artshelf health on this machine" },
1527
- { name: "update", summary: "Update the Artshelf CLI" }
1528
- ]
1529
- }
1530
- ];
1531
- // Commands with subcommands that carry their own focused help. Used to route
1532
- // `artshelf <command> <subcommand> --help` to a nested help key.
1533
- const NESTED_HELP = new Map([
1534
- ["trash", new Set(["list", "purge"])],
1535
- ["ledgers", new Set(["list", "add"])]
1536
- ]);
1537
- function resolveHelpKey(parsed) {
1538
- // `artshelf help [command [subcommand]]`
1539
- if (parsed.command === "help") {
1540
- return joinHelpKey(parsed.positionals[0], parsed.positionals[1]);
1541
- }
1542
- // `artshelf [--help|-h]` with no command resolves to the top-level help.
1543
- if (!parsed.command || parsed.command === "--help" || parsed.command === "-h") {
1544
- return "";
1545
- }
1546
- // `artshelf <command> [subcommand] --help`
1547
- return joinHelpKey(parsed.command, parsed.positionals[0]);
1548
- }
1549
- function joinHelpKey(command, subcommand) {
1550
- if (!command)
1551
- return "";
1552
- const subcommands = NESTED_HELP.get(command);
1553
- if (subcommands && subcommand && subcommands.has(subcommand)) {
1554
- return `${command} ${subcommand}`;
1555
- }
1556
- return command;
1557
- }
1558
- function renderTopLevelHelp() {
1559
- const names = COMMAND_GROUPS.flatMap((entry) => entry.commands.map((command) => command.name));
1560
- const width = Math.max(...names.map((name) => name.length)) + 2;
1561
- const lines = [
1562
- `Artshelf ${VERSION} — approval-first retention for the temporary files agents leave behind.`,
1563
- "",
1564
- "Usage:",
1565
- " artshelf <command> [options]",
1566
- "",
1567
- "Available Commands:"
1568
- ];
1569
- for (const { group, commands } of COMMAND_GROUPS) {
1570
- lines.push(` ${group}`);
1571
- for (const command of commands) {
1572
- lines.push(` ${command.name.padEnd(width)}${command.summary}`);
1573
- }
1574
- }
1575
- lines.push("", "Global Options:", " -h, --help Show help for artshelf or a specific command", " -v, --version Show the Artshelf version", "", "Output:", " --json Emit machine-readable JSON on commands that return data", "", "Scope (command-specific):", " --ledger <path> Target an explicit JSONL ledger", " --registry <path> Target an explicit ledger registry", " --all Read every registered ledger (on commands that support it)", "", `Use "artshelf <command> --help" for more information about a command.`, "");
1576
- return lines.join("\n");
1577
- }
1578
- function printHelp(command = "") {
1579
- if (command === "put") {
1580
- process.stdout.write(`Usage:
1581
- artshelf put <path> --reason <text> (--ttl <ttl>|--retain-until <date>|--manual-review) [options]
1582
-
1583
- Options:
1584
- --kind scratch|backup|run-artifact|evidence|cache|quarantine|other
1585
- --cleanup trash|review|delete (cleanup=delete is refused; trash purge needs a reviewed plan)
1586
- --owner <name>
1587
- --label <label> Repeatable
1588
- --ledger <path>
1589
- --registry <path>
1590
- --json
1591
- `);
1592
- return;
1593
- }
1594
- if (command === "cleanup") {
1595
- process.stdout.write(`Usage:
1596
- artshelf cleanup --dry-run [--ledger <path>] [--json]
1597
- artshelf cleanup --dry-run --all [--registry <path>] [--json]
1598
- artshelf cleanup --execute --plan-id <id> [--ledger <path>] [--json]
1599
-
1600
- Cleanup execution is approval-only. There is no daemon, no auto-execute, and no
1601
- global execute path: review a dry-run plan, then execute that one reviewed plan id.
1602
- Cleanup is ledger-first. Execute never computes a fresh live set; it only uses a reviewed plan id.
1603
- cleanup=delete records cleanup-refused instead of deleting files; physical trash purge needs a separate reviewed plan.
1604
- Dry-run writes and registers a plan only when executable cleanup entries exist; no-op dry-runs report not-created.
1605
- Matching dry-runs reuse the existing plan id and refresh its Artshelf-owned plan artifact.
1606
- Execute writes and registers an Artshelf-owned receipt artifact.
1607
- Global --all mode is dry-run only.
1608
- `);
1609
- return;
1610
- }
1611
- if (command === "trash") {
1612
- process.stdout.write(`Inspect and purge Artshelf trash.
1613
-
1614
- Usage:
1615
- artshelf trash [command]
1616
-
1617
- Available Commands:
1618
- list List records currently held in Artshelf trash
1619
- purge Plan or execute approved permanent trash deletion
1620
-
1621
- Flags:
1622
- -h, --help help for trash
1623
-
1624
- Use "artshelf trash <command> --help" for more information about a command.
1625
- `);
1626
- return;
1627
- }
1628
- if (command === "ledgers") {
1629
- process.stdout.write(`Manage the ledger registry.
1630
-
1631
- Usage:
1632
- artshelf ledgers [command]
1633
-
1634
- Available Commands:
1635
- list List and validate registered ledgers
1636
- add Register an existing ledger file
1637
-
1638
- Flags:
1639
- -h, --help help for ledgers
1640
-
1641
- Use "artshelf ledgers <command> --help" for more information about a command.
1642
- `);
1643
- return;
1644
- }
1645
- if (command === "list") {
1646
- process.stdout.write(`Usage:
1647
- artshelf list [--status <status>] [--ledger <path>] [--json]
1648
- artshelf list --all [--status <status>] [--registry <path>] [--json]
1649
-
1650
- Statuses:
1651
- active, review-required, trashed, cleanup-refused, resolved
1652
- `);
1653
- return;
1654
- }
1655
- if (command === "find") {
1656
- process.stdout.write(`Usage:
1657
- artshelf find (--path <path>|--owner <name>|--label <label>|--status <status>) [options]
1658
- artshelf find --all (--path <path>|--owner <name>|--label <label>|--status <status>) [options]
1659
-
1660
- Options:
1661
- --path <path> Match an exact artifact path after path normalization
1662
- --owner <name>
1663
- --label <label> Repeatable; all labels must match
1664
- --status <status>
1665
- --ledger <path>
1666
- --registry <path>
1667
- --json
1668
-
1669
- Find is read-only. Use it before put when an integration needs idempotent artifact registration.
1670
- `);
1671
- return;
1672
- }
1673
- if (command === "get") {
1674
- process.stdout.write(`Usage:
1675
- artshelf get <id> [--ledger <path>] [--json]
1676
- artshelf get <id> --all [--registry <path>] [--json]
1677
-
1678
- Get is read-only and returns one ledger record by Artshelf id.
1679
- `);
1680
- return;
1681
- }
1682
- if (command === "resolve") {
1683
- process.stdout.write(`Usage:
1684
- artshelf resolve <id> --status resolved --reason <text> [--ledger <path>] [--json]
1685
-
1686
- Resolve marks a handled, missing, or no-longer-needed record as manually resolved.
1687
- Resolved records stay in the audit trail but no longer participate in due or cleanup planning.
1688
- `);
1689
- return;
1690
- }
1691
- if (command === "review") {
1692
- process.stdout.write(`Usage:
1693
- artshelf review [--ledger <path>] [--json|--agent]
1694
- artshelf review --all [--registry <path>] [--json|--agent]
1695
-
1696
- Review runs validate, due, and cleanup plan preview without moving files or
1697
- writing a plan. With --all, review adds aggregate triage counts and the next
1698
- safe action.
1699
-
1700
- Render modes:
1701
- (default) Human summary of validation, triage counts, and the next safe action.
1702
- --json Full read-only audit report (backward-compatible).
1703
- --agent Compact single-line JSON decision packet for agents: health, triage
1704
- counts, and classified decision groups (ready for approval, needs
1705
- review first, blocked) with exact approval targets where they are
1706
- safe. Review is read-only, so cleanup approval targets are minted by
1707
- \`cleanup --dry-run\`, never leaked from a preview plan id.
1708
- Token-efficient; --agent takes precedence over --json.
1709
- `);
1710
- return;
1711
- }
1712
- if (command === "doctor") {
1713
- process.stdout.write(`Usage:
1714
- artshelf doctor [--registry <path>] [--ledger <path>] [--json|--agent]
1715
-
1716
- Doctor reports whether Artshelf is healthy on this machine: CLI version, selected
1717
- or default ledger path, selected or global registry path, registered ledger health
1718
- (stale/missing/invalid), and the cleanup safety posture. Execute is scoped to one
1719
- selected or default ledger and still requires a reviewed plan id; --all execute
1720
- and cleanup=delete are refused, while physical trash purge requires a separate
1721
- reviewed purge plan.
1722
-
1723
- Render modes:
1724
- (default) Human summary of machine health and cleanup safety.
1725
- --json Full audit report (backward-compatible; suitable for cron/reporting).
1726
- --agent Compact single-line JSON decision packet for agents: health, registry
1727
- and registered-ledger health, blockers, cleanup-safety posture, next
1728
- action, and a verify command. Token-efficient; --agent takes
1729
- precedence over --json.
1730
-
1731
- Run it after install, when --all commands behave unexpectedly, or on a schedule to
1732
- catch stale registry entries. Doctor is read-only. A healthy machine exits 0; a
1733
- broken registry or registered ledger exits non-zero with actionable errors.
1734
- `);
1735
- return;
1736
- }
1737
- if (command === "status") {
1738
- process.stdout.write(`Usage:
1739
- artshelf status [--ledger <path>] [--json|--agent]
1740
- artshelf status --all [--registry <path>] [--json|--agent]
1741
-
1742
- Status is the lightweight daily "what is going on?" view. Without --all, it
1743
- reports counts for the selected or default ledger only. With --all, it adds
1744
- registry health, total ledgers, and aggregated counts across registered ledgers.
1745
- Counts include active artifacts, kept, due, manual-review, missing-path, and
1746
- pending cleanup entries.
1747
-
1748
- Render modes:
1749
- (default) Human summary, short enough to paste into a chat.
1750
- --json Full audit report (backward-compatible; suitable for cron/reporting).
1751
- --agent Compact single-line JSON decision packet for agents: health, counts,
1752
- attention categories, blockers, next action, and a verify command.
1753
- Token-efficient; --agent takes precedence over --json.
1754
-
1755
- Status is read-only: it never creates plans or receipts and never mutates
1756
- records. A healthy selected ledger exits 0; with --all, a broken registry or any
1757
- stale or invalid registered ledger exits non-zero.
1758
- `);
1759
- return;
1760
- }
1761
- if (command === "update") {
1762
- process.stdout.write(`Usage:
1763
- artshelf update [--json]
1764
-
1765
- Update checks compare the current CLI version with the latest published npm
1766
- version. Normal commands may print a non-blocking update notice to stderr when a
1767
- newer version is available. Run update to upgrade npm global installs only:
1768
-
1769
- npm install -g artshelf@latest
1770
-
1771
- pnpm global installs should update with pnpm add -g artshelf@latest; source
1772
- installs should update by pulling, rebuilding, and linking the checkout.
1773
- `);
1774
- return;
1775
- }
1776
- if (command === "due") {
1777
- process.stdout.write(`Usage:
1778
- artshelf due [--ledger <path>] [--json]
1779
- artshelf due --all [--registry <path>] [--json]
1780
-
1781
- Due lists records whose retention has elapsed or that need attention: due,
1782
- manual-review, and missing-path entries. Kept entries are hidden in human output.
1783
- Due is read-only and never moves files or writes plans.
1784
- `);
1785
- return;
1786
- }
1787
- if (command === "validate") {
1788
- process.stdout.write(`Usage:
1789
- artshelf validate [--ledger <path>] [--json]
1790
- artshelf validate --all [--registry <path>] [--json]
1791
-
1792
- Validate checks ledger shape and reports errors and warnings, such as records
1793
- that point at missing artifact paths, without changing anything. A clean ledger
1794
- exits 0; shape errors exit non-zero. With --all it validates every registered
1795
- ledger.
1796
- `);
1797
- return;
1798
- }
1799
- if (command === "trash list") {
1800
- process.stdout.write(`Usage:
1801
- artshelf trash list [--ledger <path>] [--all] [--registry <path>] [--json]
1802
-
1803
- Options:
1804
- --ledger <path> Use a specific ledger file
1805
- --all Include records from all registered ledgers
1806
- --registry <path> Registry path used with --all
1807
- --json Emit machine-readable output
1808
-
1809
- Trash list shows records currently held in Artshelf trash without deleting anything.
1810
- With --all it reports trashed records across every registered ledger.
1811
- `);
1812
- return;
1813
- }
1814
- if (command === "trash purge") {
1815
- process.stdout.write(`Usage:
1816
- artshelf trash purge --older-than <ttl> --dry-run [--ledger <path>] [--json]
1817
- artshelf trash purge --execute --plan-id <id> [--ledger <path>] [--json]
1818
-
1819
- Options:
1820
- --older-than <ttl> Purge trashed records older than this duration
1821
- --dry-run Build a reviewed purge plan and output a plan id
1822
- --execute Execute a reviewed purge plan
1823
- --plan-id <id> Execute only this reviewed purge plan
1824
- --ledger <path> Target one specific ledger
1825
- --json Emit machine-readable output
1826
-
1827
- Trash purge permanently deletes aged trash from a reviewed plan. --dry-run turns
1828
- --older-than into a reviewed purge plan id; --execute deletes only that one reviewed
1829
- plan id. Purge is always scoped to one --ledger; --all is not supported for purge.
1830
- Completed receipts are refused on repeat execute; an interrupted purge may be resumed
1831
- and reconciled.
1832
- `);
1833
- return;
1834
- }
1835
- if (command === "ledgers list") {
1836
- process.stdout.write(`Usage:
1837
- artshelf ledgers list [--plain] [--registry <path>] [--json]
1838
-
1839
- Options:
1840
- --plain Skip ledger validation and list registrations directly
1841
- --registry <path> Registry path to use
1842
- --json Emit machine-readable output
1843
-
1844
- Ledgers list validates every registered ledger and reports ok/missing/invalid
1845
- status, entry counts, and warnings so agents can spot stale registry entries
1846
- without a separate validate pass. Use --plain for the fast path that lists
1847
- registered ledgers without reading them. It exits non-zero when the registry or
1848
- any registered ledger is broken.
1849
- `);
1850
- return;
1851
- }
1852
- if (command === "ledgers add") {
1853
- process.stdout.write(`Usage:
1854
- artshelf ledgers add --ledger <path> [--name <name>] [--scope repo|user|other] [--registry <path>] [--json]
1855
-
1856
- Options:
1857
- --ledger <path> Register this ledger file
1858
- --name <name> Override the ledger display name
1859
- --scope <scope> Registry scope: repo, user, or other
1860
- --registry <path> Registry path to update
1861
- --json Emit machine-readable output
1862
-
1863
- Ledgers add registers an existing ledger file in the global registry so --all
1864
- commands and the registry index can find it. The ledger file must already exist.
1865
- `);
1866
- return;
1867
- }
1868
- process.stdout.write(renderTopLevelHelp());
79
+ async function maybeNotifyUpdateAndReturn(status, parsed) {
80
+ await maybeNotifyAvailableUpdate(parsed);
81
+ return status;
1869
82
  }
1870
83
  main(process.argv.slice(2))
1871
84
  .then((status) => {
1872
85
  process.exitCode = status;
1873
86
  })
1874
87
  .catch((error) => {
1875
- process.stderr.write(`artshelf: ${error.message}\nRun \`artshelf help\` for usage.\n`);
88
+ process.stderr.write(formatCliError(error));
1876
89
  process.exitCode = 1;
1877
90
  });