@cfbender/cesium 0.5.1 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +97 -3
  2. package/README.md +8 -8
  3. package/package.json +19 -17
  4. package/src/cli/commands/ls.ts +62 -65
  5. package/src/cli/commands/open.ts +47 -62
  6. package/src/cli/commands/prune.ts +59 -71
  7. package/src/cli/commands/restart.ts +100 -12
  8. package/src/cli/commands/serve.ts +119 -116
  9. package/src/cli/commands/stop.ts +51 -84
  10. package/src/cli/commands/theme.ts +54 -92
  11. package/src/cli/index.ts +17 -70
  12. package/src/index.ts +4 -1
  13. package/src/prompt/field-reference.ts +2 -2
  14. package/src/prompt/system-fragment.md +46 -16
  15. package/src/render/blocks/catalog.ts +2 -0
  16. package/src/render/blocks/diff/myers.ts +221 -0
  17. package/src/render/blocks/diff/parse-unified.ts +101 -0
  18. package/src/render/blocks/highlight.ts +8 -11
  19. package/src/render/blocks/markdown.ts +28 -7
  20. package/src/render/blocks/render.ts +3 -0
  21. package/src/render/blocks/renderers/code.ts +1 -3
  22. package/src/render/blocks/renderers/compare-table.ts +3 -4
  23. package/src/render/blocks/renderers/diagram.ts +2 -5
  24. package/src/render/blocks/renderers/diff.ts +378 -0
  25. package/src/render/blocks/renderers/prose.ts +1 -2
  26. package/src/render/blocks/renderers/timeline.ts +2 -1
  27. package/src/render/blocks/themes/claret-dark.ts +1 -6
  28. package/src/render/blocks/themes/claret-light.ts +1 -6
  29. package/src/render/blocks/types.ts +13 -1
  30. package/src/render/blocks/validate-block.ts +19 -9
  31. package/src/render/theme.ts +149 -0
  32. package/src/render/validate.ts +53 -9
  33. package/src/server/api.ts +112 -124
  34. package/src/server/favicon.ts +8 -16
  35. package/src/server/http.ts +101 -106
  36. package/src/server/lifecycle.ts +12 -6
  37. package/src/storage/assets.ts +8 -10
  38. package/src/storage/index-gen.ts +2 -3
  39. package/src/storage/theme-write.ts +17 -3
  40. package/src/tools/publish.ts +1 -3
  41. package/src/tools/styleguide.ts +3 -7
  42. package/src/tools/wait.ts +1 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,99 @@
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
+
53
+ ## v0.5.2 — 2026-05-12
54
+
55
+ A 16th block type — `diff` — that renders a beautiful side-by-side
56
+ before/after code diff with curved SVG bezier connectors between the two
57
+ columns. JetBrains diff-viewer aesthetic. Plus tooling improvements for
58
+ hot-reload visual iteration during plugin development.
59
+
60
+ - **feat:** New `diff` block type. Two input arms (XOR):
61
+ - `patch`: literal unified diff string (e.g. from `git diff` output)
62
+ - `before`/`after`: paired text strings; server runs Myers O(ND) line
63
+ diff to compute the change set
64
+ - **feat:** Per-line shiki syntax highlighting is preserved through the
65
+ diff. The renderer recomposes before-side and after-side text, runs each
66
+ through `highlightCode`, then zips the styled line spans back into the
67
+ diff line list — so multi-line constructs (template strings, block
68
+ comments) tokenize correctly.
69
+ - **feat:** Visual rendering:
70
+ - Three-column grid (1fr | 60px | 1fr) with line numbers per side
71
+ - Subtle red/green line-tint backgrounds on remove/add lines
72
+ - 60px-wide SVG connector column draws semi-transparent cubic-bezier
73
+ ribbons connecting each remove region on the left to the corresponding
74
+ add region on the right; pure adds collapse to a teardrop pointing
75
+ into the left, pure removes mirror
76
+ - Optional file-header strip with filename + `+N -M` stats
77
+ - Optional caption strip below
78
+ - 720px breakpoint collapses to single-column with connector hidden
79
+ - **feat:** Theme tokens `--diff-add`, `--diff-remove`, `--diff-change`
80
+ added to all seven palette presets so colors fit each preset's character
81
+ (claret rose, warm clay, cool blue, etc.).
82
+ - **fix:** Diff connector SVG now matches the `padding: 8px 0` of the
83
+ side columns so the bezier paths line up exactly with their target
84
+ regions instead of sitting 8px high.
85
+ - **chore:** Project-local opencode config tracked: `.opencode/opencode.json`,
86
+ `.opencode/plugins/cesium.ts` (dev-loop shim that loads the working
87
+ tree's source instead of the published npm package), and
88
+ `.opencode/skills/cesium-preview/SKILL.md` (hot-reload visual iteration
89
+ workflow that bypasses the stale plugin host by importing render code
90
+ directly via bun and writing to /tmp).
91
+ - **chore:** `scripts/dogfood-diff.ts` reference preview script for the
92
+ diff block. Useful template for previewing other block work.
93
+ - **chore:** Apply oxfmt to 30 files that drifted out of format compliance
94
+ in v0.5.1 (no CI lint gate caught it). Pure cosmetic — line-wrapping,
95
+ italic style normalization, key ordering. No behavior changes.
96
+
3
97
  ## v0.5.1 — 2026-05-12
4
98
 
5
99
  Server-side syntax highlighting for `code` blocks via shiki, custom claret
@@ -10,11 +104,11 @@ longer kill the plugin host process.
10
104
  - **fix (critical):** Lazy-started cesium server now runs as a detached
11
105
  subprocess. Previously `ensureRunning` (called from publish/ask plugin
12
106
  paths) ran `Bun.serve()` in-process and wrote `pid: process.pid` to the PID
13
- file — meaning that PID was the *plugin host* (e.g. opencode). Any
107
+ file — meaning that PID was the _plugin host_ (e.g. opencode). Any
14
108
  invocation of `cesium stop` (CLI, tool, or test) would signal the host
15
109
  process and kill it. Now lazy-start spawns `bun run cli serve` as a
16
110
  detached child; the PID file points at that child. Foreground `cesium
17
- serve` still runs in-process (correct for its semantics).
111
+ serve` still runs in-process (correct for its semantics).
18
112
  - **api:** Split `ensureRunning` into `runServerForeground` (in-process, for
19
113
  the foreground CLI) and `ensureServerRunning` (detached subprocess, for
20
114
  plugins). `ensureRunning` is kept as a backward-compat alias for
@@ -82,7 +176,7 @@ tokens, more on heavily structured artifacts.
82
176
  surfaced as a small badge on index cards.
83
177
  - **feat:** Framework CSS extended with rules for every block-renderer
84
178
  pattern: `dl.kv` (2-column grid), `.pill-row`, `.check-list`, `<hr
85
- data-label>`, `figure.code`, timeline-item internals, `.lede`, plus
179
+ data-label>`, `figure.code`, timeline-item internals, `.lede`, plus
86
180
  `.diagram svg text { fill: currentColor }` so SVGs inherit theme color.
87
181
  - **feat:** `escapeHtml` and `escapeAttr` throw a clear error on non-string
88
182
  input instead of crashing inside `.replace()`.
package/README.md CHANGED
@@ -382,15 +382,15 @@ state directory, hostname, and theme settings flow through.
382
382
 
383
383
  Optional `~/.config/opencode/cesium.json`:
384
384
 
385
- | Key | Type | Default | Description |
386
- | --------------- | ------ | ----------------------- | ---------------------------------------------------------------------------------------- |
387
- | `stateDir` | string | `~/.local/state/cesium` | Where artifacts and indexes live |
388
- | `port` | number | `3030` | First port to try for the local HTTP server |
389
- | `portMax` | number | `3050` | Upper bound when scanning for free ports |
390
- | `hostname` | string | `127.0.0.1` | Bind address. Use `0.0.0.0` to expose on the LAN |
385
+ | Key | Type | Default | Description |
386
+ | --------------- | ------ | ----------------------- | ------------------------------------------------------------------------------------------- |
387
+ | `stateDir` | string | `~/.local/state/cesium` | Where artifacts and indexes live |
388
+ | `port` | number | `3030` | First port to try for the local HTTP server |
389
+ | `portMax` | number | `3050` | Upper bound when scanning for free ports |
390
+ | `hostname` | string | `127.0.0.1` | Bind address. Use `0.0.0.0` to expose on the LAN |
391
391
  | `idleTimeoutMs` | number | `1800000` | Plugin server idle-shutdown threshold (30 min). Does not apply to foreground `cesium serve` |
392
- | `themePreset` | string | `"claret-dark"` | Named color palette (`claret-dark`/`claret-light`/`claret`/`warm`/`cool`/`mono`/`paper`) |
393
- | `theme` | object | (claret-dark palette) | Per-token color overrides (stacked on preset) |
392
+ | `themePreset` | string | `"claret-dark"` | Named color palette (`claret-dark`/`claret-light`/`claret`/`warm`/`cool`/`mono`/`paper`) |
393
+ | `theme` | object | (claret-dark palette) | Per-token color overrides (stacked on preset) |
394
394
 
395
395
  Environment overrides: `CESIUM_PORT`, `CESIUM_STATE_DIR`, `CESIUM_HOSTNAME`, `CESIUM_THEME_PRESET`.
396
396
 
package/package.json CHANGED
@@ -1,29 +1,27 @@
1
1
  {
2
2
  "name": "@cfbender/cesium",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Beautiful self-contained HTML artifacts from your opencode agent.",
5
- "license": "MIT",
6
- "author": "Cody Bender",
5
+ "keywords": [
6
+ "agent",
7
+ "artifact",
8
+ "claret",
9
+ "cli",
10
+ "html",
11
+ "opencode",
12
+ "plugin"
13
+ ],
7
14
  "homepage": "https://github.com/cfbender/cesium#readme",
8
15
  "bugs": "https://github.com/cfbender/cesium/issues",
16
+ "license": "MIT",
17
+ "author": "Cody Bender",
9
18
  "repository": {
10
19
  "type": "git",
11
20
  "url": "git+https://github.com/cfbender/cesium.git"
12
21
  },
13
- "keywords": [
14
- "opencode",
15
- "agent",
16
- "html",
17
- "artifact",
18
- "plugin",
19
- "cli",
20
- "claret"
21
- ],
22
22
  "bin": {
23
23
  "cesium": "./src/cli/index.ts"
24
24
  },
25
- "type": "module",
26
- "main": "src/index.ts",
27
25
  "files": [
28
26
  "src",
29
27
  "assets/styleguide.html",
@@ -31,12 +29,11 @@
31
29
  "ARCHITECTURE.md",
32
30
  "CHANGELOG.md"
33
31
  ],
32
+ "type": "module",
33
+ "main": "src/index.ts",
34
34
  "publishConfig": {
35
35
  "access": "public"
36
36
  },
37
- "engines": {
38
- "bun": ">=1.0.0"
39
- },
40
37
  "scripts": {
41
38
  "test": "bun test",
42
39
  "typecheck": "tsc --noEmit",
@@ -49,6 +46,8 @@
49
46
  },
50
47
  "dependencies": {
51
48
  "@opencode-ai/plugin": "latest",
49
+ "citty": "^0.2.2",
50
+ "hono": "^4.12.18",
52
51
  "nanoid": "^5.0.0",
53
52
  "parse5": "^7.1.0",
54
53
  "shiki": "^4.0.2"
@@ -58,5 +57,8 @@
58
57
  "oxfmt": "^0.48.0",
59
58
  "oxlint": "^1.63.0",
60
59
  "typescript": "^5.4.0"
60
+ },
61
+ "engines": {
62
+ "bun": ">=1.0.0"
61
63
  }
62
64
  }
@@ -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
+ });