@cfbender/cesium 0.5.2 → 0.6.1

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,89 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.6.1 — 2026-05-13
4
+
5
+ Fixes a regression introduced by the v0.4 blocks refactor: the README's
6
+ "self-contained HTML file" claim had quietly become false. Block-based
7
+ artifacts (cards, callouts, compare-tables, timelines, code) rendered as
8
+ broken-looking plain HTML when opened via `file://` because the inline
9
+ fallback CSS only covered basic typography — all the block styling lived
10
+ in the server-side `theme.css`. Sharing an artifact required either the
11
+ running cesium server or a recipient who happened to have it.
12
+
13
+ - **feat:** Artifacts now embed the full `theme.css` (~21KB) in an inline
14
+ `<style>` block at generation time. Opening any artifact via `file://`
15
+ or on any other machine now renders correctly with zero external
16
+ dependencies — typography, blocks, tables, controls, diff renderer, all
17
+ of it. The `<link rel="stylesheet" href=".../theme.css">` is preserved
18
+ so served artifacts still pick up live theme changes (the link rules
19
+ override the earlier inline `<style>` in cascade order).
20
+ - **feat:** New `cesium export <id-prefix>` command emits an artifact's
21
+ self-contained HTML to stdout, or to `--out file.html`. Resolves the id
22
+ prefix against the global index the same way `cesium open` does. With
23
+ the baked-in CSS, export is now a near-trivial file copy — pipe it
24
+ anywhere, attach it to email, drop it in a chat, commit it to a repo.
25
+ - **refactor:** Removed `src/render/fallback.ts` and the matching test
26
+ file. The 8-line fallback CSS it produced is no longer needed; the
27
+ inline `<style>` carries the full framework directly.
28
+ - **chore:** README "Share an artifact" section now leads with
29
+ `cesium export` and explains the bake-and-override model.
30
+
31
+ Old artifacts on disk are not retroactively re-baked — they keep their
32
+ generation-time fallback + link, which means they continue to render
33
+ fine when served by cesium but look plain when opened standalone.
34
+ Historical fidelity is preserved. New artifacts written from v0.6.1
35
+ onward are genuinely portable.
36
+
37
+ ## v0.6.0 — 2026-05-13
38
+
39
+ Internal cleanup release. Two hand-rolled server and CLI layers swapped
40
+ for small, well-fit libraries (Hono and Citty) that delete the regex and
41
+ dispatcher boilerplate without changing user-visible behavior. No new
42
+ features and no protocol changes — artifacts written by older versions
43
+ continue rendering unchanged.
44
+
45
+ - **refactor:** Migrate the cesium HTTP server from a hand-rolled
46
+ `Bun.serve` fetch handler with a custom `preHandlers` middleware chain
47
+ to a Hono app fronted by Bun.serve. Routes for `/api/sessions/*` and
48
+ `/favicon.ico` now use Hono's typed `:param` syntax instead of regex
49
+ match + manual narrowing. The static file handler is preserved verbatim
50
+ and mounted as `app.notFound` so all 11 existing static-serve tests
51
+ pass unchanged. `ServerHandle.app: Hono` replaces `addHandler` for
52
+ callers that need to register additional routes.
53
+ - **refactor:** Migrate the `cesium` CLI from `node:util parseArgs` +
54
+ a hand-rolled subcommand dispatcher to Citty. Each command file now
55
+ exports a typed `runX(args, ctx)` inner function (kept directly
56
+ testable with injected `{stdout, stderr, loadConfig}`) plus an outer
57
+ `xxxCmd = defineCommand(...)` that Citty consumes. Subcommands load
58
+ lazily via dynamic `import()` so cold-start cost is paid only for the
59
+ command the user actually invoked. `cesium theme show|apply` is now
60
+ expressed as native Citty sub-subcommands rather than the previous
61
+ manual routing.
62
+ - **fix:** `cesium restart` no longer fails on serve-only flags. The
63
+ previous implementation passed argv through to both `stopCommand` and
64
+ `serveCommand` under `strict: true`, so `cesium restart --port 4000`
65
+ would have errored. Restart now defines its own arg schema covering
66
+ both stop and serve options.
67
+ - **chore:** Clean up all 25 oxlint warnings:
68
+ - Restructure `src/server/api.ts` regex matching to remove four
69
+ `!` non-null assertions.
70
+ - Replace `match![1]!` patterns in tests with a local `unwrap(value,
71
+ name)` helper for proper type narrowing.
72
+ - Replace `handle!.url` in tests with an explicit null check.
73
+ - Add targeted `eslint-disable-next-line no-await-in-loop` comments
74
+ (with `--` reason annotations matching the existing repo convention)
75
+ in places where sequential execution is required: middleware chain,
76
+ poll-with-backoff retry, short-circuit search.
77
+ - **chore:** Add `hono@4.12.18` and `citty@0.2.2` to dependencies.
78
+ Removes a meaningful amount of hand-rolled routing / arg-parsing code
79
+ for ~50KB total install footprint.
80
+ - **chore:** `cesium --version` output format changes from
81
+ `cesium 0.6.0` to plain `0.6.0` (Citty's default). The `cesium version`
82
+ subcommand is removed — use `cesium --version` or `cesium -v`. Auto-
83
+ generated help text for each command also follows Citty's formatting
84
+ (uppercase USAGE/OPTIONS, color codes, default annotations) rather
85
+ than the previous hand-aligned `Usage: cesium ls` strings.
86
+
3
87
  ## v0.5.2 — 2026-05-12
4
88
 
5
89
  A 16th block type — `diff` — that renders a beautiful side-by-side
package/README.md CHANGED
@@ -48,8 +48,8 @@ unreleased changes).
48
48
  ### CLI
49
49
 
50
50
  The CLI puts a `cesium` binary on your `PATH` for browsing, opening, and
51
- managing artifacts (`cesium ls`, `cesium open`, `cesium serve`, `cesium prune`,
52
- `cesium theme`).
51
+ managing artifacts (`cesium ls`, `cesium open`, `cesium export`, `cesium serve`,
52
+ `cesium prune`, `cesium theme`).
53
53
 
54
54
  **Recommended: install with [mise](https://mise.jdx.dev/)** so cesium is pinned
55
55
  in your config and tracks with the rest of your toolchain. Add to your
@@ -292,15 +292,24 @@ cesium open a7K9 --print # just print the URL
292
292
 
293
293
  ### Share an artifact
294
294
 
295
- Each artifact is a single self-contained `.html` file — no external resources.
296
- Three ways to share:
295
+ Each artifact is a single self-contained `.html` file — the full theme CSS is
296
+ baked into a `<style>` tag at generation time, so it renders correctly when
297
+ opened anywhere. Four ways to share:
297
298
 
299
+ - **Pipe it anywhere** — `cesium export <id-prefix>` dumps the file to stdout;
300
+ `cesium export <id-prefix> --out plan.html` writes it to disk. The output
301
+ is a portable HTML file you can attach to email, drop in a chat, or commit
302
+ to a repo. Opens correctly with no server running.
298
303
  - **Same machine** — copy or attach the `file://` path printed in the terminal.
299
304
  - **Over SSH** — forward the port with `ssh -L 3030:localhost:3030 your-host`,
300
305
  then send the `http://localhost:3030/...` URL.
301
306
  - **On a trusted LAN** — set `"hostname": "0.0.0.0"` in `cesium.json` and share
302
307
  the LAN URL. Only do this on networks you trust.
303
308
 
309
+ When an artifact is served by the cesium HTTP server, a `<link>` to the live
310
+ `theme.css` overrides the baked-in `<style>` so theme changes apply retroactively
311
+ to served pages. Standalone copies keep their generation-time look forever.
312
+
304
313
  ### Clean up old artifacts
305
314
 
306
315
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfbender/cesium",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
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"
@@ -0,0 +1,143 @@
1
+ // cesium export — emit an artifact's HTML to stdout (or a file).
2
+ //
3
+ // Because cesium artifacts are written with the full theme CSS baked into a
4
+ // <style> tag at generation time, an artifact file on disk is already a
5
+ // fully self-contained HTML document. Export is therefore a thin wrapper:
6
+ // resolve by id-prefix, read the file, write to stdout or --out.
7
+ //
8
+ // (When served by the cesium HTTP server, the <link rel="stylesheet"
9
+ // href=".../theme.css"> in the same artifact still loads and overrides the
10
+ // baked CSS in cascade order, so live theme changes apply server-side. The
11
+ // baked CSS only acts as the standalone fallback.)
12
+
13
+ import { defineCommand } from "citty";
14
+ import { join } from "node:path";
15
+ import { readFile } from "node:fs/promises";
16
+ import { loadConfig, type CesiumConfig } from "../../config.ts";
17
+ import { loadIndex } from "../../storage/index-cache.ts";
18
+ import { pathsFor } from "../../storage/paths.ts";
19
+ import { atomicWrite } from "../../storage/write.ts";
20
+
21
+ export interface ExportArgs {
22
+ idPrefix: string;
23
+ out: string | null;
24
+ }
25
+
26
+ export interface ExportContext {
27
+ stdout: { write: (s: string) => void };
28
+ stderr: { write: (s: string) => void };
29
+ loadConfig?: () => CesiumConfig;
30
+ }
31
+
32
+ function defaultCtx(): ExportContext {
33
+ return {
34
+ stdout: process.stdout,
35
+ stderr: process.stderr,
36
+ };
37
+ }
38
+
39
+ export async function runExport(
40
+ args: ExportArgs,
41
+ ctxOverride?: Partial<ExportContext>,
42
+ ): Promise<number> {
43
+ const ctx: ExportContext = { ...defaultCtx(), ...ctxOverride };
44
+
45
+ if (args.idPrefix.length === 0) {
46
+ ctx.stderr.write(`cesium export: missing required argument <id-prefix>\n`);
47
+ return 1;
48
+ }
49
+
50
+ const prefixLower = args.idPrefix.toLowerCase();
51
+ const cfg = (ctx.loadConfig ?? loadConfig)();
52
+
53
+ // Resolve artifact via global index (same matching as `open`)
54
+ const globalJsonPath = join(cfg.stateDir, "index.json");
55
+ let allEntries;
56
+ try {
57
+ allEntries = await loadIndex(globalJsonPath);
58
+ } catch (err) {
59
+ const e = err as Error;
60
+ ctx.stderr.write(`cesium export: failed to read index: ${e.message}\n`);
61
+ return 1;
62
+ }
63
+
64
+ const matches = allEntries.filter((e) => e.id.toLowerCase().startsWith(prefixLower));
65
+
66
+ if (matches.length === 0) {
67
+ ctx.stderr.write(`cesium export: no artifact found with id prefix "${args.idPrefix}"\n`);
68
+ return 1;
69
+ }
70
+
71
+ if (matches.length > 1) {
72
+ ctx.stderr.write(
73
+ `cesium export: ambiguous prefix "${args.idPrefix}" — ${matches.length} matches:\n`,
74
+ );
75
+ for (const m of matches) {
76
+ ctx.stderr.write(` ${m.id} ${m.title} (${m.kind})\n`);
77
+ }
78
+ return 2;
79
+ }
80
+
81
+ const entry = matches[0];
82
+ if (entry === undefined) {
83
+ // Unreachable; satisfies type checker
84
+ ctx.stderr.write(`cesium export: internal error — no match\n`);
85
+ return 1;
86
+ }
87
+
88
+ const paths = pathsFor({
89
+ stateDir: cfg.stateDir,
90
+ projectSlug: entry.projectSlug,
91
+ filename: entry.filename,
92
+ });
93
+
94
+ let html: string;
95
+ try {
96
+ html = await readFile(paths.artifactPath, "utf8");
97
+ } catch (err) {
98
+ const e = err as Error;
99
+ ctx.stderr.write(`cesium export: failed to read artifact: ${e.message}\n`);
100
+ return 1;
101
+ }
102
+
103
+ if (args.out !== null) {
104
+ try {
105
+ await atomicWrite(args.out, html);
106
+ } catch (err) {
107
+ const e = err as Error;
108
+ ctx.stderr.write(`cesium export: failed to write ${args.out}: ${e.message}\n`);
109
+ return 1;
110
+ }
111
+ ctx.stderr.write(`Wrote ${args.out}\n`);
112
+ return 0;
113
+ }
114
+
115
+ ctx.stdout.write(html);
116
+ return 0;
117
+ }
118
+
119
+ export const exportCmd = defineCommand({
120
+ meta: {
121
+ name: "export",
122
+ description: "Emit an artifact's self-contained HTML to stdout (or --out file).",
123
+ },
124
+ args: {
125
+ idPrefix: {
126
+ type: "positional",
127
+ description: "Artifact id prefix (any unique substring of the id)",
128
+ required: true,
129
+ },
130
+ out: {
131
+ type: "string",
132
+ alias: "o",
133
+ description: "Write to this file path instead of stdout",
134
+ },
135
+ },
136
+ async run({ args }) {
137
+ const code = await runExport({
138
+ idPrefix: args.idPrefix,
139
+ out: args.out ?? null,
140
+ });
141
+ if (code !== 0) process.exit(code);
142
+ },
143
+ });
@@ -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
+ });