@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.
- package/ARCHITECTURE.md +304 -0
- package/CHANGELOG.md +335 -0
- package/LICENSE +21 -0
- package/README.md +479 -0
- package/agents/cesium.md +39 -0
- package/assets/styleguide.html +857 -0
- package/package.json +61 -0
- package/src/cli/commands/ls.ts +186 -0
- package/src/cli/commands/open.ts +208 -0
- package/src/cli/commands/prune.ts +348 -0
- package/src/cli/commands/restart.ts +38 -0
- package/src/cli/commands/serve.ts +214 -0
- package/src/cli/commands/stop.ts +130 -0
- package/src/cli/commands/theme.ts +333 -0
- package/src/cli/index.ts +78 -0
- package/src/config.ts +94 -0
- package/src/index.ts +35 -0
- package/src/prompt/system-fragment.md +97 -0
- package/src/render/client-js.ts +316 -0
- package/src/render/controls.ts +302 -0
- package/src/render/critique.ts +360 -0
- package/src/render/extract.ts +83 -0
- package/src/render/scrub.ts +141 -0
- package/src/render/theme.ts +712 -0
- package/src/render/validate.ts +524 -0
- package/src/render/wrap.ts +165 -0
- package/src/server/api.ts +166 -0
- package/src/server/http.ts +195 -0
- package/src/server/lifecycle.ts +331 -0
- package/src/server/stop.ts +124 -0
- package/src/storage/index-cache.ts +71 -0
- package/src/storage/index-gen.ts +447 -0
- package/src/storage/lock.ts +108 -0
- package/src/storage/mutate.ts +396 -0
- package/src/storage/paths.ts +159 -0
- package/src/storage/project-summaries.ts +19 -0
- package/src/storage/theme-write.ts +19 -0
- package/src/storage/write.ts +75 -0
- package/src/tools/ask.ts +353 -0
- package/src/tools/critique.ts +66 -0
- package/src/tools/publish.ts +404 -0
- package/src/tools/stop.ts +53 -0
- package/src/tools/styleguide.ts +23 -0
- package/src/tools/wait.ts +192 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
// Tool handler for cesium_publish — validates input, delegates to render + storage.
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { networkInterfaces } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tool } from "@opencode-ai/plugin";
|
|
7
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
8
|
+
import { loadConfig, type CesiumConfig } from "../config.ts";
|
|
9
|
+
import { scrub } from "../render/scrub.ts";
|
|
10
|
+
import { extractTextContent } from "../render/extract.ts";
|
|
11
|
+
import { themeFromPreset, mergeTheme } from "../render/theme.ts";
|
|
12
|
+
import { validatePublishInput, htmlBodyWarnings, PUBLISH_KINDS } from "../render/validate.ts";
|
|
13
|
+
import { wrapDocument, type ArtifactMeta } from "../render/wrap.ts";
|
|
14
|
+
import { deriveProjectIdentity, artifactFilename, pathsFor } from "../storage/paths.ts";
|
|
15
|
+
import { atomicWrite, patchEmbeddedMetadata } from "../storage/write.ts";
|
|
16
|
+
import { writeThemeCss } from "../storage/theme-write.ts";
|
|
17
|
+
import {
|
|
18
|
+
loadIndex,
|
|
19
|
+
writeIndex,
|
|
20
|
+
appendEntry,
|
|
21
|
+
patchEntry,
|
|
22
|
+
type IndexEntry,
|
|
23
|
+
} from "../storage/index-cache.ts";
|
|
24
|
+
import { withLock } from "../storage/lock.ts";
|
|
25
|
+
import { renderProjectIndex, renderGlobalIndex } from "../storage/index-gen.ts";
|
|
26
|
+
import { buildProjectSummaries } from "../storage/project-summaries.ts";
|
|
27
|
+
import {
|
|
28
|
+
ensureRunning as defaultEnsureRunning,
|
|
29
|
+
type RunningInfo,
|
|
30
|
+
type LifecycleConfig,
|
|
31
|
+
} from "../server/lifecycle.ts";
|
|
32
|
+
|
|
33
|
+
export interface PublishToolOverrides {
|
|
34
|
+
loadConfig?: () => CesiumConfig;
|
|
35
|
+
now?: () => Date;
|
|
36
|
+
nanoid?: () => string;
|
|
37
|
+
ensureRunning?: (cfg: LifecycleConfig) => Promise<RunningInfo | null>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TerminalSummaryArgs {
|
|
41
|
+
title: string;
|
|
42
|
+
kind: string;
|
|
43
|
+
httpUrl: string;
|
|
44
|
+
fileUrl: string;
|
|
45
|
+
isSsh: boolean;
|
|
46
|
+
port: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildTerminalSummary(args: TerminalSummaryArgs): string {
|
|
50
|
+
const { title, kind, httpUrl, fileUrl, isSsh, port } = args;
|
|
51
|
+
let summary = `Cesium · ${title} (${kind})\n ${httpUrl}\n ${fileUrl}`;
|
|
52
|
+
if (isSsh) {
|
|
53
|
+
summary += `\n\n (remote: forward port → ssh -L ${port}:localhost:${port} <host>)`;
|
|
54
|
+
}
|
|
55
|
+
return summary;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const TOOL_DESCRIPTION = `Publish a beautiful self-contained HTML document to the cesium artifacts directory.
|
|
59
|
+
Use this when responding with substantive content — plans, code reviews, comparisons,
|
|
60
|
+
reports, explainers, audits, design proposals — that the user is likely to re-read or share.
|
|
61
|
+
|
|
62
|
+
When to use cesium_publish (vs replying in terminal):
|
|
63
|
+
- Response would be ≥ ~400 words, OR
|
|
64
|
+
- Contains a comparison/decision matrix, OR
|
|
65
|
+
- Multi-section plan/PRD/RFC/design doc, OR
|
|
66
|
+
- Code review with > 3 findings, OR
|
|
67
|
+
- Anything the user might revisit later.
|
|
68
|
+
|
|
69
|
+
When to stay in the terminal: short factual answers, status updates ("done", "fixed",
|
|
70
|
+
"running tests"), mid-tool-call chatter, single-paragraph replies.
|
|
71
|
+
|
|
72
|
+
User overrides win: "/cesium" or "publish this" → publish; "in terminal" → don't.
|
|
73
|
+
|
|
74
|
+
The \`html\` argument is the BODY ONLY — do NOT include <!doctype>, <html>, <head>, <body>.
|
|
75
|
+
The plugin wraps your body with the design system.
|
|
76
|
+
|
|
77
|
+
Available CSS classes (call cesium_styleguide for full reference + examples):
|
|
78
|
+
- .eyebrow uppercase mono micro-label above headings
|
|
79
|
+
- .h-display page-title heading
|
|
80
|
+
- .h-section section heading paired with .section-num
|
|
81
|
+
- .section-num numbered chip ("01", "02") next to .h-section
|
|
82
|
+
- .card bordered surface block (1.5px border, 12px radius)
|
|
83
|
+
- .tldr clay-bordered summary box (use ONE per doc, near top)
|
|
84
|
+
- .callout info box; modifiers: .callout.note .callout.warn .callout.risk
|
|
85
|
+
- .code block-level code panel; inline highlights via .kw .str .cm .fn
|
|
86
|
+
- .timeline milestone list with dots and connectors
|
|
87
|
+
- .diagram wraps inline SVG with a caption below
|
|
88
|
+
- .compare-table bordered comparison grid
|
|
89
|
+
- .risk-table bordered risk-grid (likelihood/impact/mitigation columns)
|
|
90
|
+
- .kbd .pill .tag inline chips
|
|
91
|
+
- .byline rendered automatically as the footer
|
|
92
|
+
|
|
93
|
+
Inline \`style="..."\` and inline \`<svg>\` are encouraged for bespoke diagrams. NEVER reference
|
|
94
|
+
external resources: no <script src=>, no <link rel=stylesheet href=http>, no remote fonts,
|
|
95
|
+
no remote images. The plugin will silently strip external resources but the artifact will
|
|
96
|
+
look broken in the resulting render.
|
|
97
|
+
|
|
98
|
+
Title aim: 3-8 words, descriptive. Kind: pick the closest match.`;
|
|
99
|
+
|
|
100
|
+
export function createPublishTool(
|
|
101
|
+
ctx: PluginInput,
|
|
102
|
+
overrides?: PublishToolOverrides,
|
|
103
|
+
): ReturnType<typeof tool> {
|
|
104
|
+
const resolveConfig = overrides?.loadConfig ?? loadConfig;
|
|
105
|
+
const now = overrides?.now ?? (() => new Date());
|
|
106
|
+
const genId = overrides?.nanoid ?? defaultNanoid;
|
|
107
|
+
const runEnsureRunning = overrides?.ensureRunning ?? defaultEnsureRunning;
|
|
108
|
+
|
|
109
|
+
return tool({
|
|
110
|
+
description: TOOL_DESCRIPTION,
|
|
111
|
+
args: {
|
|
112
|
+
title: tool.schema.string(),
|
|
113
|
+
kind: tool.schema.enum([...PUBLISH_KINDS] as [string, ...string[]]),
|
|
114
|
+
html: tool.schema.string(),
|
|
115
|
+
summary: tool.schema.string().optional(),
|
|
116
|
+
tags: tool.schema.array(tool.schema.string()).optional(),
|
|
117
|
+
supersedes: tool.schema.string().optional(),
|
|
118
|
+
},
|
|
119
|
+
async execute(args, _context) {
|
|
120
|
+
// 1. Validate input
|
|
121
|
+
const validation = validatePublishInput(args);
|
|
122
|
+
if (!validation.ok) {
|
|
123
|
+
return `Error: ${validation.error}`;
|
|
124
|
+
}
|
|
125
|
+
const input = validation.value;
|
|
126
|
+
|
|
127
|
+
// 2. Load config
|
|
128
|
+
const config = resolveConfig();
|
|
129
|
+
|
|
130
|
+
// 3. Probe git state
|
|
131
|
+
const shell = ctx.$.cwd(ctx.directory).nothrow();
|
|
132
|
+
let gitRemote: string | null = null;
|
|
133
|
+
let gitBranch: string | null = null;
|
|
134
|
+
let gitCommit: string | null = null;
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const remoteResult = await shell`git config --get remote.origin.url`.quiet();
|
|
138
|
+
if (remoteResult.exitCode === 0) {
|
|
139
|
+
gitRemote = remoteResult.text().trim() || null;
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
// not a git repo or no remote
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const branchResult = await shell`git rev-parse --abbrev-ref HEAD`.quiet();
|
|
147
|
+
if (branchResult.exitCode === 0) {
|
|
148
|
+
gitBranch = branchResult.text().trim() || null;
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// not a git repo
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const commitResult = await shell`git rev-parse HEAD`.quiet();
|
|
156
|
+
if (commitResult.exitCode === 0) {
|
|
157
|
+
gitCommit = commitResult.text().trim() || null;
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// not a git repo
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 4. Derive project identity
|
|
164
|
+
const identity = deriveProjectIdentity({
|
|
165
|
+
cwd: ctx.directory,
|
|
166
|
+
gitRemote,
|
|
167
|
+
worktree: ctx.worktree ?? null,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// 5. Generate id
|
|
171
|
+
const id = genId();
|
|
172
|
+
|
|
173
|
+
// 6. Timestamps
|
|
174
|
+
const createdAt = now();
|
|
175
|
+
|
|
176
|
+
// 7. Scrub
|
|
177
|
+
const scrubbed = scrub(input.html);
|
|
178
|
+
|
|
179
|
+
// 7a. Extract body text for full-text search
|
|
180
|
+
const bodyText = extractTextContent(scrubbed.html);
|
|
181
|
+
|
|
182
|
+
// 8. Compute filename + paths
|
|
183
|
+
const filename = artifactFilename({ title: input.title, id, createdAt });
|
|
184
|
+
const paths = pathsFor({
|
|
185
|
+
stateDir: config.stateDir,
|
|
186
|
+
projectSlug: identity.slug,
|
|
187
|
+
filename,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// 9. Content SHA-256
|
|
191
|
+
const contentSha256 = createHash("sha256").update(scrubbed.html).digest("hex");
|
|
192
|
+
|
|
193
|
+
// 10. Build ArtifactMeta
|
|
194
|
+
const meta: ArtifactMeta = {
|
|
195
|
+
id,
|
|
196
|
+
title: input.title,
|
|
197
|
+
kind: input.kind,
|
|
198
|
+
summary: input.summary ?? null,
|
|
199
|
+
tags: input.tags ?? [],
|
|
200
|
+
createdAt: createdAt.toISOString(),
|
|
201
|
+
model: null,
|
|
202
|
+
sessionId: null,
|
|
203
|
+
projectSlug: identity.slug,
|
|
204
|
+
projectName: identity.name,
|
|
205
|
+
cwd: ctx.directory,
|
|
206
|
+
worktree: identity.worktree,
|
|
207
|
+
gitBranch,
|
|
208
|
+
gitCommit,
|
|
209
|
+
supersedes: input.supersedes ?? null,
|
|
210
|
+
supersededBy: null,
|
|
211
|
+
contentSha256,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// 11. Build warnings
|
|
215
|
+
const warnings: string[] = [];
|
|
216
|
+
if (scrubbed.removed.length > 0) {
|
|
217
|
+
warnings.push(`Removed ${scrubbed.removed.length} external resource(s) during scrub.`);
|
|
218
|
+
}
|
|
219
|
+
const bodyWarnings = htmlBodyWarnings(scrubbed.html);
|
|
220
|
+
for (const w of bodyWarnings) {
|
|
221
|
+
warnings.push(w);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 12. Build theme + wrap document
|
|
225
|
+
const theme = mergeTheme(themeFromPreset(config.themePreset), config.theme);
|
|
226
|
+
|
|
227
|
+
// 12a. Write theme.css (idempotent, outside index lock — separate file)
|
|
228
|
+
await writeThemeCss(config.stateDir, theme);
|
|
229
|
+
|
|
230
|
+
const fullHtml = wrapDocument({
|
|
231
|
+
body: scrubbed.html,
|
|
232
|
+
meta,
|
|
233
|
+
theme,
|
|
234
|
+
warnings,
|
|
235
|
+
themeCssHref: "../../../theme.css",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// 13. Atomic write
|
|
239
|
+
await atomicWrite(paths.artifactPath, fullHtml);
|
|
240
|
+
|
|
241
|
+
// 14. Build IndexEntry
|
|
242
|
+
const entry: IndexEntry = {
|
|
243
|
+
id: meta.id,
|
|
244
|
+
title: meta.title,
|
|
245
|
+
kind: meta.kind,
|
|
246
|
+
summary: meta.summary,
|
|
247
|
+
tags: meta.tags,
|
|
248
|
+
createdAt: meta.createdAt,
|
|
249
|
+
filename,
|
|
250
|
+
supersedes: meta.supersedes,
|
|
251
|
+
supersededBy: null,
|
|
252
|
+
gitBranch: meta.gitBranch,
|
|
253
|
+
gitCommit: meta.gitCommit,
|
|
254
|
+
contentSha256: meta.contentSha256,
|
|
255
|
+
projectSlug: identity.slug,
|
|
256
|
+
projectName: identity.name,
|
|
257
|
+
bodyText,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const lockPath = join(config.stateDir, ".index.lock");
|
|
261
|
+
|
|
262
|
+
await withLock({ lockPath }, async () => {
|
|
263
|
+
// 15. Update per-project index
|
|
264
|
+
const projectEntries = await loadIndex(paths.projectIndexJsonPath);
|
|
265
|
+
const updatedProjectEntries = appendEntry(projectEntries, entry);
|
|
266
|
+
await writeIndex(paths.projectIndexJsonPath, updatedProjectEntries);
|
|
267
|
+
|
|
268
|
+
// 16. Update global index
|
|
269
|
+
const globalEntries = await loadIndex(paths.globalIndexJsonPath);
|
|
270
|
+
const updatedGlobalEntries = appendEntry(globalEntries, entry);
|
|
271
|
+
await writeIndex(paths.globalIndexJsonPath, updatedGlobalEntries);
|
|
272
|
+
|
|
273
|
+
// 17. Handle supersedes chain
|
|
274
|
+
if (input.supersedes) {
|
|
275
|
+
const prevId = input.supersedes;
|
|
276
|
+
const prevEntryIdx = updatedProjectEntries.findIndex((e) => e.id === prevId);
|
|
277
|
+
if (prevEntryIdx !== -1) {
|
|
278
|
+
const prevEntry = updatedProjectEntries[prevEntryIdx];
|
|
279
|
+
if (prevEntry !== undefined) {
|
|
280
|
+
const prevFilename = prevEntry.filename;
|
|
281
|
+
const prevPaths = pathsFor({
|
|
282
|
+
stateDir: config.stateDir,
|
|
283
|
+
projectSlug: identity.slug,
|
|
284
|
+
filename: prevFilename,
|
|
285
|
+
});
|
|
286
|
+
// Patch embedded metadata in the previous artifact file
|
|
287
|
+
try {
|
|
288
|
+
await patchEmbeddedMetadata(prevPaths.artifactPath, { supersededBy: id });
|
|
289
|
+
} catch {
|
|
290
|
+
// File may not exist on disk (e.g. cleaned up); log but don't fail
|
|
291
|
+
}
|
|
292
|
+
// Patch the index entry
|
|
293
|
+
const patchedEntries = patchEntry(updatedProjectEntries, prevId, {
|
|
294
|
+
supersededBy: id,
|
|
295
|
+
});
|
|
296
|
+
await writeIndex(paths.projectIndexJsonPath, patchedEntries);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// If not found — publish still succeeds, warn is logged implicitly
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 18. Render and write project index.html
|
|
303
|
+
const latestProjectEntries = await loadIndex(paths.projectIndexJsonPath);
|
|
304
|
+
const projectIndexHtml = renderProjectIndex({
|
|
305
|
+
projectSlug: identity.slug,
|
|
306
|
+
projectName: identity.name,
|
|
307
|
+
entries: latestProjectEntries,
|
|
308
|
+
theme,
|
|
309
|
+
themeCssHref: "../../theme.css",
|
|
310
|
+
});
|
|
311
|
+
await atomicWrite(paths.projectIndexPath, projectIndexHtml);
|
|
312
|
+
|
|
313
|
+
// 19. Render and write global index.html
|
|
314
|
+
const latestGlobalEntries = await loadIndex(paths.globalIndexJsonPath);
|
|
315
|
+
const projectSummaries = buildProjectSummaries(latestGlobalEntries);
|
|
316
|
+
const globalIndexHtml = renderGlobalIndex({
|
|
317
|
+
projects: projectSummaries,
|
|
318
|
+
theme,
|
|
319
|
+
themeCssHref: "theme.css",
|
|
320
|
+
});
|
|
321
|
+
await atomicWrite(paths.globalIndexPath, globalIndexHtml);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// 20. Start server (best-effort) and build URLs
|
|
325
|
+
const displayHost = resolveDisplayHost(config.hostname);
|
|
326
|
+
let httpUrl = `http://${displayHost}:${config.port}${paths.serverPath}`;
|
|
327
|
+
let indexUrl = `http://${displayHost}:${config.port}/projects/${identity.slug}/index.html`;
|
|
328
|
+
let serverInfo: RunningInfo | null = null;
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const maybeInfo = await runEnsureRunning({
|
|
332
|
+
stateDir: config.stateDir,
|
|
333
|
+
port: config.port,
|
|
334
|
+
portMax: config.portMax,
|
|
335
|
+
idleTimeoutMs: config.idleTimeoutMs,
|
|
336
|
+
hostname: config.hostname,
|
|
337
|
+
});
|
|
338
|
+
if (maybeInfo !== null) {
|
|
339
|
+
serverInfo = maybeInfo;
|
|
340
|
+
const liveDisplay = resolveDisplayHost(config.hostname);
|
|
341
|
+
httpUrl = `http://${liveDisplay}:${serverInfo.port}${paths.serverPath}`;
|
|
342
|
+
indexUrl = `http://${liveDisplay}:${serverInfo.port}/projects/${identity.slug}/index.html`;
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
// Server failed to start — proceed without; user can still use file:// URL.
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const terminalSummary = buildTerminalSummary({
|
|
349
|
+
title: input.title,
|
|
350
|
+
kind: input.kind,
|
|
351
|
+
httpUrl,
|
|
352
|
+
fileUrl: paths.fileUrl,
|
|
353
|
+
isSsh: Boolean(process.env["SSH_CONNECTION"]),
|
|
354
|
+
port: serverInfo?.port ?? config.port,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// 21. Return result
|
|
358
|
+
const result = {
|
|
359
|
+
id,
|
|
360
|
+
filePath: paths.artifactPath,
|
|
361
|
+
fileUrl: paths.fileUrl,
|
|
362
|
+
httpUrl,
|
|
363
|
+
indexUrl,
|
|
364
|
+
terminalSummary,
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
return JSON.stringify(result, null, 2);
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Default nanoid implementation using built-in crypto for alphanumeric IDs
|
|
373
|
+
function defaultNanoid(): string {
|
|
374
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
375
|
+
const bytes = new Uint8Array(6);
|
|
376
|
+
crypto.getRandomValues(bytes);
|
|
377
|
+
let result = "";
|
|
378
|
+
for (const byte of bytes) {
|
|
379
|
+
result += alphabet[byte % alphabet.length];
|
|
380
|
+
}
|
|
381
|
+
return result;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Pick a host for display URLs. When binding 0.0.0.0 (any-interface), substitute
|
|
385
|
+
// the first non-loopback IPv4 we can find so the URL is actually reachable from
|
|
386
|
+
// other machines on the LAN. For loopback/named bindings, just translate
|
|
387
|
+
// 127.0.0.1 to "localhost" for friendliness; everything else is used verbatim.
|
|
388
|
+
export function resolveDisplayHost(bindHost: string): string {
|
|
389
|
+
if (bindHost === "0.0.0.0" || bindHost === "::") {
|
|
390
|
+
const ifaces = networkInterfaces();
|
|
391
|
+
for (const list of Object.values(ifaces)) {
|
|
392
|
+
for (const iface of list ?? []) {
|
|
393
|
+
if (iface.family === "IPv4" && !iface.internal) {
|
|
394
|
+
return iface.address;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return "localhost";
|
|
399
|
+
}
|
|
400
|
+
if (bindHost === "127.0.0.1" || bindHost === "::1") {
|
|
401
|
+
return "localhost";
|
|
402
|
+
}
|
|
403
|
+
return bindHost;
|
|
404
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Tool handler for cesium_stop — stops the running cesium HTTP server cross-process.
|
|
2
|
+
|
|
3
|
+
import { tool } from "@opencode-ai/plugin";
|
|
4
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
5
|
+
import { loadConfig } from "../config.ts";
|
|
6
|
+
import { stopServer } from "../server/stop.ts";
|
|
7
|
+
import type { StopOutcome } from "../server/stop.ts";
|
|
8
|
+
|
|
9
|
+
const TOOL_DESCRIPTION = `Stop the running cesium HTTP server.
|
|
10
|
+
|
|
11
|
+
Idempotent: returns a clear message when no server is running. Sends SIGTERM
|
|
12
|
+
with a 3s grace period, then SIGKILL if the process hasn't exited. The agent
|
|
13
|
+
can call this when:
|
|
14
|
+
- The user explicitly asks to stop or restart the cesium server.
|
|
15
|
+
- The user is changing config (port, hostname, theme) and wants the new server
|
|
16
|
+
to start fresh on the next publish.
|
|
17
|
+
- Cleanup at session end is desired.
|
|
18
|
+
|
|
19
|
+
Note: the next call to cesium_publish will lazy-start a new server, so calling
|
|
20
|
+
this between publishes effectively cycles the server with the latest config.`;
|
|
21
|
+
|
|
22
|
+
export function formatStopOutcome(outcome: StopOutcome): string {
|
|
23
|
+
switch (outcome.kind) {
|
|
24
|
+
case "not-running":
|
|
25
|
+
return "no cesium server running";
|
|
26
|
+
case "stale":
|
|
27
|
+
return "server not running (stale PID file removed)";
|
|
28
|
+
case "stopped":
|
|
29
|
+
return `stopped cesium server (pid ${outcome.pid}, port ${outcome.port}, ${outcome.signal})`;
|
|
30
|
+
case "permission-denied":
|
|
31
|
+
return `could not stop server: permission denied (pid ${outcome.pid} owned by another user)`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createStopTool(_ctx: PluginInput): ReturnType<typeof tool> {
|
|
36
|
+
return tool({
|
|
37
|
+
description: TOOL_DESCRIPTION,
|
|
38
|
+
args: {
|
|
39
|
+
force: tool.schema.boolean().optional(),
|
|
40
|
+
timeoutMs: tool.schema.number().optional(),
|
|
41
|
+
},
|
|
42
|
+
async execute(args) {
|
|
43
|
+
const config = loadConfig();
|
|
44
|
+
const stopArgs: Parameters<typeof stopServer>[0] = {
|
|
45
|
+
stateDir: config.stateDir,
|
|
46
|
+
...(args.force !== undefined ? { force: args.force } : {}),
|
|
47
|
+
...(args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}),
|
|
48
|
+
};
|
|
49
|
+
const outcome = await stopServer(stopArgs);
|
|
50
|
+
return formatStopOutcome(outcome);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Tool handler for cesium_styleguide — returns the full CSS reference page as a string.
|
|
2
|
+
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { tool } from "@opencode-ai/plugin";
|
|
7
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
8
|
+
|
|
9
|
+
const STYLEGUIDE_PATH = join(
|
|
10
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
11
|
+
"../../assets/styleguide.html",
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
export function createStyleguideTool(_ctx: PluginInput): ReturnType<typeof tool> {
|
|
15
|
+
return tool({
|
|
16
|
+
description:
|
|
17
|
+
"Returns the cesium HTML design system reference page (CSS classes with example usage). Call this once at the start of writing a complex artifact to internalize the available components.",
|
|
18
|
+
args: {},
|
|
19
|
+
async execute(_args, _context) {
|
|
20
|
+
return await readFile(STYLEGUIDE_PATH, "utf8");
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Tool handler for cesium_wait — polls an interactive artifact until complete or timeout.
|
|
2
|
+
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
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 { loadIndex } from "../storage/index-cache.ts";
|
|
9
|
+
import type { AnswerValue, InteractiveData } from "../render/validate.ts";
|
|
10
|
+
import { readEmbeddedMetadata } from "../storage/write.ts";
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
|
|
13
|
+
export interface WaitToolOverrides {
|
|
14
|
+
loadConfig?: () => CesiumConfig;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type WaitStatus = "complete" | "incomplete" | "expired" | "cancelled" | "not-found";
|
|
18
|
+
|
|
19
|
+
const TOOL_DESCRIPTION = `cesium_wait — Block until the user completes a cesium_ask interactive artifact, or
|
|
20
|
+
until timeout. Returns the user's answers as a map keyed by question id.
|
|
21
|
+
|
|
22
|
+
Pass the id returned from cesium_ask. Default timeout is 10 minutes. Polls the
|
|
23
|
+
artifact file every 500ms (no server-side coordination needed — disk is the source
|
|
24
|
+
of truth).
|
|
25
|
+
|
|
26
|
+
Use this immediately after cesium_ask when you need the user's input to continue.
|
|
27
|
+
If the user doesn't finish within the timeout, you'll get status: "incomplete"
|
|
28
|
+
with whatever they answered so far — handle that case (re-prompt, give up, or
|
|
29
|
+
publish a partial artifact).`;
|
|
30
|
+
|
|
31
|
+
export function createWaitTool(
|
|
32
|
+
_ctx: PluginInput,
|
|
33
|
+
overrides?: WaitToolOverrides,
|
|
34
|
+
): ReturnType<typeof tool> {
|
|
35
|
+
const resolveConfig = overrides?.loadConfig ?? loadConfig;
|
|
36
|
+
|
|
37
|
+
return tool({
|
|
38
|
+
description: TOOL_DESCRIPTION,
|
|
39
|
+
args: {
|
|
40
|
+
id: tool.schema.string(),
|
|
41
|
+
timeoutMs: tool.schema.number().optional(),
|
|
42
|
+
pollIntervalMs: tool.schema.number().optional(),
|
|
43
|
+
},
|
|
44
|
+
async execute(args, _context) {
|
|
45
|
+
const config = resolveConfig();
|
|
46
|
+
const { id } = args;
|
|
47
|
+
const timeoutMs = typeof args.timeoutMs === "number" ? args.timeoutMs : 600_000;
|
|
48
|
+
const pollIntervalMs = Math.max(
|
|
49
|
+
100,
|
|
50
|
+
typeof args.pollIntervalMs === "number" ? args.pollIntervalMs : 500,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Resolve id → artifactPath by scanning project index.json files
|
|
54
|
+
const artifactPath = await resolveArtifactPath(config.stateDir, id);
|
|
55
|
+
|
|
56
|
+
if (artifactPath === null) {
|
|
57
|
+
const result = {
|
|
58
|
+
status: "not-found" as WaitStatus,
|
|
59
|
+
answers: {},
|
|
60
|
+
remaining: [] as string[],
|
|
61
|
+
};
|
|
62
|
+
return JSON.stringify(result, null, 2);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Poll until complete, expired, cancelled, or timeout
|
|
66
|
+
const startTime = Date.now();
|
|
67
|
+
|
|
68
|
+
const pollResult = await pollLoop(artifactPath, timeoutMs, pollIntervalMs, startTime);
|
|
69
|
+
return JSON.stringify(pollResult, null, 2);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── Artifact path resolution ──────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
async function resolveArtifactPath(stateDir: string, id: string): Promise<string | null> {
|
|
77
|
+
const projectsDir = join(stateDir, "projects");
|
|
78
|
+
|
|
79
|
+
let projectDirs: string[];
|
|
80
|
+
try {
|
|
81
|
+
projectDirs = await readdir(projectsDir);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const projectSlug of projectDirs) {
|
|
87
|
+
const indexPath = join(projectsDir, projectSlug, "index.json");
|
|
88
|
+
let entries;
|
|
89
|
+
try {
|
|
90
|
+
entries = await loadIndex(indexPath);
|
|
91
|
+
} catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const entry = entries.find((e) => e.id === id);
|
|
95
|
+
if (entry !== undefined) {
|
|
96
|
+
return join(projectsDir, projectSlug, "artifacts", entry.filename);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Poll loop (recursive to satisfy no-await-in-loop) ────────────────────────
|
|
104
|
+
|
|
105
|
+
interface PollResult {
|
|
106
|
+
status: WaitStatus;
|
|
107
|
+
answers: Record<string, AnswerValue>;
|
|
108
|
+
remaining: string[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function pollLoop(
|
|
112
|
+
artifactPath: string,
|
|
113
|
+
timeoutMs: number,
|
|
114
|
+
pollIntervalMs: number,
|
|
115
|
+
startTime: number,
|
|
116
|
+
): Promise<PollResult> {
|
|
117
|
+
// Read the artifact HTML
|
|
118
|
+
let html: string;
|
|
119
|
+
try {
|
|
120
|
+
html = await readFile(artifactPath, "utf8");
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const e = err as NodeJS.ErrnoException;
|
|
123
|
+
if (e.code === "ENOENT") {
|
|
124
|
+
return { status: "not-found", answers: {}, remaining: [] };
|
|
125
|
+
}
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const meta = readEmbeddedMetadata(html);
|
|
130
|
+
if (meta === null || !isInteractiveData(meta["interactive"])) {
|
|
131
|
+
return { status: "not-found", answers: {}, remaining: [] };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const interactive = meta["interactive"] as InteractiveData;
|
|
135
|
+
|
|
136
|
+
const answers = extractAnswers(interactive);
|
|
137
|
+
const remaining = interactive.questions
|
|
138
|
+
.map((q) => q.id)
|
|
139
|
+
.filter((qid) => interactive.answers[qid] === undefined);
|
|
140
|
+
|
|
141
|
+
switch (interactive.status) {
|
|
142
|
+
case "complete":
|
|
143
|
+
return { status: "complete", answers, remaining };
|
|
144
|
+
case "expired":
|
|
145
|
+
return { status: "expired", answers, remaining };
|
|
146
|
+
case "cancelled":
|
|
147
|
+
return { status: "cancelled", answers, remaining };
|
|
148
|
+
case "open":
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Check timeout
|
|
153
|
+
if (Date.now() - startTime >= timeoutMs) {
|
|
154
|
+
return { status: "incomplete", answers, remaining };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Sleep then recurse
|
|
158
|
+
await sleep(pollIntervalMs);
|
|
159
|
+
|
|
160
|
+
// Check timeout again after sleep (in case sleep took longer)
|
|
161
|
+
if (Date.now() - startTime >= timeoutMs) {
|
|
162
|
+
return { status: "incomplete", answers, remaining };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return pollLoop(artifactPath, timeoutMs, pollIntervalMs, startTime);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
function isInteractiveData(v: unknown): v is InteractiveData {
|
|
171
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
|
172
|
+
const raw = v as Record<string, unknown>;
|
|
173
|
+
return (
|
|
174
|
+
(raw["status"] === "open" ||
|
|
175
|
+
raw["status"] === "complete" ||
|
|
176
|
+
raw["status"] === "expired" ||
|
|
177
|
+
raw["status"] === "cancelled") &&
|
|
178
|
+
Array.isArray(raw["questions"])
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function extractAnswers(interactive: InteractiveData): Record<string, AnswerValue> {
|
|
183
|
+
const answers: Record<string, AnswerValue> = {};
|
|
184
|
+
for (const [id, entry] of Object.entries(interactive.answers)) {
|
|
185
|
+
answers[id] = entry.value;
|
|
186
|
+
}
|
|
187
|
+
return answers;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function sleep(ms: number): Promise<void> {
|
|
191
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
192
|
+
}
|