@cfbender/cesium 0.3.5

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 (44) hide show
  1. package/ARCHITECTURE.md +304 -0
  2. package/CHANGELOG.md +335 -0
  3. package/LICENSE +21 -0
  4. package/README.md +479 -0
  5. package/agents/cesium.md +39 -0
  6. package/assets/styleguide.html +857 -0
  7. package/package.json +61 -0
  8. package/src/cli/commands/ls.ts +186 -0
  9. package/src/cli/commands/open.ts +208 -0
  10. package/src/cli/commands/prune.ts +348 -0
  11. package/src/cli/commands/restart.ts +38 -0
  12. package/src/cli/commands/serve.ts +214 -0
  13. package/src/cli/commands/stop.ts +130 -0
  14. package/src/cli/commands/theme.ts +333 -0
  15. package/src/cli/index.ts +78 -0
  16. package/src/config.ts +94 -0
  17. package/src/index.ts +35 -0
  18. package/src/prompt/system-fragment.md +97 -0
  19. package/src/render/client-js.ts +316 -0
  20. package/src/render/controls.ts +302 -0
  21. package/src/render/critique.ts +360 -0
  22. package/src/render/extract.ts +83 -0
  23. package/src/render/scrub.ts +141 -0
  24. package/src/render/theme.ts +712 -0
  25. package/src/render/validate.ts +524 -0
  26. package/src/render/wrap.ts +165 -0
  27. package/src/server/api.ts +166 -0
  28. package/src/server/http.ts +195 -0
  29. package/src/server/lifecycle.ts +331 -0
  30. package/src/server/stop.ts +124 -0
  31. package/src/storage/index-cache.ts +71 -0
  32. package/src/storage/index-gen.ts +447 -0
  33. package/src/storage/lock.ts +108 -0
  34. package/src/storage/mutate.ts +396 -0
  35. package/src/storage/paths.ts +159 -0
  36. package/src/storage/project-summaries.ts +19 -0
  37. package/src/storage/theme-write.ts +19 -0
  38. package/src/storage/write.ts +75 -0
  39. package/src/tools/ask.ts +353 -0
  40. package/src/tools/critique.ts +66 -0
  41. package/src/tools/publish.ts +404 -0
  42. package/src/tools/stop.ts +53 -0
  43. package/src/tools/styleguide.ts +23 -0
  44. package/src/tools/wait.ts +192 -0
@@ -0,0 +1,75 @@
1
+ // Atomic file write: write to a .tmp file, then rename to the final path.
2
+
3
+ import { mkdir, open, rename } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ import { randomBytes } from "node:crypto";
6
+
7
+ export async function atomicWrite(filePath: string, contents: string): Promise<void> {
8
+ const dir = dirname(filePath);
9
+ await mkdir(dir, { recursive: true });
10
+ const rand = randomBytes(6).toString("hex");
11
+ const tmpPath = `${filePath}.tmp.${rand}`;
12
+ const fh = await open(tmpPath, "w");
13
+ try {
14
+ await fh.writeFile(contents, "utf8");
15
+ await fh.datasync().catch(() => {
16
+ // best-effort fsync
17
+ });
18
+ } finally {
19
+ await fh.close();
20
+ }
21
+ await rename(tmpPath, filePath);
22
+ }
23
+
24
+ export interface EmbeddedMetadata {
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ const META_RE =
29
+ /<script\s[^>]*type="application\/json"[^>]*id="cesium-meta"[^>]*>([\s\S]*?)<\/script>/i;
30
+
31
+ export function readEmbeddedMetadata(html: string): EmbeddedMetadata | null {
32
+ const match = META_RE.exec(html);
33
+ if (!match) return null;
34
+ const raw = match[1];
35
+ if (raw === undefined) return null;
36
+ try {
37
+ const parsed: unknown = JSON.parse(raw);
38
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
39
+ return parsed as EmbeddedMetadata;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ export async function patchEmbeddedMetadata(
46
+ filePath: string,
47
+ patch: Partial<EmbeddedMetadata>,
48
+ ): Promise<void> {
49
+ const content = await Bun.file(filePath).text();
50
+ const match = META_RE.exec(content);
51
+ if (!match) throw new Error(`No cesium-meta script block found in: ${filePath}`);
52
+
53
+ const raw = match[1];
54
+ if (raw === undefined) throw new Error(`Empty cesium-meta script block in: ${filePath}`);
55
+
56
+ let existing: unknown;
57
+ try {
58
+ existing = JSON.parse(raw);
59
+ } catch (err) {
60
+ throw new Error(`Malformed cesium-meta JSON in: ${filePath}`, { cause: err });
61
+ }
62
+
63
+ if (existing === null || typeof existing !== "object" || Array.isArray(existing)) {
64
+ throw new Error(`cesium-meta JSON is not an object in: ${filePath}`);
65
+ }
66
+
67
+ const merged: EmbeddedMetadata = { ...(existing as EmbeddedMetadata), ...patch };
68
+ const newJson = JSON.stringify(merged, null, 2).replace(/<\/script>/gi, "<\\/script>");
69
+
70
+ const fullMatch = match[0];
71
+ const newBlock = fullMatch.replace(raw, newJson);
72
+ const newContent = content.replace(fullMatch, newBlock);
73
+
74
+ await atomicWrite(filePath, newContent);
75
+ }
@@ -0,0 +1,353 @@
1
+ // Tool handler for cesium_ask — publishes an interactive Q&A artifact.
2
+
3
+ import { createHash } from "node:crypto";
4
+ import { join } from "node:path";
5
+ import { tool } from "@opencode-ai/plugin";
6
+ import type { PluginInput } from "@opencode-ai/plugin";
7
+ import { loadConfig, type CesiumConfig } from "../config.ts";
8
+ import { scrub } from "../render/scrub.ts";
9
+ import { extractTextContent } from "../render/extract.ts";
10
+ import { themeFromPreset, mergeTheme } from "../render/theme.ts";
11
+ import { validateAskInput, htmlBodyWarnings } from "../render/validate.ts";
12
+ import { wrapDocument, type ArtifactMeta } from "../render/wrap.ts";
13
+ import type { InteractiveData } from "../render/validate.ts";
14
+ import { deriveProjectIdentity, artifactFilename, pathsFor } from "../storage/paths.ts";
15
+ import { atomicWrite } from "../storage/write.ts";
16
+ import { writeThemeCss } from "../storage/theme-write.ts";
17
+ import { loadIndex, writeIndex, appendEntry, type IndexEntry } from "../storage/index-cache.ts";
18
+ import { withLock } from "../storage/lock.ts";
19
+ import { renderProjectIndex, renderGlobalIndex } from "../storage/index-gen.ts";
20
+ import { buildProjectSummaries } from "../storage/project-summaries.ts";
21
+ import {
22
+ ensureRunning as defaultEnsureRunning,
23
+ type RunningInfo,
24
+ type LifecycleConfig,
25
+ } from "../server/lifecycle.ts";
26
+ import { buildTerminalSummary, resolveDisplayHost } from "./publish.ts";
27
+
28
+ export interface AskToolOverrides {
29
+ loadConfig?: () => CesiumConfig;
30
+ now?: () => Date;
31
+ nanoid?: () => string;
32
+ ensureRunning?: (cfg: LifecycleConfig) => Promise<RunningInfo | null>;
33
+ }
34
+
35
+ const TOOL_DESCRIPTION = `cesium_ask — Publish an interactive Q&A artifact and return its URL. Use this when you need
36
+ structured input from the user before producing a final artifact: design tradeoffs,
37
+ plan choices, confirmation gates, or any decision you'd otherwise type as a multi-choice
38
+ question into the terminal.
39
+
40
+ The artifact is a single self-contained .html file with the same look as cesium_publish
41
+ artifacts, plus interactive controls (radios, checkboxes, sliders, etc.). The user opens
42
+ the URL, clicks an answer, and the artifact crystallizes their choice into a permanent
43
+ record. After cesium_ask, call cesium_wait with the returned id to block until the user
44
+ finishes.
45
+
46
+ Question types: pick_one, pick_many, confirm, ask_text, slider, react. See cesium_styleguide
47
+ for the full schema.
48
+
49
+ DO NOT use for short yes/no questions you can ask in the terminal — those don't deserve
50
+ an artifact. Use cesium_ask for questions you'd want to remember later: shape decisions,
51
+ plan branches, design tradeoffs.`;
52
+
53
+ export function createAskTool(
54
+ ctx: PluginInput,
55
+ overrides?: AskToolOverrides,
56
+ ): ReturnType<typeof tool> {
57
+ const resolveConfig = overrides?.loadConfig ?? loadConfig;
58
+ const now = overrides?.now ?? (() => new Date());
59
+ const genId = overrides?.nanoid ?? defaultNanoid;
60
+ const runEnsureRunning = overrides?.ensureRunning ?? defaultEnsureRunning;
61
+
62
+ return tool({
63
+ description: TOOL_DESCRIPTION,
64
+ args: {
65
+ title: tool.schema.string(),
66
+ body: tool.schema.string(),
67
+ questions: tool.schema.array(
68
+ tool.schema.object({
69
+ type: tool.schema.enum([
70
+ "pick_one",
71
+ "pick_many",
72
+ "confirm",
73
+ "ask_text",
74
+ "slider",
75
+ "react",
76
+ ] as [string, ...string[]]),
77
+ id: tool.schema.string(),
78
+ question: tool.schema.string(),
79
+ // All type-specific fields are optional; runtime validation handles them
80
+ options: tool.schema
81
+ .array(
82
+ tool.schema.object({
83
+ id: tool.schema.string(),
84
+ label: tool.schema.string(),
85
+ description: tool.schema.string().optional(),
86
+ }),
87
+ )
88
+ .optional(),
89
+ recommended: tool.schema.string().optional(),
90
+ context: tool.schema.string().optional(),
91
+ min: tool.schema.number().optional(),
92
+ max: tool.schema.number().optional(),
93
+ step: tool.schema.number().optional(),
94
+ defaultValue: tool.schema.number().optional(),
95
+ yesLabel: tool.schema.string().optional(),
96
+ noLabel: tool.schema.string().optional(),
97
+ multiline: tool.schema.boolean().optional(),
98
+ placeholder: tool.schema.string().optional(),
99
+ mode: tool.schema.enum(["approve", "thumbs"] as [string, ...string[]]).optional(),
100
+ allowComment: tool.schema.boolean().optional(),
101
+ }),
102
+ ),
103
+ summary: tool.schema.string().optional(),
104
+ tags: tool.schema.array(tool.schema.string()).optional(),
105
+ expiresAt: tool.schema.string().optional(),
106
+ requireAll: tool.schema.boolean().optional(),
107
+ },
108
+ async execute(args, _context) {
109
+ // 1. Validate input
110
+ const validation = validateAskInput(args);
111
+ if (!validation.ok) {
112
+ return `Error: ${validation.error}`;
113
+ }
114
+ const input = validation.value;
115
+
116
+ // 2. Load config
117
+ const config = resolveConfig();
118
+
119
+ // 3. Probe git state
120
+ const shell = ctx.$.cwd(ctx.directory).nothrow();
121
+ let gitRemote: string | null = null;
122
+ let gitBranch: string | null = null;
123
+ let gitCommit: string | null = null;
124
+
125
+ try {
126
+ const remoteResult = await shell`git config --get remote.origin.url`.quiet();
127
+ if (remoteResult.exitCode === 0) {
128
+ gitRemote = remoteResult.text().trim() || null;
129
+ }
130
+ } catch {
131
+ // not a git repo or no remote
132
+ }
133
+
134
+ try {
135
+ const branchResult = await shell`git rev-parse --abbrev-ref HEAD`.quiet();
136
+ if (branchResult.exitCode === 0) {
137
+ gitBranch = branchResult.text().trim() || null;
138
+ }
139
+ } catch {
140
+ // not a git repo
141
+ }
142
+
143
+ try {
144
+ const commitResult = await shell`git rev-parse HEAD`.quiet();
145
+ if (commitResult.exitCode === 0) {
146
+ gitCommit = commitResult.text().trim() || null;
147
+ }
148
+ } catch {
149
+ // not a git repo
150
+ }
151
+
152
+ // 4. Derive project identity
153
+ const identity = deriveProjectIdentity({
154
+ cwd: ctx.directory,
155
+ gitRemote,
156
+ worktree: ctx.worktree ?? null,
157
+ });
158
+
159
+ // 5. Generate id
160
+ const id = genId();
161
+
162
+ // 6. Timestamps
163
+ const createdAt = now();
164
+
165
+ // 7. Compute expiresAt and requireAll with defaults
166
+ const expiresAt =
167
+ input.expiresAt ?? new Date(createdAt.getTime() + 24 * 60 * 60 * 1000).toISOString();
168
+ const requireAll = input.requireAll ?? true;
169
+
170
+ // 8. Scrub body HTML
171
+ const scrubbed = scrub(input.body);
172
+
173
+ // 8a. Extract body text for full-text search
174
+ const bodyText = extractTextContent(scrubbed.html);
175
+
176
+ // 9. Content SHA-256 (over the body)
177
+ const contentSha256 = createHash("sha256").update(scrubbed.html).digest("hex");
178
+
179
+ // 10. Compute filename + paths
180
+ const filename = artifactFilename({ title: input.title, id, createdAt });
181
+ const paths = pathsFor({
182
+ stateDir: config.stateDir,
183
+ projectSlug: identity.slug,
184
+ filename,
185
+ });
186
+
187
+ // 11. Build ArtifactMeta
188
+ const meta: ArtifactMeta = {
189
+ id,
190
+ title: input.title,
191
+ kind: "ask",
192
+ summary: input.summary ?? null,
193
+ tags: input.tags ?? [],
194
+ createdAt: createdAt.toISOString(),
195
+ model: null,
196
+ sessionId: null,
197
+ projectSlug: identity.slug,
198
+ projectName: identity.name,
199
+ cwd: ctx.directory,
200
+ worktree: identity.worktree,
201
+ gitBranch,
202
+ gitCommit,
203
+ supersedes: null,
204
+ supersededBy: null,
205
+ contentSha256,
206
+ };
207
+
208
+ // 12. Build interactive data
209
+ const interactive: InteractiveData = {
210
+ status: "open",
211
+ requireAll,
212
+ expiresAt,
213
+ questions: input.questions,
214
+ answers: {},
215
+ };
216
+
217
+ // 13. Build warnings
218
+ const warnings: string[] = [];
219
+ if (scrubbed.removed.length > 0) {
220
+ warnings.push(`Removed ${scrubbed.removed.length} external resource(s) during scrub.`);
221
+ }
222
+ const bodyWarnings = htmlBodyWarnings(scrubbed.html);
223
+ for (const w of bodyWarnings) {
224
+ warnings.push(w);
225
+ }
226
+
227
+ // 14. Build theme + wrap document
228
+ const theme = mergeTheme(themeFromPreset(config.themePreset), config.theme);
229
+
230
+ // 14a. Write theme.css (idempotent, outside index lock — separate file)
231
+ await writeThemeCss(config.stateDir, theme);
232
+
233
+ const fullHtml = wrapDocument({
234
+ body: scrubbed.html,
235
+ meta,
236
+ theme,
237
+ warnings,
238
+ themeCssHref: "../../../theme.css",
239
+ interactive,
240
+ });
241
+
242
+ // 15. Atomic write
243
+ await atomicWrite(paths.artifactPath, fullHtml);
244
+
245
+ // 16. Build IndexEntry
246
+ const entry: IndexEntry = {
247
+ id: meta.id,
248
+ title: meta.title,
249
+ kind: meta.kind,
250
+ summary: meta.summary,
251
+ tags: meta.tags,
252
+ createdAt: meta.createdAt,
253
+ filename,
254
+ supersedes: null,
255
+ supersededBy: null,
256
+ gitBranch: meta.gitBranch,
257
+ gitCommit: meta.gitCommit,
258
+ contentSha256: meta.contentSha256,
259
+ projectSlug: identity.slug,
260
+ projectName: identity.name,
261
+ bodyText,
262
+ };
263
+
264
+ const lockPath = join(config.stateDir, ".index.lock");
265
+
266
+ await withLock({ lockPath }, async () => {
267
+ // 17. Update per-project index
268
+ const projectEntries = await loadIndex(paths.projectIndexJsonPath);
269
+ const updatedProjectEntries = appendEntry(projectEntries, entry);
270
+ await writeIndex(paths.projectIndexJsonPath, updatedProjectEntries);
271
+
272
+ // 18. Update global index
273
+ const globalEntries = await loadIndex(paths.globalIndexJsonPath);
274
+ const updatedGlobalEntries = appendEntry(globalEntries, entry);
275
+ await writeIndex(paths.globalIndexJsonPath, updatedGlobalEntries);
276
+
277
+ // 19. Render and write project index.html
278
+ const latestProjectEntries = await loadIndex(paths.projectIndexJsonPath);
279
+ const projectIndexHtml = renderProjectIndex({
280
+ projectSlug: identity.slug,
281
+ projectName: identity.name,
282
+ entries: latestProjectEntries,
283
+ theme,
284
+ themeCssHref: "../../theme.css",
285
+ });
286
+ await atomicWrite(paths.projectIndexPath, projectIndexHtml);
287
+
288
+ // 20. Render and write global index.html
289
+ const latestGlobalEntries = await loadIndex(paths.globalIndexJsonPath);
290
+ const projectSummaries = buildProjectSummaries(latestGlobalEntries);
291
+ const globalIndexHtml = renderGlobalIndex({
292
+ projects: projectSummaries,
293
+ theme,
294
+ themeCssHref: "theme.css",
295
+ });
296
+ await atomicWrite(paths.globalIndexPath, globalIndexHtml);
297
+ });
298
+
299
+ // 21. Start server (best-effort) and build URLs
300
+ let httpUrl: string | null = null;
301
+ let serverInfo: RunningInfo | null = null;
302
+
303
+ try {
304
+ const maybeInfo = await runEnsureRunning({
305
+ stateDir: config.stateDir,
306
+ port: config.port,
307
+ portMax: config.portMax,
308
+ idleTimeoutMs: config.idleTimeoutMs,
309
+ hostname: config.hostname,
310
+ });
311
+ if (maybeInfo !== null) {
312
+ serverInfo = maybeInfo;
313
+ const liveDisplay = resolveDisplayHost(config.hostname);
314
+ httpUrl = `http://${liveDisplay}:${serverInfo.port}${paths.serverPath}`;
315
+ }
316
+ } catch {
317
+ httpUrl = null;
318
+ }
319
+
320
+ const terminalSummary = buildTerminalSummary({
321
+ title: input.title,
322
+ kind: "ask",
323
+ httpUrl: httpUrl ?? paths.fileUrl,
324
+ fileUrl: paths.fileUrl,
325
+ isSsh: Boolean(process.env["SSH_CONNECTION"]),
326
+ port: serverInfo?.port ?? config.port,
327
+ });
328
+
329
+ // 22. Return result
330
+ const result = {
331
+ id,
332
+ filePath: paths.artifactPath,
333
+ fileUrl: paths.fileUrl,
334
+ httpUrl,
335
+ terminalSummary,
336
+ };
337
+
338
+ return JSON.stringify(result, null, 2);
339
+ },
340
+ });
341
+ }
342
+
343
+ // Default nanoid implementation using built-in crypto for alphanumeric IDs
344
+ function defaultNanoid(): string {
345
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
346
+ const bytes = new Uint8Array(6);
347
+ crypto.getRandomValues(bytes);
348
+ let result = "";
349
+ for (const byte of bytes) {
350
+ result += alphabet[byte % alphabet.length];
351
+ }
352
+ return result;
353
+ }
@@ -0,0 +1,66 @@
1
+ // Tool handler for cesium_critique — runs the body analyzer and returns a human-readable report.
2
+
3
+ import { tool } from "@opencode-ai/plugin";
4
+ import type { PluginInput } from "@opencode-ai/plugin";
5
+ import { critique, type CritiqueResult, type CritiqueSeverity } from "../render/critique.ts";
6
+
7
+ const TOOL_DESCRIPTION = `Analyze a draft HTML body for adherence to the cesium design
8
+ system before publishing. Returns a 0-100 score and findings (warn/suggest/info).
9
+
10
+ Call this on complex artifacts (>500 words, plans/comparisons/explainers) BEFORE
11
+ calling cesium_publish. Address warn-level findings; suggest-level findings are
12
+ optional but usually worth applying. info-level findings are FYI.
13
+
14
+ The 'html' argument is the same body you'd pass to cesium_publish — body inner
15
+ HTML only, no <!doctype>/<html>/<head>/<body> wrappers.`;
16
+
17
+ /**
18
+ * Format a CritiqueResult into a concise human-readable string the agent can parse.
19
+ * Format:
20
+ * score: 87/100
21
+ *
22
+ * warn:
23
+ * - [external-resource] External resource will be stripped...
24
+ *
25
+ * suggest:
26
+ * - [no-tldr] Long artifact with no .tldr summary...
27
+ *
28
+ * info:
29
+ * - [code-without-highlights] Code blocks render without...
30
+ */
31
+ export function formatCritiqueForAgent(result: CritiqueResult): string {
32
+ const lines: string[] = [`score: ${result.score}/100`];
33
+
34
+ const bySeverity: Record<CritiqueSeverity, typeof result.findings> = {
35
+ warn: [],
36
+ suggest: [],
37
+ info: [],
38
+ };
39
+
40
+ for (const f of result.findings) {
41
+ bySeverity[f.severity].push(f);
42
+ }
43
+
44
+ for (const sev of ["warn", "suggest", "info"] as const) {
45
+ const group = bySeverity[sev];
46
+ if (group.length === 0) continue;
47
+ lines.push("");
48
+ lines.push(`${sev}:`);
49
+ for (const f of group) {
50
+ lines.push(`- [${f.code}] ${f.message}`);
51
+ }
52
+ }
53
+
54
+ return lines.join("\n");
55
+ }
56
+
57
+ export function createCritiqueTool(_ctx: PluginInput): ReturnType<typeof tool> {
58
+ return tool({
59
+ description: TOOL_DESCRIPTION,
60
+ args: { html: tool.schema.string() },
61
+ async execute(args) {
62
+ const result = critique(args.html);
63
+ return formatCritiqueForAgent(result);
64
+ },
65
+ });
66
+ }