@cfbender/cesium 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.6.0 — 2026-05-13
4
+
5
+ Internal cleanup release. Two hand-rolled server and CLI layers swapped
6
+ for small, well-fit libraries (Hono and Citty) that delete the regex and
7
+ dispatcher boilerplate without changing user-visible behavior. No new
8
+ features and no protocol changes — artifacts written by older versions
9
+ continue rendering unchanged.
10
+
11
+ - **refactor:** Migrate the cesium HTTP server from a hand-rolled
12
+ `Bun.serve` fetch handler with a custom `preHandlers` middleware chain
13
+ to a Hono app fronted by Bun.serve. Routes for `/api/sessions/*` and
14
+ `/favicon.ico` now use Hono's typed `:param` syntax instead of regex
15
+ match + manual narrowing. The static file handler is preserved verbatim
16
+ and mounted as `app.notFound` so all 11 existing static-serve tests
17
+ pass unchanged. `ServerHandle.app: Hono` replaces `addHandler` for
18
+ callers that need to register additional routes.
19
+ - **refactor:** Migrate the `cesium` CLI from `node:util parseArgs` +
20
+ a hand-rolled subcommand dispatcher to Citty. Each command file now
21
+ exports a typed `runX(args, ctx)` inner function (kept directly
22
+ testable with injected `{stdout, stderr, loadConfig}`) plus an outer
23
+ `xxxCmd = defineCommand(...)` that Citty consumes. Subcommands load
24
+ lazily via dynamic `import()` so cold-start cost is paid only for the
25
+ command the user actually invoked. `cesium theme show|apply` is now
26
+ expressed as native Citty sub-subcommands rather than the previous
27
+ manual routing.
28
+ - **fix:** `cesium restart` no longer fails on serve-only flags. The
29
+ previous implementation passed argv through to both `stopCommand` and
30
+ `serveCommand` under `strict: true`, so `cesium restart --port 4000`
31
+ would have errored. Restart now defines its own arg schema covering
32
+ both stop and serve options.
33
+ - **chore:** Clean up all 25 oxlint warnings:
34
+ - Restructure `src/server/api.ts` regex matching to remove four
35
+ `!` non-null assertions.
36
+ - Replace `match![1]!` patterns in tests with a local `unwrap(value,
37
+ name)` helper for proper type narrowing.
38
+ - Replace `handle!.url` in tests with an explicit null check.
39
+ - Add targeted `eslint-disable-next-line no-await-in-loop` comments
40
+ (with `--` reason annotations matching the existing repo convention)
41
+ in places where sequential execution is required: middleware chain,
42
+ poll-with-backoff retry, short-circuit search.
43
+ - **chore:** Add `hono@4.12.18` and `citty@0.2.2` to dependencies.
44
+ Removes a meaningful amount of hand-rolled routing / arg-parsing code
45
+ for ~50KB total install footprint.
46
+ - **chore:** `cesium --version` output format changes from
47
+ `cesium 0.6.0` to plain `0.6.0` (Citty's default). The `cesium version`
48
+ subcommand is removed — use `cesium --version` or `cesium -v`. Auto-
49
+ generated help text for each command also follows Citty's formatting
50
+ (uppercase USAGE/OPTIONS, color codes, default annotations) rather
51
+ than the previous hand-aligned `Usage: cesium ls` strings.
52
+
3
53
  ## v0.5.2 — 2026-05-12
4
54
 
5
55
  A 16th block type — `diff` — that renders a beautiful side-by-side
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfbender/cesium",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Beautiful self-contained HTML artifacts from your opencode agent.",
5
5
  "keywords": [
6
6
  "agent",
@@ -46,6 +46,8 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@opencode-ai/plugin": "latest",
49
+ "citty": "^0.2.2",
50
+ "hono": "^4.12.18",
49
51
  "nanoid": "^5.0.0",
50
52
  "parse5": "^7.1.0",
51
53
  "shiki": "^4.0.2"
@@ -1,12 +1,18 @@
1
1
  // cesium ls — list artifacts for current project (or all).
2
2
 
3
- import { parseArgs } from "node:util";
3
+ import { defineCommand } from "citty";
4
4
  import { join } from "node:path";
5
5
  import { execSync } from "node:child_process";
6
6
  import { loadConfig, type CesiumConfig } from "../../config.ts";
7
7
  import { loadIndex, type IndexEntry } from "../../storage/index-cache.ts";
8
8
  import { deriveProjectIdentity } from "../../storage/paths.ts";
9
9
 
10
+ export interface LsArgs {
11
+ all: boolean;
12
+ json: boolean;
13
+ limit: number;
14
+ }
15
+
10
16
  export interface LsContext {
11
17
  stdout: { write: (s: string) => void };
12
18
  stderr: { write: (s: string) => void };
@@ -58,69 +64,27 @@ function getGitRemote(cwd: string): string | null {
58
64
  }
59
65
  }
60
66
 
61
- export async function lsCommand(argv: string[], ctx?: Partial<LsContext>): Promise<number> {
62
- const resolved: LsContext = { ...defaultCtx(), ...ctx };
63
-
64
- let values: {
65
- all: boolean;
66
- json: boolean;
67
- limit: string | undefined;
68
- help: boolean;
69
- };
70
-
71
- try {
72
- const parsed = parseArgs({
73
- args: argv,
74
- options: {
75
- all: { type: "boolean", short: "a", default: false },
76
- json: { type: "boolean", default: false },
77
- limit: { type: "string", short: "n" },
78
- help: { type: "boolean", short: "h", default: false },
79
- },
80
- allowPositionals: false,
81
- strict: true,
82
- });
83
- values = parsed.values as typeof values;
84
- } catch (err) {
85
- const e = err as Error;
86
- resolved.stderr.write(`cesium ls: ${e.message}\n`);
87
- resolved.stderr.write(`Usage: cesium ls [--all] [--json] [--limit N]\n`);
88
- return 1;
89
- }
90
-
91
- if (values.help) {
92
- resolved.stdout.write(
93
- [
94
- "Usage: cesium ls [options]",
95
- "",
96
- "Options:",
97
- " --all, -a Show artifacts for all projects (default: current project only)",
98
- " --json Output as JSON array",
99
- " --limit, -n N Show at most N most recent artifacts (default: 50)",
100
- " --help, -h Show this help message",
101
- "",
102
- ].join("\n"),
103
- );
104
- return 0;
105
- }
67
+ /**
68
+ * Inner command logic. Tests call this directly with typed args + injected
69
+ * context for fast feedback; the Citty wrapper handles argv parsing.
70
+ */
71
+ export async function runLs(args: LsArgs, ctxOverride?: Partial<LsContext>): Promise<number> {
72
+ const ctx: LsContext = { ...defaultCtx(), ...ctxOverride };
106
73
 
107
- const limitRaw = values.limit !== undefined ? parseInt(values.limit, 10) : 50;
108
- if (isNaN(limitRaw) || limitRaw < 1) {
109
- resolved.stderr.write(`cesium ls: --limit must be a positive integer\n`);
74
+ if (args.limit < 1) {
75
+ ctx.stderr.write(`cesium ls: --limit must be a positive integer\n`);
110
76
  return 1;
111
77
  }
112
- const limit = limitRaw;
113
78
 
114
- // Load config
115
- const cfg = (resolved.loadConfig ?? loadConfig)();
79
+ const cfg = (ctx.loadConfig ?? loadConfig)();
116
80
 
117
81
  // Determine which index.json to read
118
82
  let jsonPath: string;
119
- if (values.all) {
83
+ if (args.all) {
120
84
  jsonPath = join(cfg.stateDir, "index.json");
121
85
  } else {
122
- const gitRemote = getGitRemote(resolved.cwd);
123
- const identity = deriveProjectIdentity({ cwd: resolved.cwd, gitRemote });
86
+ const gitRemote = getGitRemote(ctx.cwd);
87
+ const identity = deriveProjectIdentity({ cwd: ctx.cwd, gitRemote });
124
88
  jsonPath = join(cfg.stateDir, "projects", identity.slug, "index.json");
125
89
  }
126
90
 
@@ -129,21 +93,19 @@ export async function lsCommand(argv: string[], ctx?: Partial<LsContext>): Promi
129
93
  entries = await loadIndex(jsonPath);
130
94
  } catch (err) {
131
95
  const e = err as Error;
132
- resolved.stderr.write(`cesium ls: failed to read index: ${e.message}\n`);
96
+ ctx.stderr.write(`cesium ls: failed to read index: ${e.message}\n`);
133
97
  return 1;
134
98
  }
135
99
 
136
- // Already sorted newest-first by loadIndex / appendEntry; apply limit
137
- const limited = entries.slice(0, limit);
100
+ const limited = entries.slice(0, args.limit);
138
101
 
139
- if (values.json) {
140
- resolved.stdout.write(JSON.stringify(limited, null, 2) + "\n");
102
+ if (args.json) {
103
+ ctx.stdout.write(JSON.stringify(limited, null, 2) + "\n");
141
104
  return 0;
142
105
  }
143
106
 
144
- // Table output
145
107
  if (limited.length === 0) {
146
- resolved.stdout.write("No artifacts found.\n");
108
+ ctx.stdout.write("No artifacts found.\n");
147
109
  return 0;
148
110
  }
149
111
 
@@ -165,8 +127,8 @@ export async function lsCommand(argv: string[], ctx?: Partial<LsContext>): Promi
165
127
 
166
128
  const sep = "─".repeat(header.length);
167
129
 
168
- resolved.stdout.write(header + "\n");
169
- resolved.stdout.write(sep + "\n");
130
+ ctx.stdout.write(header + "\n");
131
+ ctx.stdout.write(sep + "\n");
170
132
 
171
133
  for (const e of limited) {
172
134
  const row =
@@ -179,8 +141,43 @@ export async function lsCommand(argv: string[], ctx?: Partial<LsContext>): Promi
179
141
  fmtDate(e.createdAt).padEnd(COL_DATE) +
180
142
  " " +
181
143
  superCol(e);
182
- resolved.stdout.write(row + "\n");
144
+ ctx.stdout.write(row + "\n");
183
145
  }
184
146
 
185
147
  return 0;
186
148
  }
149
+
150
+ export const lsCmd = defineCommand({
151
+ meta: {
152
+ name: "ls",
153
+ description: "List artifacts in the current project (or all with --all).",
154
+ },
155
+ args: {
156
+ all: {
157
+ type: "boolean",
158
+ alias: "a",
159
+ default: false,
160
+ description: "Show artifacts for all projects (default: current project only)",
161
+ },
162
+ json: {
163
+ type: "boolean",
164
+ default: false,
165
+ description: "Output as JSON array",
166
+ },
167
+ limit: {
168
+ type: "string",
169
+ alias: "n",
170
+ default: "50",
171
+ description: "Show at most N most recent artifacts",
172
+ },
173
+ },
174
+ async run({ args }) {
175
+ const limit = parseInt(args.limit, 10);
176
+ if (isNaN(limit) || limit < 1) {
177
+ process.stderr.write(`cesium ls: --limit must be a positive integer\n`);
178
+ process.exit(1);
179
+ }
180
+ const code = await runLs({ all: args.all, json: args.json, limit });
181
+ if (code !== 0) process.exit(code);
182
+ },
183
+ });
@@ -1,6 +1,6 @@
1
1
  // cesium open — find an artifact by id prefix and open it in the browser.
2
2
 
3
- import { parseArgs } from "node:util";
3
+ import { defineCommand } from "citty";
4
4
  import { join } from "node:path";
5
5
  import { spawn } from "node:child_process";
6
6
  import { platform } from "node:os";
@@ -15,6 +15,11 @@ import {
15
15
  } from "../../server/lifecycle.ts";
16
16
  import { resolveDisplayHost } from "../../tools/publish.ts";
17
17
 
18
+ export interface OpenArgs {
19
+ idPrefix: string;
20
+ print: boolean;
21
+ }
22
+
18
23
  export interface OpenContext {
19
24
  stdout: { write: (s: string) => void };
20
25
  stderr: { write: (s: string) => void };
@@ -90,59 +95,16 @@ async function tryGetHttpUrl(
90
95
  return null;
91
96
  }
92
97
 
93
- export async function openCommand(argv: string[], ctx?: Partial<OpenContext>): Promise<number> {
94
- const resolved: OpenContext = { ...defaultCtx(), ...ctx };
95
-
96
- let values: { print: boolean; help: boolean };
97
- let positionals: string[];
98
-
99
- try {
100
- const parsed = parseArgs({
101
- args: argv,
102
- options: {
103
- print: { type: "boolean", default: false },
104
- help: { type: "boolean", short: "h", default: false },
105
- },
106
- allowPositionals: true,
107
- strict: true,
108
- });
109
- values = parsed.values as typeof values;
110
- positionals = parsed.positionals;
111
- } catch (err) {
112
- const e = err as Error;
113
- resolved.stderr.write(`cesium open: ${e.message}\n`);
114
- resolved.stderr.write(`Usage: cesium open <id-prefix> [--print]\n`);
115
- return 1;
116
- }
117
-
118
- if (values.help) {
119
- resolved.stdout.write(
120
- [
121
- "Usage: cesium open <id-prefix> [options]",
122
- "",
123
- "Options:",
124
- " --print Print the URL instead of opening in the browser",
125
- " --help, -h Show this help message",
126
- "",
127
- "Notes:",
128
- " Browser launch is supported on macOS (open) and Linux (xdg-open).",
129
- " On Windows, use --print to get the URL.",
130
- "",
131
- ].join("\n"),
132
- );
133
- return 0;
134
- }
98
+ export async function runOpen(args: OpenArgs, ctxOverride?: Partial<OpenContext>): Promise<number> {
99
+ const ctx: OpenContext = { ...defaultCtx(), ...ctxOverride };
135
100
 
136
- const idPrefix = positionals[0];
137
- if (idPrefix === undefined || idPrefix.length === 0) {
138
- resolved.stderr.write(`cesium open: missing required argument <id-prefix>\n`);
139
- resolved.stderr.write(`Usage: cesium open <id-prefix> [--print]\n`);
101
+ if (args.idPrefix.length === 0) {
102
+ ctx.stderr.write(`cesium open: missing required argument <id-prefix>\n`);
140
103
  return 1;
141
104
  }
142
105
 
143
- const prefixLower = idPrefix.toLowerCase();
144
-
145
- const cfg = (resolved.loadConfig ?? loadConfig)();
106
+ const prefixLower = args.idPrefix.toLowerCase();
107
+ const cfg = (ctx.loadConfig ?? loadConfig)();
146
108
 
147
109
  // Search global index for matches
148
110
  const globalJsonPath = join(cfg.stateDir, "index.json");
@@ -151,23 +113,23 @@ export async function openCommand(argv: string[], ctx?: Partial<OpenContext>): P
151
113
  allEntries = await loadIndex(globalJsonPath);
152
114
  } catch (err) {
153
115
  const e = err as Error;
154
- resolved.stderr.write(`cesium open: failed to read index: ${e.message}\n`);
116
+ ctx.stderr.write(`cesium open: failed to read index: ${e.message}\n`);
155
117
  return 1;
156
118
  }
157
119
 
158
120
  const matches = allEntries.filter((e) => e.id.toLowerCase().startsWith(prefixLower));
159
121
 
160
122
  if (matches.length === 0) {
161
- resolved.stderr.write(`cesium open: no artifact found with id prefix "${idPrefix}"\n`);
123
+ ctx.stderr.write(`cesium open: no artifact found with id prefix "${args.idPrefix}"\n`);
162
124
  return 1;
163
125
  }
164
126
 
165
127
  if (matches.length > 1) {
166
- resolved.stderr.write(
167
- `cesium open: ambiguous prefix "${idPrefix}" — ${matches.length} matches:\n`,
128
+ ctx.stderr.write(
129
+ `cesium open: ambiguous prefix "${args.idPrefix}" — ${matches.length} matches:\n`,
168
130
  );
169
131
  for (const m of matches) {
170
- resolved.stderr.write(` ${m.id} ${m.title} (${m.kind})\n`);
132
+ ctx.stderr.write(` ${m.id} ${m.title} (${m.kind})\n`);
171
133
  }
172
134
  return 2;
173
135
  }
@@ -175,7 +137,7 @@ export async function openCommand(argv: string[], ctx?: Partial<OpenContext>): P
175
137
  const entry = matches[0];
176
138
  if (entry === undefined) {
177
139
  // Unreachable: guarded by matches.length === 1, but satisfies the type checker
178
- resolved.stderr.write(`cesium open: internal error — no match\n`);
140
+ ctx.stderr.write(`cesium open: internal error — no match\n`);
179
141
  return 1;
180
142
  }
181
143
  const paths = pathsFor({
@@ -185,24 +147,47 @@ export async function openCommand(argv: string[], ctx?: Partial<OpenContext>): P
185
147
  });
186
148
 
187
149
  // Resolve URL
188
- const runEnsureRunning = resolved.ensureRunning ?? ensureRunning;
150
+ const runEnsureRunning = ctx.ensureRunning ?? ensureRunning;
189
151
  const httpUrl = await tryGetHttpUrl(cfg, paths.serverPath, runEnsureRunning);
190
152
  const url = httpUrl ?? paths.fileUrl;
191
153
 
192
- if (values.print) {
193
- resolved.stdout.write(url + "\n");
154
+ if (args.print) {
155
+ ctx.stdout.write(url + "\n");
194
156
  return 0;
195
157
  }
196
158
 
197
- const open = resolved.opener ?? defaultOpener;
159
+ const open = ctx.opener ?? defaultOpener;
198
160
  try {
199
161
  await open(url);
200
162
  } catch (err) {
201
163
  const e = err as Error;
202
- resolved.stderr.write(`cesium open: ${e.message}\n`);
203
- resolved.stdout.write(`URL: ${url}\n`);
164
+ ctx.stderr.write(`cesium open: ${e.message}\n`);
165
+ ctx.stdout.write(`URL: ${url}\n`);
204
166
  return 1;
205
167
  }
206
168
 
207
169
  return 0;
208
170
  }
171
+
172
+ export const openCmd = defineCommand({
173
+ meta: {
174
+ name: "open",
175
+ description: "Open an artifact by id prefix in the browser.",
176
+ },
177
+ args: {
178
+ idPrefix: {
179
+ type: "positional",
180
+ description: "Artifact id prefix (any unique substring of the id)",
181
+ required: true,
182
+ },
183
+ print: {
184
+ type: "boolean",
185
+ default: false,
186
+ description: "Print the URL instead of opening in the browser",
187
+ },
188
+ },
189
+ async run({ args }) {
190
+ const code = await runOpen({ idPrefix: args.idPrefix, print: args.print });
191
+ if (code !== 0) process.exit(code);
192
+ },
193
+ });
@@ -1,6 +1,6 @@
1
1
  // cesium prune — delete artifacts older than a given duration.
2
2
 
3
- import { parseArgs } from "node:util";
3
+ import { defineCommand } from "citty";
4
4
  import { join } from "node:path";
5
5
  import { readdir, unlink as fsUnlink, stat } from "node:fs/promises";
6
6
  import { loadConfig, type CesiumConfig } from "../../config.ts";
@@ -15,6 +15,11 @@ import { themeFromPreset, mergeTheme } from "../../render/theme.ts";
15
15
  import { readEmbeddedMetadata } from "../../storage/write.ts";
16
16
  import { readFile } from "node:fs/promises";
17
17
 
18
+ export interface PruneArgs {
19
+ olderThan: string;
20
+ yes: boolean;
21
+ }
22
+
18
23
  export interface PruneContext {
19
24
  stdout: { write: (s: string) => void };
20
25
  stderr: { write: (s: string) => void };
@@ -164,75 +169,28 @@ function formatTable(candidates: ArtifactCandidate[]): string {
164
169
  return [header, sep, ...rows].join("\n");
165
170
  }
166
171
 
167
- export async function pruneCommand(argv: string[], ctx?: Partial<PruneContext>): Promise<number> {
168
- const resolved: PruneContext = { ...defaultCtx(), ...ctx };
172
+ export async function runPrune(
173
+ args: PruneArgs,
174
+ ctxOverride?: Partial<PruneContext>,
175
+ ): Promise<number> {
176
+ const ctx: PruneContext = { ...defaultCtx(), ...ctxOverride };
169
177
 
170
- let values: {
171
- "older-than": string | undefined;
172
- "dry-run": boolean;
173
- yes: boolean;
174
- help: boolean;
175
- };
176
-
177
- try {
178
- const parsed = parseArgs({
179
- args: argv,
180
- options: {
181
- "older-than": { type: "string" },
182
- "dry-run": { type: "boolean", default: false },
183
- yes: { type: "boolean", short: "y", default: false },
184
- help: { type: "boolean", short: "h", default: false },
185
- },
186
- allowPositionals: false,
187
- strict: true,
188
- });
189
- values = parsed.values as typeof values;
190
- } catch (err) {
191
- const e = err as Error;
192
- resolved.stderr.write(`cesium prune: ${e.message}\n`);
193
- resolved.stderr.write(`Usage: cesium prune --older-than <duration> [--yes]\n`);
178
+ if (args.olderThan.length === 0) {
179
+ ctx.stderr.write(`cesium prune: --older-than is required\n`);
194
180
  return 1;
195
181
  }
196
182
 
197
- if (values.help) {
198
- resolved.stdout.write(
199
- [
200
- "Usage: cesium prune --older-than <duration> [options]",
201
- "",
202
- "Options:",
203
- " --older-than <dur> Delete artifacts older than this duration (e.g. 90d, 2w, 12h, 30m)",
204
- " --yes, -y Actually delete (default is dry-run)",
205
- " --dry-run Explicit dry-run (same as omitting --yes)",
206
- " --help, -h Show this help message",
207
- "",
208
- "Duration format: <N><unit> where unit is d (days), w (weeks), h (hours), m (minutes).",
209
- "",
210
- "Note: prune deletes by age only. It does not check revision chains.",
211
- " Deleting an early version in a supersedes chain does not affect the newer version.",
212
- "",
213
- ].join("\n"),
214
- );
215
- return 0;
216
- }
217
-
218
- const durationStr = values["older-than"];
219
- if (durationStr === undefined || durationStr.length === 0) {
220
- resolved.stderr.write(`cesium prune: --older-than is required\n`);
221
- resolved.stderr.write(`Usage: cesium prune --older-than <duration> [--yes]\n`);
222
- return 1;
223
- }
224
-
225
- const durationMs = parseDuration(durationStr);
183
+ const durationMs = parseDuration(args.olderThan);
226
184
  if (durationMs === null) {
227
- resolved.stderr.write(
228
- `cesium prune: invalid duration "${durationStr}". Use format like 90d, 2w, 12h, 30m\n`,
185
+ ctx.stderr.write(
186
+ `cesium prune: invalid duration "${args.olderThan}". Use format like 90d, 2w, 12h, 30m\n`,
229
187
  );
230
188
  return 1;
231
189
  }
232
190
 
233
- const isDryRun = !values.yes;
234
- const cfg = (resolved.loadConfig ?? loadConfig)();
235
- const now = (resolved.now ?? (() => new Date()))();
191
+ const isDryRun = !args.yes;
192
+ const cfg = (ctx.loadConfig ?? loadConfig)();
193
+ const now = (ctx.now ?? (() => new Date()))();
236
194
  const cutoff = now.getTime() - durationMs;
237
195
 
238
196
  // Collect all artifacts
@@ -241,7 +199,7 @@ export async function pruneCommand(argv: string[], ctx?: Partial<PruneContext>):
241
199
  allArtifacts = await collectAllArtifacts(cfg.stateDir);
242
200
  } catch (err) {
243
201
  const e = err as Error;
244
- resolved.stderr.write(`cesium prune: failed to scan artifacts: ${e.message}\n`);
202
+ ctx.stderr.write(`cesium prune: failed to scan artifacts: ${e.message}\n`);
245
203
  return 1;
246
204
  }
247
205
 
@@ -252,16 +210,16 @@ export async function pruneCommand(argv: string[], ctx?: Partial<PruneContext>):
252
210
  });
253
211
 
254
212
  if (toDelete.length === 0) {
255
- resolved.stdout.write(`No artifacts older than ${durationStr} found.\n`);
213
+ ctx.stdout.write(`No artifacts older than ${args.olderThan} found.\n`);
256
214
  return 0;
257
215
  }
258
216
 
259
217
  if (isDryRun) {
260
- resolved.stdout.write(
261
- `Would delete ${toDelete.length} artifact${toDelete.length !== 1 ? "s" : ""} older than ${durationStr}:\n`,
218
+ ctx.stdout.write(
219
+ `Would delete ${toDelete.length} artifact${toDelete.length !== 1 ? "s" : ""} older than ${args.olderThan}:\n`,
262
220
  );
263
- resolved.stdout.write(formatTable(toDelete) + "\n\n");
264
- resolved.stdout.write(`Re-run with --yes to delete.\n`);
221
+ ctx.stdout.write(formatTable(toDelete) + "\n\n");
222
+ ctx.stdout.write(`Re-run with --yes to delete.\n`);
265
223
  return 0;
266
224
  }
267
225
 
@@ -274,9 +232,7 @@ export async function pruneCommand(argv: string[], ctx?: Partial<PruneContext>):
274
232
  } catch (err) {
275
233
  const e = err as NodeJS.ErrnoException;
276
234
  if (e.code !== "ENOENT") {
277
- resolved.stderr.write(
278
- `cesium prune: failed to delete ${candidate.filePath}: ${e.message}\n`,
279
- );
235
+ ctx.stderr.write(`cesium prune: failed to delete ${candidate.filePath}: ${e.message}\n`);
280
236
  }
281
237
  return false;
282
238
  }
@@ -341,8 +297,40 @@ export async function pruneCommand(argv: string[], ctx?: Partial<PruneContext>):
341
297
  // best-effort
342
298
  }
343
299
 
344
- resolved.stdout.write(
300
+ ctx.stdout.write(
345
301
  `Deleted ${deletedCount} artifact${deletedCount !== 1 ? "s" : ""}. Indexes regenerated.\n`,
346
302
  );
347
303
  return 0;
348
304
  }
305
+
306
+ export const pruneCmd = defineCommand({
307
+ meta: {
308
+ name: "prune",
309
+ description: "Delete artifacts older than a given duration.",
310
+ },
311
+ args: {
312
+ "older-than": {
313
+ type: "string",
314
+ required: true,
315
+ description: "Delete artifacts older than this duration (e.g. 90d, 2w, 12h, 30m)",
316
+ },
317
+ yes: {
318
+ type: "boolean",
319
+ alias: "y",
320
+ default: false,
321
+ description: "Actually delete (default is dry-run)",
322
+ },
323
+ "dry-run": {
324
+ type: "boolean",
325
+ default: false,
326
+ description: "Explicit dry-run (same as omitting --yes)",
327
+ },
328
+ },
329
+ async run({ args }) {
330
+ const code = await runPrune({
331
+ olderThan: args["older-than"],
332
+ yes: args.yes,
333
+ });
334
+ if (code !== 0) process.exit(code);
335
+ },
336
+ });