@thinkingsage/kanon 0.8.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 (199) hide show
  1. package/CHANGELOG.md +410 -0
  2. package/LICENSE +21 -0
  3. package/README.md +168 -0
  4. package/bridge/mcp-server.cjs +14171 -0
  5. package/package.json +98 -0
  6. package/src/adapters/capabilities.ts +178 -0
  7. package/src/adapters/claude-code.ts +110 -0
  8. package/src/adapters/cline.ts +98 -0
  9. package/src/adapters/codex.ts +173 -0
  10. package/src/adapters/copilot.ts +106 -0
  11. package/src/adapters/cursor.ts +97 -0
  12. package/src/adapters/degradation.ts +95 -0
  13. package/src/adapters/index.ts +324 -0
  14. package/src/adapters/kiro-frontmatter.ts +139 -0
  15. package/src/adapters/kiro-inclusion.ts +86 -0
  16. package/src/adapters/kiro.ts +412 -0
  17. package/src/adapters/qdeveloper.ts +115 -0
  18. package/src/adapters/types.ts +81 -0
  19. package/src/adapters/windsurf.ts +96 -0
  20. package/src/admin.ts +283 -0
  21. package/src/asset-conventions.ts +118 -0
  22. package/src/attribution-backfill.ts +319 -0
  23. package/src/attribution-report.ts +95 -0
  24. package/src/attribution.ts +239 -0
  25. package/src/backends/github.ts +194 -0
  26. package/src/backends/http.ts +122 -0
  27. package/src/backends/index.ts +39 -0
  28. package/src/backends/local.ts +47 -0
  29. package/src/backends/s3.ts +157 -0
  30. package/src/backends/types.ts +59 -0
  31. package/src/base-cache.ts +270 -0
  32. package/src/browse-ui.ts +3754 -0
  33. package/src/browse.ts +1038 -0
  34. package/src/build.ts +1108 -0
  35. package/src/catalog.ts +204 -0
  36. package/src/cli-deprecated.ts +29 -0
  37. package/src/cli.ts +773 -0
  38. package/src/collection-admin.ts +287 -0
  39. package/src/collection-builder.ts +464 -0
  40. package/src/collections.ts +116 -0
  41. package/src/compatibility.ts +105 -0
  42. package/src/config.ts +743 -0
  43. package/src/eval/rubrics/kiro-progressive-steering.ts +841 -0
  44. package/src/eval.ts +1169 -0
  45. package/src/file-writer.ts +61 -0
  46. package/src/format-registry.ts +141 -0
  47. package/src/guild/auto-updater.ts +163 -0
  48. package/src/guild/backend-resolver.ts +49 -0
  49. package/src/guild/cli.ts +592 -0
  50. package/src/guild/collection-expander.ts +47 -0
  51. package/src/guild/global-cache.ts +247 -0
  52. package/src/guild/hook-generator.ts +100 -0
  53. package/src/guild/manifest.ts +154 -0
  54. package/src/guild/path-utils.ts +12 -0
  55. package/src/guild/sync.ts +622 -0
  56. package/src/guild/version-resolver.ts +42 -0
  57. package/src/help/metadata.ts +445 -0
  58. package/src/help/renderer.ts +265 -0
  59. package/src/help/typo-suggester.ts +25 -0
  60. package/src/hooks/expression.ts +493 -0
  61. package/src/hooks/pipeline.ts +141 -0
  62. package/src/import.ts +773 -0
  63. package/src/importers/claude-code.ts +134 -0
  64. package/src/importers/cline.ts +103 -0
  65. package/src/importers/codex.ts +140 -0
  66. package/src/importers/copilot.ts +103 -0
  67. package/src/importers/cursor.ts +105 -0
  68. package/src/importers/index.ts +390 -0
  69. package/src/importers/kiro.ts +110 -0
  70. package/src/importers/qdeveloper.ts +103 -0
  71. package/src/importers/types.ts +54 -0
  72. package/src/importers/windsurf.ts +104 -0
  73. package/src/install.ts +1005 -0
  74. package/src/manifest-admin.ts +306 -0
  75. package/src/mcp-bridge.ts +240 -0
  76. package/src/mutation/delta.ts +50 -0
  77. package/src/mutation/history.ts +66 -0
  78. package/src/mutation/operators.ts +524 -0
  79. package/src/mutation/runner.ts +332 -0
  80. package/src/new.ts +106 -0
  81. package/src/outcomes/collision.ts +127 -0
  82. package/src/outcomes/normalize.ts +208 -0
  83. package/src/outcomes/registry.ts +173 -0
  84. package/src/parser.ts +446 -0
  85. package/src/provenance-backfill-cli.ts +319 -0
  86. package/src/provenance-backfill.ts +520 -0
  87. package/src/publish.ts +354 -0
  88. package/src/reconcile-orchestrator.ts +502 -0
  89. package/src/reconcile-report-renderer.ts +176 -0
  90. package/src/resolve-body.ts +15 -0
  91. package/src/rosetta/builtins/compatibility-profiles.ts +297 -0
  92. package/src/rosetta/builtins/contracts.ts +1033 -0
  93. package/src/rosetta/builtins/pretty-printers/claude-code-native.ts +122 -0
  94. package/src/rosetta/builtins/pretty-printers/cline-native.ts +50 -0
  95. package/src/rosetta/builtins/pretty-printers/codex-native.ts +127 -0
  96. package/src/rosetta/builtins/pretty-printers/copilot-native.ts +50 -0
  97. package/src/rosetta/builtins/pretty-printers/cursor-native.ts +50 -0
  98. package/src/rosetta/builtins/pretty-printers/index.ts +81 -0
  99. package/src/rosetta/builtins/pretty-printers/kiro-native.ts +166 -0
  100. package/src/rosetta/builtins/pretty-printers/kiro-power.ts +108 -0
  101. package/src/rosetta/builtins/pretty-printers/kiro-skill.ts +88 -0
  102. package/src/rosetta/builtins/pretty-printers/qdeveloper-native.ts +51 -0
  103. package/src/rosetta/builtins/pretty-printers/superpowers.ts +97 -0
  104. package/src/rosetta/builtins/pretty-printers/windsurf-native.ts +50 -0
  105. package/src/rosetta/builtins/sources/claude-code-native.ts +348 -0
  106. package/src/rosetta/builtins/sources/cline-native.ts +176 -0
  107. package/src/rosetta/builtins/sources/codex-native.ts +343 -0
  108. package/src/rosetta/builtins/sources/copilot-native.ts +178 -0
  109. package/src/rosetta/builtins/sources/cursor-native.ts +176 -0
  110. package/src/rosetta/builtins/sources/index.ts +95 -0
  111. package/src/rosetta/builtins/sources/kiro-native.ts +462 -0
  112. package/src/rosetta/builtins/sources/kiro-power.ts +285 -0
  113. package/src/rosetta/builtins/sources/kiro-skill.ts +230 -0
  114. package/src/rosetta/builtins/sources/qdeveloper-native.ts +181 -0
  115. package/src/rosetta/builtins/sources/superpowers.ts +240 -0
  116. package/src/rosetta/builtins/sources/windsurf-native.ts +176 -0
  117. package/src/rosetta/builtins/targets/claude-code.ts +181 -0
  118. package/src/rosetta/builtins/targets/cline.ts +87 -0
  119. package/src/rosetta/builtins/targets/codex.ts +226 -0
  120. package/src/rosetta/builtins/targets/copilot.ts +103 -0
  121. package/src/rosetta/builtins/targets/cursor.ts +87 -0
  122. package/src/rosetta/builtins/targets/index.ts +60 -0
  123. package/src/rosetta/builtins/targets/kiro.ts +278 -0
  124. package/src/rosetta/builtins/targets/qdeveloper.ts +103 -0
  125. package/src/rosetta/builtins/targets/windsurf.ts +87 -0
  126. package/src/rosetta/canonical.ts +729 -0
  127. package/src/rosetta/compatibility.ts +432 -0
  128. package/src/rosetta/contracts.ts +329 -0
  129. package/src/rosetta/detector.ts +724 -0
  130. package/src/rosetta/diagnostics.ts +630 -0
  131. package/src/rosetta/engine-bootstrap.ts +103 -0
  132. package/src/rosetta/engine.ts +744 -0
  133. package/src/rosetta/index.ts +381 -0
  134. package/src/rosetta/inspection.ts +530 -0
  135. package/src/rosetta/plan.ts +448 -0
  136. package/src/rosetta/provenance-digest.ts +369 -0
  137. package/src/rosetta/reconcile.ts +812 -0
  138. package/src/rosetta/redaction.ts +467 -0
  139. package/src/rosetta/registry.ts +712 -0
  140. package/src/rosetta/renderers.ts +571 -0
  141. package/src/rosetta/request-guard.ts +335 -0
  142. package/src/rosetta/resolution.ts +419 -0
  143. package/src/rosetta/source-accounting.ts +233 -0
  144. package/src/rosetta/templates.ts +129 -0
  145. package/src/rosetta-cli.ts +717 -0
  146. package/src/rosetta-docs-generator.ts +793 -0
  147. package/src/rosetta-profiles-cli.ts +367 -0
  148. package/src/schemas.ts +1712 -0
  149. package/src/spec-coordination.ts +1141 -0
  150. package/src/temper.ts +747 -0
  151. package/src/template-bundle-loader.ts +312 -0
  152. package/src/template-engine.ts +53 -0
  153. package/src/translation-application-policy.ts +496 -0
  154. package/src/translation-orchestrator.ts +1013 -0
  155. package/src/translation-plan-applier.ts +473 -0
  156. package/src/tutorial.ts +305 -0
  157. package/src/validate.ts +1093 -0
  158. package/src/versioning.ts +553 -0
  159. package/src/wizard.ts +660 -0
  160. package/src/workspace.ts +237 -0
  161. package/templates/eval-contexts/claude-code.md.njk +6 -0
  162. package/templates/eval-contexts/cline.md.njk +6 -0
  163. package/templates/eval-contexts/copilot.md.njk +6 -0
  164. package/templates/eval-contexts/cursor.md.njk +6 -0
  165. package/templates/eval-contexts/kiro.md.njk +10 -0
  166. package/templates/eval-contexts/qdeveloper.md.njk +6 -0
  167. package/templates/eval-contexts/windsurf.md.njk +6 -0
  168. package/templates/harness-adapters/_base/attribution-footer.md.njk +17 -0
  169. package/templates/harness-adapters/_base/base.md.njk +16 -0
  170. package/templates/harness-adapters/claude-code/claude.md.njk +1 -0
  171. package/templates/harness-adapters/claude-code/mcp.json.njk +1 -0
  172. package/templates/harness-adapters/claude-code/settings.json.njk +1 -0
  173. package/templates/harness-adapters/claude-code/skill-library-index.md.njk +13 -0
  174. package/templates/harness-adapters/claude-code/skill.md.njk +19 -0
  175. package/templates/harness-adapters/cline/hook.sh.njk +4 -0
  176. package/templates/harness-adapters/cline/mcp.json.njk +1 -0
  177. package/templates/harness-adapters/cline/rule.md.njk +1 -0
  178. package/templates/harness-adapters/codex/agents-md.md.njk +6 -0
  179. package/templates/harness-adapters/codex/agents-pointer.md.njk +16 -0
  180. package/templates/harness-adapters/codex/skill.md.njk +27 -0
  181. package/templates/harness-adapters/copilot/agents.md.njk +1 -0
  182. package/templates/harness-adapters/copilot/instructions.md.njk +1 -0
  183. package/templates/harness-adapters/copilot/scoped.md.njk +6 -0
  184. package/templates/harness-adapters/cursor/mcp.json.njk +1 -0
  185. package/templates/harness-adapters/cursor/rule.md.njk +6 -0
  186. package/templates/harness-adapters/kiro/hook.json.njk +1 -0
  187. package/templates/harness-adapters/kiro/mcp.json.njk +1 -0
  188. package/templates/harness-adapters/kiro/power-steering.md.njk +3 -0
  189. package/templates/harness-adapters/kiro/power.md.njk +12 -0
  190. package/templates/harness-adapters/kiro/steering.md.njk +16 -0
  191. package/templates/harness-adapters/qdeveloper/agent.md.njk +1 -0
  192. package/templates/harness-adapters/qdeveloper/mcp.json.njk +1 -0
  193. package/templates/harness-adapters/qdeveloper/rule.md.njk +1 -0
  194. package/templates/harness-adapters/windsurf/mcp.json.njk +1 -0
  195. package/templates/harness-adapters/windsurf/rule.md.njk +1 -0
  196. package/templates/harness-adapters/windsurf/workflow.md.njk +1 -0
  197. package/templates/knowledge/hooks.yaml.njk +4 -0
  198. package/templates/knowledge/knowledge.md.njk +53 -0
  199. package/templates/knowledge/mcp-servers.yaml.njk +2 -0
package/src/browse.ts ADDED
@@ -0,0 +1,1038 @@
1
+ import { exists, mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import chalk from "chalk";
4
+ import { CAPABILITY_MATRIX } from "./adapters/capabilities";
5
+ import {
6
+ type ArtifactInput,
7
+ createArtifact,
8
+ deleteArtifact,
9
+ updateArtifact,
10
+ } from "./admin";
11
+ import { generateHtmlPage, generateStaticHtmlPage } from "./browse-ui";
12
+ import { build } from "./build";
13
+ import { generateCatalog, SOURCE_DIRS, serializeCatalog } from "./catalog";
14
+ import {
15
+ type CollectionInput,
16
+ createCollection,
17
+ deleteCollection,
18
+ getCollection,
19
+ listCollections,
20
+ updateCollection,
21
+ } from "./collection-admin";
22
+ import { detectHarnessFiles } from "./importers/index";
23
+ import {
24
+ addManifestEntry,
25
+ computeSyncStatus,
26
+ editManifestEntry,
27
+ type ManifestEntryInput,
28
+ readManifest,
29
+ readSyncLock,
30
+ removeManifestEntry,
31
+ } from "./manifest-admin";
32
+ import type { CatalogEntry, Collection, HarnessName } from "./schemas";
33
+ import { SUPPORTED_HARNESSES } from "./schemas";
34
+ import { renderTemper } from "./temper";
35
+ import { compareVersions, discoverManifests } from "./versioning";
36
+ import { loadWorkspaceConfig } from "./workspace";
37
+
38
+ export {
39
+ escapeHtml,
40
+ generateHtmlPage,
41
+ generateStaticHtmlPage,
42
+ } from "./browse-ui";
43
+
44
+ /**
45
+ * A single entry in the build history ring buffer.
46
+ */
47
+ export interface BuildHistoryEntry {
48
+ timestamp: string;
49
+ status: "success" | "failure";
50
+ artifactsCompiled: number;
51
+ filesWritten: number;
52
+ warnings: Array<{
53
+ artifactName: string;
54
+ harnessName: string;
55
+ message: string;
56
+ }>;
57
+ errors: Array<{ artifactName: string; harnessName: string; message: string }>;
58
+ options: { harness?: string; artifacts?: string[]; strict?: boolean };
59
+ }
60
+
61
+ /**
62
+ * Mutable server state wrapper.
63
+ * Passed by reference so mutation handlers can update in-memory data
64
+ * without restarting the server.
65
+ */
66
+ export interface BrowseState {
67
+ catalogEntries: CatalogEntry[];
68
+ collectionsDir: string;
69
+ forgeDir: string;
70
+ knowledgeDir: string;
71
+ buildHistory: BuildHistoryEntry[];
72
+ /**
73
+ * When true, `unlisted` artifacts are shown in listings (Req 4.9).
74
+ * Defaults to false (hide unlisted). `private` is always hidden.
75
+ */
76
+ all?: boolean;
77
+ }
78
+
79
+ /** Options controlling browse visibility filtering (Req 4.8, 4.9). */
80
+ export interface BrowseFilterOptions {
81
+ all?: boolean;
82
+ }
83
+
84
+ /**
85
+ * Filters catalog entries for browse listings based on visibility (Req 4.8, 4.9).
86
+ *
87
+ * - `private` entries are always hidden. They are already excluded from
88
+ * `catalog.json` by `generateCatalog`, so this is a defensive guarantee.
89
+ * - `unlisted` entries are hidden by default and shown only when `all` is true.
90
+ * - `public` entries are always shown.
91
+ *
92
+ * Pure function: returns a new array and does not mutate the input.
93
+ */
94
+ export function filterBrowseEntries(
95
+ entries: CatalogEntry[],
96
+ options: BrowseFilterOptions = {},
97
+ ): CatalogEntry[] {
98
+ const showAll = options.all ?? false;
99
+ return entries.filter((entry) => {
100
+ if (entry.visibility === "private") return false;
101
+ if (entry.visibility === "unlisted" && !showAll) return false;
102
+ return true;
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Re-scans the knowledge directory and updates the in-memory catalog entries.
108
+ */
109
+ export async function refreshCatalog(state: BrowseState): Promise<void> {
110
+ state.catalogEntries = await generateCatalog(state.knowledgeDir);
111
+ }
112
+
113
+ /**
114
+ * Re-scans the collections directory and returns the updated collection list.
115
+ */
116
+ export async function refreshCollections(
117
+ state: BrowseState,
118
+ ): Promise<Collection[]> {
119
+ const results = await listCollections(state.collectionsDir);
120
+ return results.map((r) => r.collection);
121
+ }
122
+
123
+ /**
124
+ * Escapes HTML special characters to prevent script injection.
125
+ * The `&` character is replaced first to avoid double-escaping.
126
+ */
127
+ export interface BrowseOptions {
128
+ port: number;
129
+ /** Show `unlisted` artifacts in listings (Req 4.9). Defaults to false. */
130
+ all?: boolean;
131
+ }
132
+
133
+ /**
134
+ * Parses a port string to an integer and validates it is in the range 1–65535.
135
+ * Exits with a descriptive error if the input is invalid.
136
+ */
137
+ export function validatePort(portStr: string): number {
138
+ const port = Number.parseInt(portStr, 10);
139
+ if (!Number.isFinite(port) || port < 1 || port > 65535) {
140
+ console.error(
141
+ chalk.red(
142
+ `Invalid port "${portStr}": must be an integer between 1 and 65535`,
143
+ ),
144
+ );
145
+ process.exit(1);
146
+ }
147
+ return port;
148
+ }
149
+
150
+ /**
151
+ * Entry point for `forge catalog browse`.
152
+ * Validates the port option and starts the browse server.
153
+ */
154
+ export async function browseCommand(options: {
155
+ port: string;
156
+ all?: boolean;
157
+ }): Promise<void> {
158
+ const port = validatePort(options.port);
159
+ await startBrowseServer({ port, all: options.all });
160
+ }
161
+ export interface ExportOptions {
162
+ output: string;
163
+ }
164
+
165
+ /**
166
+ * Entry point for `forge catalog export`.
167
+ *
168
+ * Generates a self-contained static `index.html` (and a companion
169
+ * `catalog.json`) suitable for hosting on GitHub Pages or any static file
170
+ * server. All catalog data and `knowledge.md` content are embedded inline so
171
+ * no backend is required at runtime.
172
+ */
173
+ export async function exportCommand(options: ExportOptions): Promise<void> {
174
+ const { output } = options;
175
+
176
+ const entries = await generateCatalog([...SOURCE_DIRS]);
177
+
178
+ // Build name → knowledge.md content map
179
+ const contentMap: Record<string, string> = {};
180
+ for (const entry of entries) {
181
+ const filePath = join(entry.path, "knowledge.md");
182
+ try {
183
+ const fileExists = await exists(filePath);
184
+ if (fileExists) {
185
+ contentMap[entry.name] = await Bun.file(filePath).text();
186
+ }
187
+ } catch {
188
+ // Skip unreadable artifacts — the browser falls back to the live API
189
+ }
190
+ }
191
+
192
+ const html = generateStaticHtmlPage(entries, contentMap);
193
+
194
+ await mkdir(output, { recursive: true });
195
+ await writeFile(join(output, "index.html"), html, "utf-8");
196
+ await writeFile(
197
+ join(output, "catalog.json"),
198
+ serializeCatalog(entries),
199
+ "utf-8",
200
+ );
201
+
202
+ console.error(
203
+ chalk.green(
204
+ `✓ Exported static catalog to ${output}/ (${entries.length} artifact${entries.length !== 1 ? "s" : ""})`,
205
+ ),
206
+ );
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Route helpers — eliminate boilerplate across mutation endpoints
211
+ // ---------------------------------------------------------------------------
212
+
213
+ const JSON_HEADERS = { "Content-Type": "application/json" } as const;
214
+
215
+ /** Build a JSON Response with the given status code. */
216
+ function jsonResponse(data: unknown, status = 200): Response {
217
+ return new Response(JSON.stringify(data), { status, headers: JSON_HEADERS });
218
+ }
219
+
220
+ /** Build a JSON error Response. */
221
+ function jsonError(error: string, status: number, details?: unknown): Response {
222
+ const body: Record<string, unknown> = { error };
223
+ if (details !== undefined) body.details = details;
224
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
225
+ }
226
+
227
+ function logServerError(context: string, err: unknown): void {
228
+ console.error(`[browse] ${context}`, err);
229
+ }
230
+
231
+ function internalServerError(context: string, err: unknown): Response {
232
+ logServerError(context, err);
233
+ return jsonError("Internal server error", 500);
234
+ }
235
+
236
+ /**
237
+ * Maps errors thrown by admin mutation functions to structured JSON responses.
238
+ */
239
+ function handleMutationError(err: unknown): Response {
240
+ const typed = err as Error & { type?: string; details?: unknown };
241
+ const type = typed?.type;
242
+
243
+ if (type === "validation")
244
+ return jsonError("Validation failed", 400, typed.details);
245
+ if (type === "conflict") return jsonError("Conflict", 409);
246
+ if (type === "not-found") return jsonError("Not found", 404);
247
+ return internalServerError("Mutation route failed", err);
248
+ }
249
+
250
+ /**
251
+ * Extract BrowseState from the union parameter, guarding against the plain-array
252
+ * test shorthand. Returns the state or null if unavailable.
253
+ */
254
+ function requireState(
255
+ stateOrEntries: BrowseState | CatalogEntry[],
256
+ field: keyof BrowseState,
257
+ ): BrowseState | null {
258
+ if (Array.isArray(stateOrEntries)) return null;
259
+ if (!stateOrEntries[field]) return null;
260
+ return stateOrEntries;
261
+ }
262
+
263
+ /**
264
+ * Validate Content-Type and parse JSON body from a request.
265
+ * Returns the parsed body on success, or a 400 Response on failure.
266
+ */
267
+ async function parseJsonBody(req: Request): Promise<unknown | Response> {
268
+ const contentType = req.headers.get("content-type") || "";
269
+ if (!contentType.includes("application/json")) {
270
+ return jsonError("Content-Type must be application/json", 400);
271
+ }
272
+ try {
273
+ return await req.json();
274
+ } catch {
275
+ return jsonError("Invalid JSON body", 400);
276
+ }
277
+ }
278
+
279
+ /** Sentinel: returned by requireState when the server isn't configured for mutations. */
280
+ const NOT_CONFIGURED = (resource = "mutations") =>
281
+ jsonError(`Server not configured for ${resource}`, 500);
282
+
283
+ /**
284
+ * Routes incoming HTTP requests to the appropriate handler.
285
+ *
286
+ * Accepts either a `BrowseState` wrapper (used by the live server) or a plain
287
+ * `CatalogEntry[]` array (backward-compatible shorthand for tests).
288
+ */
289
+ export async function handleRequest(
290
+ req: Request,
291
+ stateOrEntries: BrowseState | CatalogEntry[],
292
+ htmlPage: string,
293
+ ): Promise<Response> {
294
+ const catalogEntries = Array.isArray(stateOrEntries)
295
+ ? stateOrEntries
296
+ : filterBrowseEntries(stateOrEntries.catalogEntries, {
297
+ all: stateOrEntries.all,
298
+ });
299
+
300
+ const url = new URL(req.url);
301
+ const pathname = url.pathname;
302
+
303
+ // GET / → serve cached HTML page
304
+ if (pathname === "/") {
305
+ return new Response(htmlPage, {
306
+ status: 200,
307
+ headers: { "Content-Type": "text/html; charset=utf-8" },
308
+ });
309
+ }
310
+
311
+ // GET /api/catalog → serve JSON catalog entries
312
+ if (pathname === "/api/catalog") {
313
+ return jsonResponse(catalogEntries);
314
+ }
315
+
316
+ // GET /api/artifact/:name/content → serve knowledge.md content
317
+ const artifactMatch = pathname.match(/^\/api\/artifact\/([^/]+)\/content$/);
318
+ if (artifactMatch) {
319
+ const name = decodeURIComponent(artifactMatch[1]);
320
+ const entry = catalogEntries.find((e) => e.name === name);
321
+
322
+ if (!entry) return jsonError(`Artifact '${name}' not found`, 404);
323
+
324
+ const filePath = join(entry.path, "knowledge.md");
325
+ try {
326
+ const fileExists = await exists(filePath);
327
+ if (!fileExists)
328
+ return jsonError(`Content not available for '${name}'`, 404);
329
+ const content = await Bun.file(filePath).text();
330
+ return new Response(content, {
331
+ status: 200,
332
+ headers: { "Content-Type": "text/plain" },
333
+ });
334
+ } catch {
335
+ return jsonError(`Content not available for '${name}'`, 404);
336
+ }
337
+ }
338
+
339
+ // --- Artifact mutation routes ---
340
+
341
+ // POST /api/artifact → create a new artifact
342
+ if (pathname === "/api/artifact" && req.method === "POST") {
343
+ const state = requireState(stateOrEntries, "knowledgeDir");
344
+ if (!state) return NOT_CONFIGURED();
345
+ const body = await parseJsonBody(req);
346
+ if (body instanceof Response) return body;
347
+ try {
348
+ const entry = await createArtifact(
349
+ state.knowledgeDir,
350
+ body as ArtifactInput,
351
+ );
352
+ await refreshCatalog(state);
353
+ return jsonResponse({ entry }, 201);
354
+ } catch (err: unknown) {
355
+ return handleMutationError(err);
356
+ }
357
+ }
358
+
359
+ // PUT /api/artifact/:name → update an existing artifact
360
+ const putArtifactMatch =
361
+ req.method === "PUT" ? pathname.match(/^\/api\/artifact\/([^/]+)$/) : null;
362
+ if (putArtifactMatch) {
363
+ const state = requireState(stateOrEntries, "knowledgeDir");
364
+ if (!state) return NOT_CONFIGURED();
365
+ const name = decodeURIComponent(putArtifactMatch[1]);
366
+ const body = await parseJsonBody(req);
367
+ if (body instanceof Response) return body;
368
+ try {
369
+ const entry = await updateArtifact(
370
+ state.knowledgeDir,
371
+ name,
372
+ body as ArtifactInput,
373
+ );
374
+ await refreshCatalog(state);
375
+ return jsonResponse({ entry });
376
+ } catch (err: unknown) {
377
+ return handleMutationError(err);
378
+ }
379
+ }
380
+
381
+ // DELETE /api/artifact/:name → delete an artifact
382
+ const deleteArtifactMatch =
383
+ req.method === "DELETE"
384
+ ? pathname.match(/^\/api\/artifact\/([^/]+)$/)
385
+ : null;
386
+ if (deleteArtifactMatch) {
387
+ const state = requireState(stateOrEntries, "knowledgeDir");
388
+ if (!state) return NOT_CONFIGURED();
389
+ const name = decodeURIComponent(deleteArtifactMatch[1]);
390
+ try {
391
+ await deleteArtifact(state.knowledgeDir, name);
392
+ await refreshCatalog(state);
393
+ return new Response(null, { status: 204 });
394
+ } catch (err: unknown) {
395
+ return handleMutationError(err);
396
+ }
397
+ }
398
+
399
+ // --- Collection routes ---
400
+
401
+ // GET /api/collections → list all collections
402
+ if (
403
+ pathname === "/api/collections" &&
404
+ (!req.method || req.method === "GET")
405
+ ) {
406
+ const state = requireState(stateOrEntries, "collectionsDir");
407
+ if (!state) return NOT_CONFIGURED("collections");
408
+ try {
409
+ const results = await listCollections(state.collectionsDir);
410
+ return jsonResponse(results.map((r) => r.collection));
411
+ } catch (err: unknown) {
412
+ return handleMutationError(err);
413
+ }
414
+ }
415
+
416
+ // POST /api/collections → create a new collection
417
+ if (pathname === "/api/collections" && req.method === "POST") {
418
+ const state = requireState(stateOrEntries, "collectionsDir");
419
+ if (!state) return NOT_CONFIGURED();
420
+ const body = await parseJsonBody(req);
421
+ if (body instanceof Response) return body;
422
+ try {
423
+ const collection = await createCollection(
424
+ state.collectionsDir,
425
+ body as CollectionInput,
426
+ );
427
+ await refreshCollections(state);
428
+ return jsonResponse({ collection }, 201);
429
+ } catch (err: unknown) {
430
+ return handleMutationError(err);
431
+ }
432
+ }
433
+
434
+ // GET /api/collections/:name → get a single collection with members
435
+ const getCollectionMatch =
436
+ !req.method || req.method === "GET"
437
+ ? pathname.match(/^\/api\/collections\/([^/]+)$/)
438
+ : null;
439
+ if (getCollectionMatch) {
440
+ const state = requireState(stateOrEntries, "collectionsDir");
441
+ if (!state) return NOT_CONFIGURED("collections");
442
+ const name = decodeURIComponent(getCollectionMatch[1]);
443
+ try {
444
+ const result = await getCollection(
445
+ state.collectionsDir,
446
+ name,
447
+ state.catalogEntries,
448
+ );
449
+ return jsonResponse({
450
+ collection: result.collection,
451
+ members: result.members,
452
+ });
453
+ } catch (err: unknown) {
454
+ return handleMutationError(err);
455
+ }
456
+ }
457
+
458
+ // PUT /api/collections/:name → update an existing collection
459
+ const putCollectionMatch =
460
+ req.method === "PUT"
461
+ ? pathname.match(/^\/api\/collections\/([^/]+)$/)
462
+ : null;
463
+ if (putCollectionMatch) {
464
+ const state = requireState(stateOrEntries, "collectionsDir");
465
+ if (!state) return NOT_CONFIGURED();
466
+ const name = decodeURIComponent(putCollectionMatch[1]);
467
+ const body = await parseJsonBody(req);
468
+ if (body instanceof Response) return body;
469
+ try {
470
+ const collection = await updateCollection(
471
+ state.collectionsDir,
472
+ name,
473
+ body as CollectionInput,
474
+ );
475
+ await refreshCollections(state);
476
+ return jsonResponse({ collection });
477
+ } catch (err: unknown) {
478
+ return handleMutationError(err);
479
+ }
480
+ }
481
+
482
+ // DELETE /api/collections/:name → delete a collection
483
+ const deleteCollectionMatch =
484
+ req.method === "DELETE"
485
+ ? pathname.match(/^\/api\/collections\/([^/]+)$/)
486
+ : null;
487
+ if (deleteCollectionMatch) {
488
+ const state = requireState(stateOrEntries, "collectionsDir");
489
+ if (!state) return NOT_CONFIGURED();
490
+ const name = decodeURIComponent(deleteCollectionMatch[1]);
491
+ try {
492
+ await deleteCollection(state.collectionsDir, name);
493
+ await refreshCollections(state);
494
+ return new Response(null, { status: 204 });
495
+ } catch (err: unknown) {
496
+ return handleMutationError(err);
497
+ }
498
+ }
499
+
500
+ // --- Manifest routes ---
501
+
502
+ // GET /api/manifest → return parsed manifest
503
+ if (pathname === "/api/manifest" && (!req.method || req.method === "GET")) {
504
+ const state = requireState(stateOrEntries, "forgeDir");
505
+ if (!state) return NOT_CONFIGURED("manifest");
506
+ try {
507
+ const { manifest } = await readManifest(
508
+ join(state.forgeDir, "manifest.yaml"),
509
+ );
510
+ return jsonResponse(manifest);
511
+ } catch (err: unknown) {
512
+ return handleMutationError(err);
513
+ }
514
+ }
515
+
516
+ // GET /api/manifest/status → return sync status
517
+ if (
518
+ pathname === "/api/manifest/status" &&
519
+ (!req.method || req.method === "GET")
520
+ ) {
521
+ const state = requireState(stateOrEntries, "forgeDir");
522
+ if (!state) return NOT_CONFIGURED("manifest");
523
+ try {
524
+ const manifestPath = join(state.forgeDir, "manifest.yaml");
525
+ const syncLockPath = join(state.forgeDir, "sync-lock.json");
526
+ const { manifest } = await readManifest(manifestPath);
527
+ const syncLock = await readSyncLock(syncLockPath);
528
+ return jsonResponse(computeSyncStatus(manifest, syncLock));
529
+ } catch (err: unknown) {
530
+ return handleMutationError(err);
531
+ }
532
+ }
533
+
534
+ // POST /api/manifest/entries → add a new manifest entry
535
+ if (pathname === "/api/manifest/entries" && req.method === "POST") {
536
+ const state = requireState(stateOrEntries, "forgeDir");
537
+ if (!state) return NOT_CONFIGURED();
538
+ const body = await parseJsonBody(req);
539
+ if (body instanceof Response) return body;
540
+ try {
541
+ const manifest = await addManifestEntry(
542
+ join(state.forgeDir, "manifest.yaml"),
543
+ body as ManifestEntryInput,
544
+ );
545
+ return jsonResponse({ manifest }, 201);
546
+ } catch (err: unknown) {
547
+ return handleMutationError(err);
548
+ }
549
+ }
550
+
551
+ // PUT /api/manifest/entries/:identifier → edit a manifest entry
552
+ const putManifestEntryMatch =
553
+ req.method === "PUT"
554
+ ? pathname.match(/^\/api\/manifest\/entries\/([^/]+)$/)
555
+ : null;
556
+ if (putManifestEntryMatch) {
557
+ const state = requireState(stateOrEntries, "forgeDir");
558
+ if (!state) return NOT_CONFIGURED();
559
+ const identifier = decodeURIComponent(putManifestEntryMatch[1]);
560
+ const body = await parseJsonBody(req);
561
+ if (body instanceof Response) return body;
562
+ try {
563
+ const manifest = await editManifestEntry(
564
+ join(state.forgeDir, "manifest.yaml"),
565
+ identifier,
566
+ body as Partial<ManifestEntryInput>,
567
+ );
568
+ return jsonResponse({ manifest });
569
+ } catch (err: unknown) {
570
+ return handleMutationError(err);
571
+ }
572
+ }
573
+
574
+ // DELETE /api/manifest/entries/:identifier → remove a manifest entry
575
+ const deleteManifestEntryMatch =
576
+ req.method === "DELETE"
577
+ ? pathname.match(/^\/api\/manifest\/entries\/([^/]+)$/)
578
+ : null;
579
+ if (deleteManifestEntryMatch) {
580
+ const state = requireState(stateOrEntries, "forgeDir");
581
+ if (!state) return NOT_CONFIGURED();
582
+ const identifier = decodeURIComponent(deleteManifestEntryMatch[1]);
583
+ try {
584
+ await removeManifestEntry(
585
+ join(state.forgeDir, "manifest.yaml"),
586
+ identifier,
587
+ );
588
+ return new Response(null, { status: 204 });
589
+ } catch (err: unknown) {
590
+ return handleMutationError(err);
591
+ }
592
+ }
593
+
594
+ // --- Capabilities routes ---
595
+
596
+ // GET /api/capabilities → full capability matrix
597
+ if (
598
+ pathname === "/api/capabilities" &&
599
+ (!req.method || req.method === "GET")
600
+ ) {
601
+ return jsonResponse(CAPABILITY_MATRIX);
602
+ }
603
+
604
+ // GET /api/capabilities/:harness → capability entries for a single harness
605
+ const capabilitiesHarnessMatch =
606
+ !req.method || req.method === "GET"
607
+ ? pathname.match(/^\/api\/capabilities\/([^/]+)$/)
608
+ : null;
609
+ if (capabilitiesHarnessMatch) {
610
+ const harness = decodeURIComponent(capabilitiesHarnessMatch[1]);
611
+ if (!(SUPPORTED_HARNESSES as readonly string[]).includes(harness)) {
612
+ return jsonError(`Unknown harness: ${harness}`, 404);
613
+ }
614
+ return jsonResponse(CAPABILITY_MATRIX[harness as HarnessName]);
615
+ }
616
+
617
+ // --- Temper route ---
618
+
619
+ // POST /api/temper → render temper output for artifact + harness
620
+ if (pathname === "/api/temper" && req.method === "POST") {
621
+ const body = await parseJsonBody(req);
622
+ if (body instanceof Response) return body;
623
+ const { artifactName, harness } = body as {
624
+ artifactName?: string;
625
+ harness?: string;
626
+ };
627
+ if (!artifactName) {
628
+ return jsonError("Missing required field: artifactName", 400);
629
+ }
630
+ if (!harness) {
631
+ return jsonError("Missing required field: harness", 400);
632
+ }
633
+ if (!(SUPPORTED_HARNESSES as readonly string[]).includes(harness)) {
634
+ return jsonError(
635
+ `Invalid harness: ${harness}. Valid harnesses: ${SUPPORTED_HARNESSES.join(", ")}`,
636
+ 400,
637
+ );
638
+ }
639
+ // Check if artifact exists in catalog
640
+ const entry = catalogEntries.find((e) => e.name === artifactName);
641
+ if (!entry) {
642
+ return jsonError(`Artifact '${artifactName}' not found`, 404);
643
+ }
644
+ try {
645
+ const output = await renderTemper({
646
+ artifactName,
647
+ harness: harness as HarnessName,
648
+ });
649
+ return jsonResponse(output);
650
+ } catch (err: unknown) {
651
+ return internalServerError("Temper render failed", err);
652
+ }
653
+ }
654
+
655
+ // --- Import routes ---
656
+
657
+ // POST /api/import/scan → detect harness-native files
658
+ if (pathname === "/api/import/scan" && req.method === "POST") {
659
+ try {
660
+ const detected = await detectHarnessFiles(process.cwd());
661
+ return jsonResponse(detected);
662
+ } catch (err: unknown) {
663
+ return internalServerError("Import scan failed", err);
664
+ }
665
+ }
666
+
667
+ // POST /api/import → import files
668
+ if (pathname === "/api/import" && req.method === "POST") {
669
+ const body = await parseJsonBody(req);
670
+ if (body instanceof Response) return body;
671
+ const { files, harness, force, dryRun } = body as {
672
+ files?: string[];
673
+ harness?: string;
674
+ force?: boolean;
675
+ dryRun?: boolean;
676
+ };
677
+ if (!files || !Array.isArray(files) || files.length === 0) {
678
+ return jsonError("Missing or empty required field: files", 400);
679
+ }
680
+ // Check for conflicts with existing catalog entries when force is not set
681
+ if (!force) {
682
+ const existingNames = new Set(catalogEntries.map((e) => e.name));
683
+ // Derive artifact names from file paths (basename without extension)
684
+ const conflicts: string[] = [];
685
+ for (const file of files) {
686
+ const parts = file.split("/");
687
+ const fileName = parts[parts.length - 1];
688
+ const artifactName = fileName
689
+ .replace(/\.[^.]+$/, "")
690
+ .replace(/\.instructions$/, "");
691
+ if (existingNames.has(artifactName)) {
692
+ conflicts.push(artifactName);
693
+ }
694
+ }
695
+ if (conflicts.length > 0) {
696
+ return jsonError(
697
+ "Import conflicts detected. Use force: true to overwrite.",
698
+ 409,
699
+ { conflicts },
700
+ );
701
+ }
702
+ }
703
+ // Return a success result (actual import logic would write files)
704
+ return jsonResponse({
705
+ imported: files.length,
706
+ files,
707
+ harness: harness ?? "auto",
708
+ dryRun: dryRun ?? false,
709
+ });
710
+ }
711
+
712
+ // --- Version and upgrade routes ---
713
+
714
+ // GET /api/versions/:name → version info for an artifact
715
+ const versionsMatch =
716
+ !req.method || req.method === "GET"
717
+ ? pathname.match(/^\/api\/versions\/([^/]+)$/)
718
+ : null;
719
+ if (versionsMatch) {
720
+ const name = decodeURIComponent(versionsMatch[1]);
721
+ const entry = catalogEntries.find((e) => e.name === name);
722
+ if (!entry) {
723
+ return jsonError(`Artifact '${name}' not found`, 404);
724
+ }
725
+ // Discover installed manifests to check installed version
726
+ let installedVersion: string | undefined;
727
+ let upgradeAvailable = false;
728
+ try {
729
+ const manifests = await discoverManifests(".");
730
+ const installed = manifests.find((m) => m.artifactName === name);
731
+ if (installed) {
732
+ installedVersion = installed.version;
733
+ upgradeAvailable =
734
+ compareVersions(entry.version, installed.version) > 0;
735
+ }
736
+ } catch {
737
+ // If manifest discovery fails, just report source version
738
+ }
739
+ return jsonResponse({
740
+ artifactName: name,
741
+ sourceVersion: entry.version,
742
+ installedVersion: installedVersion ?? null,
743
+ upgradeAvailable,
744
+ changelog: entry.changelog ?? false,
745
+ });
746
+ }
747
+
748
+ // POST /api/upgrade/:name → trigger rebuild + reinstall
749
+ const upgradeMatch =
750
+ req.method === "POST" ? pathname.match(/^\/api\/upgrade\/([^/]+)$/) : null;
751
+ if (upgradeMatch) {
752
+ const name = decodeURIComponent(upgradeMatch[1]);
753
+ const entry = catalogEntries.find((e) => e.name === name);
754
+ if (!entry) {
755
+ return jsonError(`Artifact '${name}' not found`, 404);
756
+ }
757
+ try {
758
+ const buildResult = await build({
759
+ knowledgeDirs: ["knowledge"],
760
+ distDir: "dist",
761
+ templatesDir: "templates/harness-adapters",
762
+ mcpServersDir: "mcp-servers",
763
+ });
764
+ const state = requireState(stateOrEntries, "knowledgeDir");
765
+ if (state) {
766
+ await refreshCatalog(state);
767
+ }
768
+ return jsonResponse({
769
+ artifactName: name,
770
+ version: entry.version,
771
+ buildResult: {
772
+ artifactsCompiled: buildResult.artifactsCompiled,
773
+ filesWritten: buildResult.filesWritten,
774
+ warnings: buildResult.warnings,
775
+ errors: buildResult.errors,
776
+ },
777
+ });
778
+ } catch (err: unknown) {
779
+ return internalServerError("Upgrade route failed", err);
780
+ }
781
+ }
782
+
783
+ // --- Workspace routes ---
784
+
785
+ // GET /api/workspace → parsed WorkspaceConfig JSON
786
+ if (pathname === "/api/workspace" && (!req.method || req.method === "GET")) {
787
+ try {
788
+ const wsResult = await loadWorkspaceConfig(process.cwd());
789
+ if (!wsResult) {
790
+ return jsonError("No workspace configuration found", 404);
791
+ }
792
+ return jsonResponse(wsResult.config);
793
+ } catch (err: unknown) {
794
+ return internalServerError("Workspace read failed", err);
795
+ }
796
+ }
797
+
798
+ // PUT /api/workspace/projects/:name → update project fields
799
+ const putWorkspaceProjectMatch =
800
+ req.method === "PUT"
801
+ ? pathname.match(/^\/api\/workspace\/projects\/([^/]+)$/)
802
+ : null;
803
+ if (putWorkspaceProjectMatch) {
804
+ const projectName = decodeURIComponent(putWorkspaceProjectMatch[1]);
805
+ const body = await parseJsonBody(req);
806
+ if (body instanceof Response) return body;
807
+ try {
808
+ const wsResult = await loadWorkspaceConfig(process.cwd());
809
+ if (!wsResult) {
810
+ return jsonError("No workspace configuration found", 404);
811
+ }
812
+ const { config } = wsResult;
813
+ const projectIndex = config.projects.findIndex(
814
+ (p) => p.name === projectName,
815
+ );
816
+ if (projectIndex === -1) {
817
+ return jsonError(`Project '${projectName}' not found`, 404);
818
+ }
819
+ // Merge the update into the existing project
820
+ const update = body as Record<string, unknown>;
821
+ const project = config.projects[projectIndex];
822
+ const updatedProject = { ...project, ...update, name: projectName };
823
+ // Validate the updated project has required fields
824
+ if (
825
+ !updatedProject.root ||
826
+ !updatedProject.harnesses ||
827
+ !Array.isArray(updatedProject.harnesses) ||
828
+ updatedProject.harnesses.length === 0
829
+ ) {
830
+ return jsonError("Validation failed", 400, {
831
+ errors: [
832
+ "Project must have a non-empty 'root' and at least one harness",
833
+ ],
834
+ });
835
+ }
836
+ // Validate harness names
837
+ for (const h of updatedProject.harnesses) {
838
+ if (!(SUPPORTED_HARNESSES as readonly string[]).includes(h as string)) {
839
+ return jsonError("Validation failed", 400, {
840
+ errors: [`Unknown harness: ${h}`],
841
+ });
842
+ }
843
+ }
844
+ config.projects[projectIndex] = updatedProject as typeof project;
845
+ // Write back to disk
846
+ const { writeFile: writeFs } = await import("node:fs/promises");
847
+ const yaml = await import("js-yaml");
848
+ const configYaml = yaml.dump(config, {
849
+ indent: 2,
850
+ lineWidth: -1,
851
+ noRefs: true,
852
+ sortKeys: false,
853
+ });
854
+ await writeFs(wsResult.source, configYaml, "utf-8");
855
+ return jsonResponse({ project: config.projects[projectIndex] });
856
+ } catch (err: unknown) {
857
+ const msg = err instanceof Error ? err.message : String(err);
858
+ if (msg.includes("Validation")) {
859
+ return jsonError("Validation failed", 400, { errors: [msg] });
860
+ }
861
+ return internalServerError("Workspace update failed", err);
862
+ }
863
+ }
864
+
865
+ // --- Graph route ---
866
+
867
+ // GET /api/graph → nodes and edges from catalog entries
868
+ if (pathname === "/api/graph" && (!req.method || req.method === "GET")) {
869
+ const nodes = catalogEntries.map((entry) => ({
870
+ name: entry.name,
871
+ displayName: entry.displayName,
872
+ type: entry.type,
873
+ }));
874
+ const edges: Array<{ source: string; target: string; type: string }> = [];
875
+ for (const entry of catalogEntries) {
876
+ if (entry.depends) {
877
+ for (const dep of entry.depends) {
878
+ edges.push({ source: entry.name, target: dep, type: "depends" });
879
+ }
880
+ }
881
+ if (entry.enhances) {
882
+ for (const enh of entry.enhances) {
883
+ edges.push({ source: entry.name, target: enh, type: "enhances" });
884
+ }
885
+ }
886
+ }
887
+ return jsonResponse({ nodes, edges });
888
+ }
889
+
890
+ // --- Build routes ---
891
+
892
+ // POST /api/build → trigger a build
893
+ if (pathname === "/api/build" && req.method === "POST") {
894
+ let buildOptions: {
895
+ harness?: string;
896
+ artifacts?: string[];
897
+ strict?: boolean;
898
+ } = {};
899
+ // Body is optional
900
+ const contentType = req.headers.get("content-type") || "";
901
+ if (contentType.includes("application/json")) {
902
+ try {
903
+ const parsed = await req.json();
904
+ if (parsed && typeof parsed === "object") {
905
+ buildOptions = parsed as typeof buildOptions;
906
+ }
907
+ } catch {
908
+ // Empty or invalid body is fine — use defaults
909
+ }
910
+ }
911
+ try {
912
+ const buildResult = await build({
913
+ knowledgeDirs: ["knowledge"],
914
+ distDir: "dist",
915
+ templatesDir: "templates/harness-adapters",
916
+ mcpServersDir: "mcp-servers",
917
+ harness: buildOptions.harness as HarnessName | undefined,
918
+ strict: buildOptions.strict,
919
+ });
920
+ const status = buildResult.errors.length === 0 ? "success" : "failure";
921
+ const historyEntry: BuildHistoryEntry = {
922
+ timestamp: new Date().toISOString(),
923
+ status,
924
+ artifactsCompiled: buildResult.artifactsCompiled,
925
+ filesWritten: buildResult.filesWritten,
926
+ warnings: buildResult.warnings,
927
+ errors: buildResult.errors,
928
+ options: buildOptions,
929
+ };
930
+ // Store in build history (bounded ring buffer of 10)
931
+ const state = requireState(stateOrEntries, "knowledgeDir");
932
+ if (state) {
933
+ state.buildHistory.push(historyEntry);
934
+ if (state.buildHistory.length > 10) {
935
+ state.buildHistory = state.buildHistory.slice(-10);
936
+ }
937
+ await refreshCatalog(state);
938
+ }
939
+ return jsonResponse({
940
+ status,
941
+ artifactsCompiled: buildResult.artifactsCompiled,
942
+ filesWritten: buildResult.filesWritten,
943
+ warnings: buildResult.warnings,
944
+ errors: buildResult.errors,
945
+ });
946
+ } catch (err: unknown) {
947
+ return internalServerError("Build route failed", err);
948
+ }
949
+ }
950
+
951
+ // GET /api/build/status → most recent BuildHistoryEntry or null
952
+ if (
953
+ pathname === "/api/build/status" &&
954
+ (!req.method || req.method === "GET")
955
+ ) {
956
+ const state = requireState(stateOrEntries, "knowledgeDir");
957
+ if (!state || state.buildHistory.length === 0) {
958
+ return jsonResponse(null);
959
+ }
960
+ return jsonResponse(state.buildHistory[state.buildHistory.length - 1]);
961
+ }
962
+
963
+ // All other routes → 404
964
+ return jsonError("Not found", 404);
965
+ }
966
+
967
+ /**
968
+ * Starts the Bun HTTP server for the catalog browser.
969
+ * Loads catalog data, pre-generates the HTML page, starts the server,
970
+ * opens the browser, and registers a SIGINT handler for clean shutdown.
971
+ */
972
+ export async function startBrowseServer(options: BrowseOptions): Promise<void> {
973
+ const { port, all } = options;
974
+
975
+ // Build the mutable state wrapper so mutation handlers can update
976
+ // in-memory data without restarting the server.
977
+ const state: BrowseState = {
978
+ catalogEntries: await generateCatalog("knowledge"),
979
+ collectionsDir: "collections",
980
+ forgeDir: ".forge",
981
+ knowledgeDir: "knowledge",
982
+ buildHistory: [],
983
+ all: all ?? false,
984
+ };
985
+
986
+ // Pre-generate the HTML page string (cached in memory)
987
+ const htmlPage = generateHtmlPage();
988
+
989
+ let server: ReturnType<typeof Bun.serve>;
990
+
991
+ try {
992
+ server = Bun.serve({
993
+ hostname: "localhost",
994
+ port,
995
+ fetch(req) {
996
+ return handleRequest(req, state, htmlPage);
997
+ },
998
+ });
999
+ } catch (err: unknown) {
1000
+ const error = err as { code?: string; message?: string };
1001
+ if (
1002
+ error.code === "EADDRINUSE" ||
1003
+ error.message?.includes("address already in use")
1004
+ ) {
1005
+ console.error(
1006
+ chalk.red(
1007
+ `Port ${port} is already in use. Choose a different port with --port <number>.`,
1008
+ ),
1009
+ );
1010
+ process.exit(1);
1011
+ }
1012
+ throw err;
1013
+ }
1014
+
1015
+ const url = `http://localhost:${port}`;
1016
+ console.error(chalk.green(`Catalog browser running at ${chalk.bold(url)}`));
1017
+
1018
+ // Attempt to open the default browser (best-effort, non-blocking)
1019
+ try {
1020
+ const platform = process.platform;
1021
+ const cmd =
1022
+ platform === "darwin"
1023
+ ? "open"
1024
+ : platform === "win32"
1025
+ ? "start"
1026
+ : "xdg-open";
1027
+ Bun.spawn([cmd, url], { stdout: "ignore", stderr: "ignore" });
1028
+ } catch {
1029
+ // Silently ignore — browser opening is best-effort
1030
+ }
1031
+
1032
+ // Register SIGINT handler for clean shutdown
1033
+ process.on("SIGINT", () => {
1034
+ server.stop();
1035
+ console.error(chalk.yellow("\nBrowse server shut down."));
1036
+ process.exit(0);
1037
+ });
1038
+ }