@lopecode/channel 0.1.3 → 0.1.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.
@@ -60,6 +60,8 @@ const mcp = new Server(
60
60
  },
61
61
  instructions: `You are connected to Lopecode notebooks via the lopecode channel.
62
62
 
63
+ Lopecode notebooks are self-contained HTML files built on the Observable runtime. Each notebook contains modules (collections of reactive cells). The Observable runtime provides reactive dataflow: cells automatically recompute when their dependencies change, like a spreadsheet.
64
+
63
65
  ## Starting a lopecode notebook
64
66
 
65
67
  When the user asks to start/open a lopecode notebook, or start a pairing/collaboration session:
@@ -71,6 +73,72 @@ When the user asks to start/open a lopecode notebook, or start a pairing/collabo
71
73
 
72
74
  If channels are not enabled, tell the user to restart with: claude --channels server:lopecode
73
75
 
76
+ ## Observable Cell Syntax
77
+
78
+ Cells use Observable JavaScript. The define_cell tool accepts this syntax directly.
79
+
80
+ ### Named cells
81
+ x = 42
82
+ greeting = \`Hello, \${name}!\` // depends on 'name' cell — auto-recomputes when name changes
83
+
84
+ ### Markdown
85
+ md\`# Title\nSome **bold** text\`
86
+
87
+ ### HTML
88
+ htl.html\`<div style="color: red">Hello</div>\`
89
+
90
+ ### Imports (from other modules in the notebook)
91
+ import {md} from "@tomlarkworthy/editable-md"
92
+ import {chart, data} from "@tomlarkworthy/my-viz"
93
+
94
+ ### viewof — interactive inputs (creates TWO cells: "viewof X" for DOM, "X" for value)
95
+ viewof slider = Inputs.range([0, 100], {label: "Value", value: 50})
96
+ viewof name = Inputs.text({label: "Name"})
97
+ viewof choice = Inputs.select(["a", "b", "c"])
98
+
99
+ ### mutable — imperative state
100
+ mutable counter = 0
101
+ // Other cells can do: mutable counter++
102
+
103
+ ### Block cells (multi-statement)
104
+ result = {
105
+ const data = await fetch(url).then(r => r.json());
106
+ return data.filter(d => d.value > 10);
107
+ }
108
+
109
+ ### Generator cells (streaming values)
110
+ ticker = {
111
+ let i = 0;
112
+ while (true) { yield i++; await Promises.delay(1000); }
113
+ }
114
+
115
+ ## Testing
116
+
117
+ Any cell named test_* is a test. It passes if it doesn't throw:
118
+ test_addition = {
119
+ if (add(2, 2) !== 4) throw new Error("Expected 4");
120
+ return "2 + 2 = 4";
121
+ }
122
+
123
+ Use run_tests to execute all test_* cells.
124
+
125
+ ## Typical workflow
126
+
127
+ 1. create_module("@tomlarkworthy/my-app")
128
+ 2. define_cell('import {md} from "@tomlarkworthy/editable-md"', module: "...")
129
+ 3. define_cell('title = md\`# My App\`', module: "...")
130
+ 4. define_cell('viewof name = Inputs.text({label: "Name"})', module: "...")
131
+ 5. define_cell('greeting = md\`Hello, **\${name}**!\`', module: "...")
132
+ 6. export_notebook() to persist cells to disk
133
+
134
+ ## Tool guidance
135
+
136
+ - define_cell: PRIMARY tool for creating content. Accepts Observable source, compiles via toolchain. Use for almost everything.
137
+ - eval_code: For throwaway/ephemeral actions (DOM hacks, debugging, location.reload()). Lost on reload. NEVER use define_cell for one-off side effects.
138
+ - define_variable: Low-level escape hatch with explicit function string + inputs array. Rarely needed.
139
+ - export_notebook: Persists all runtime state to the HTML file. Call after defining cells so they survive reloads.
140
+ - fork_notebook: Creates a sibling HTML copy (checkpoint).
141
+
74
142
  ## Message formats
75
143
 
76
144
  User chat messages:
@@ -86,15 +154,6 @@ Lifecycle:
86
154
  Variable updates (when watching):
87
155
  <channel source="lopecode" type="variable_update" notebook="..." name="varName" module="@author/mod">value</channel>
88
156
 
89
- ## Tools
90
-
91
- - reply: Send markdown to notebook chat
92
- - get_variable / define_variable / delete_variable / list_variables: Interact with runtime
93
- - watch_variable / unwatch_variable: Subscribe to reactive variable updates
94
- - run_tests: Run test_* variables
95
- - eval_code: Evaluate JS in browser context
96
- - fork_notebook: Create a copy as sibling HTML file
97
-
98
157
  IMPORTANT: Always specify the module parameter when calling define_variable, get_variable, etc.
99
158
  Use the currentModules watch to identify the user's content module (not lopepage, module-selection, or claude-code-pairing).
100
159
  When multiple notebooks are connected, specify notebook_id (the URL). When only one is connected, it's used automatically.`,
@@ -191,6 +250,24 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
191
250
  required: ["name", "definition"],
192
251
  },
193
252
  },
253
+ {
254
+ name: "define_cell",
255
+ description:
256
+ "Define a cell using Observable source code. Supports full Observable syntax including imports (e.g. 'import {md} from \"@tomlarkworthy/editable-md\"'), named cells ('x = 42'), markdown ('md`# Hello`'), viewof, mutable, etc. The source is compiled via the Observable toolchain and may produce multiple runtime variables.",
257
+ inputSchema: {
258
+ type: "object",
259
+ properties: {
260
+ notebook_id: { type: "string" },
261
+ source: {
262
+ type: "string",
263
+ description:
264
+ 'Observable source code, e.g. \'import {md} from "@tomlarkworthy/editable-md"\' or \'x = 42\'',
265
+ },
266
+ module: { type: "string", description: "Target module name (optional)" },
267
+ },
268
+ required: ["source"],
269
+ },
270
+ },
194
271
  {
195
272
  name: "delete_variable",
196
273
  description: "Delete a variable from the runtime.",
@@ -215,6 +292,22 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
215
292
  },
216
293
  },
217
294
  },
295
+ {
296
+ name: "list_cells",
297
+ description:
298
+ "List all cells in a module with their names, inputs, and definition source. More detailed than list_variables — shows the cell's dependency inputs and function definition.",
299
+ inputSchema: {
300
+ type: "object",
301
+ properties: {
302
+ notebook_id: { type: "string" },
303
+ module: {
304
+ type: "string",
305
+ description: "Module name (required)",
306
+ },
307
+ },
308
+ required: ["module"],
309
+ },
310
+ },
218
311
  {
219
312
  name: "run_tests",
220
313
  description: "Run test_* variables and return results.",
@@ -239,6 +332,16 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
239
332
  required: ["code"],
240
333
  },
241
334
  },
335
+ {
336
+ name: "export_notebook",
337
+ description: "Export/save the notebook in place. Serializes the current runtime state (all modules, cells, file attachments) back to the HTML file, overwriting it. Use this to persist cells created via define_cell.",
338
+ inputSchema: {
339
+ type: "object",
340
+ properties: {
341
+ notebook_id: { type: "string" },
342
+ },
343
+ },
344
+ },
242
345
  {
243
346
  name: "fork_notebook",
244
347
  description: "Create a copy of the notebook as a sibling HTML file. Returns the new file path.",
@@ -250,6 +353,30 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
250
353
  },
251
354
  },
252
355
  },
356
+ {
357
+ name: "create_module",
358
+ description: "Create a new empty module in the runtime. The module is registered in runtime.mains so it appears in moduleMap/currentModules.",
359
+ inputSchema: {
360
+ type: "object",
361
+ properties: {
362
+ notebook_id: { type: "string" },
363
+ name: { type: "string", description: "Module name, e.g. '@tomlarkworthy/my-module'" },
364
+ },
365
+ required: ["name"],
366
+ },
367
+ },
368
+ {
369
+ name: "delete_module",
370
+ description: "Delete a module and all its variables from the runtime.",
371
+ inputSchema: {
372
+ type: "object",
373
+ properties: {
374
+ notebook_id: { type: "string" },
375
+ name: { type: "string", description: "Module name to delete" },
376
+ },
377
+ required: ["name"],
378
+ },
379
+ },
253
380
  {
254
381
  name: "watch_variable",
255
382
  description: "Subscribe to reactive updates for a variable. Changes are pushed as notifications.",
@@ -323,6 +450,10 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
323
450
  };
324
451
  break;
325
452
  }
453
+ case "define_cell":
454
+ action = "define-cell";
455
+ params = { source: args.source, module: args.module || null };
456
+ break;
326
457
  case "delete_variable":
327
458
  action = "delete-variable";
328
459
  params = { name: args.name, module: args.module || null };
@@ -331,6 +462,10 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
331
462
  action = "list-variables";
332
463
  params = { module: args.module || null };
333
464
  break;
465
+ case "list_cells":
466
+ action = "list-cells";
467
+ params = { module: args.module };
468
+ break;
334
469
  case "run_tests":
335
470
  action = "run-tests";
336
471
  timeout = (args.timeout as number) || 30000;
@@ -345,6 +480,19 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
345
480
  timeout = 120000;
346
481
  params = { suffix: args.suffix || null };
347
482
  break;
483
+ case "export_notebook":
484
+ action = "fork";
485
+ timeout = 120000;
486
+ params = { _save_in_place: true };
487
+ break;
488
+ case "create_module":
489
+ action = "create-module";
490
+ params = { name: args.name };
491
+ break;
492
+ case "delete_module":
493
+ action = "delete-module";
494
+ params = { name: args.name };
495
+ break;
348
496
  case "watch_variable":
349
497
  action = "watch";
350
498
  params = { name: args.name, module: args.module || null };
@@ -365,13 +513,27 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
365
513
 
366
514
  // Fork special handling: write the HTML to disk
367
515
  if (action === "fork" && result.result?.html) {
368
- const originalUrl = target.url;
516
+ // Strip hash and query fragments before extracting the file path
517
+ const originalUrl = target.url.split("#")[0].split("?")[0];
369
518
  let originalPath: string;
370
519
  if (originalUrl.startsWith("file://")) {
371
520
  originalPath = decodeURIComponent(originalUrl.replace("file://", ""));
372
521
  } else {
373
522
  originalPath = originalUrl;
374
523
  }
524
+ if (params._save_in_place) {
525
+ // Export: overwrite the original file
526
+ const html = result.result.html;
527
+ const htmlStr = typeof html === "string" ? html : String(html);
528
+ if (typeof html !== "string") {
529
+ console.error(`[export] WARNING: html is ${typeof html}, keys: ${html && typeof html === "object" ? Object.keys(html).join(",") : "N/A"}`);
530
+ }
531
+ if (htmlStr.length < 1000) {
532
+ return { content: [{ type: "text", text: `Export failed: HTML content too small (${htmlStr.length} bytes). Type was: ${typeof html}` }], isError: true };
533
+ }
534
+ await Bun.write(originalPath, htmlStr);
535
+ return { content: [{ type: "text", text: `Exported to ${originalPath} (${(htmlStr.length / 1024 / 1024).toFixed(2)} MB)` }] };
536
+ }
375
537
  const dir = dirname(originalPath);
376
538
  const base = basename(originalPath, ".html");
377
539
  const suffix = params.suffix || Date.now().toString();
@@ -544,7 +706,7 @@ await mcp.connect(new StdioServerTransport());
544
706
  const server = Bun.serve({
545
707
  port: REQUESTED_PORT,
546
708
  hostname: "127.0.0.1",
547
- fetch(req, server) {
709
+ async fetch(req, server) {
548
710
  const url = new URL(req.url);
549
711
  if (url.pathname === "/ws") {
550
712
  if (server.upgrade(req)) return;
@@ -556,6 +718,51 @@ const server = Bun.serve({
556
718
  pending: pendingConnections.size,
557
719
  }), { headers: { "content-type": "application/json" } });
558
720
  }
721
+
722
+ // Tool activity endpoint — receives PostToolUse hook data and broadcasts to notebooks
723
+ if (url.pathname === "/tool-activity" && req.method === "POST") {
724
+ try {
725
+ const body = await req.json();
726
+ const toolName = body.tool_name || "unknown";
727
+ const toolInput = body.tool_input || {};
728
+ const toolResponse = body.tool_response;
729
+
730
+ // Build a compact summary for the chat widget
731
+ let summary = toolName;
732
+ if (toolName === "Read" && toolInput.file_path) {
733
+ summary = `Read ${toolInput.file_path}`;
734
+ } else if (toolName === "Edit" && toolInput.file_path) {
735
+ summary = `Edit ${toolInput.file_path}`;
736
+ } else if (toolName === "Write" && toolInput.file_path) {
737
+ summary = `Write ${toolInput.file_path}`;
738
+ } else if (toolName === "Bash" && toolInput.command) {
739
+ summary = `$ ${toolInput.command.slice(0, 120)}`;
740
+ } else if (toolName === "Grep" && toolInput.pattern) {
741
+ summary = `Grep "${toolInput.pattern}"`;
742
+ } else if (toolName === "Glob" && toolInput.pattern) {
743
+ summary = `Glob "${toolInput.pattern}"`;
744
+ } else if (toolName === "Agent" && toolInput.description) {
745
+ summary = `Agent: ${toolInput.description}`;
746
+ } else if (toolName.startsWith("mcp__lopecode__")) {
747
+ // Our own MCP tools — skip broadcasting to avoid echo
748
+ return new Response("ok", { status: 200 });
749
+ }
750
+
751
+ // Broadcast to all paired notebooks
752
+ const msg = JSON.stringify({
753
+ type: "tool-activity",
754
+ tool_name: toolName,
755
+ summary,
756
+ timestamp: Date.now(),
757
+ });
758
+ for (const ws of pairedConnections.values()) {
759
+ ws.send(msg);
760
+ }
761
+ return new Response("ok", { status: 200 });
762
+ } catch (e) {
763
+ return new Response("bad request", { status: 400 });
764
+ }
765
+ }
559
766
  if (url.pathname === "/") {
560
767
  const notebookUrl = `https://tomlarkworthy.github.io/lopecode/notebooks/@tomlarkworthy_blank-notebook.html#view=R100(S50(@tomlarkworthy/blank-notebook),S25(@tomlarkworthy/module-selection),S25(@tomlarkworthy/claude-code-pairing))&cc=${PAIRING_TOKEN}`;
561
768
  return new Response(null, {
@@ -576,5 +783,11 @@ const server = Bun.serve({
576
783
 
577
784
  PORT = server.port; // read actual port (important when REQUESTED_PORT is 0)
578
785
  PAIRING_TOKEN = generateToken();
786
+
787
+ // Write port file so hooks can find us
788
+ const portFilePath = join(import.meta.dir, ".lopecode-port");
789
+ await Bun.write(portFilePath, String(PORT));
790
+ process.on("exit", () => { try { require("fs").unlinkSync(portFilePath); } catch {} });
791
+
579
792
  process.stderr.write(`lopecode-channel: pairing token: ${PAIRING_TOKEN}\n`);
580
793
  process.stderr.write(`lopecode-channel: WebSocket server on ws://127.0.0.1:${PORT}/ws\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lopecode/channel",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Pair program with Claude inside Lopecode notebooks. MCP server bridging browser notebooks and Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,8 @@
10
10
  "files": [
11
11
  "lopecode-channel.ts",
12
12
  "claude-code-pairing-module.js",
13
- "inject-module.js"
13
+ "sync-module.ts",
14
+ "README.md"
14
15
  ],
15
16
  "keywords": [
16
17
  "mcp",
package/sync-module.ts ADDED
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Syncs a module between files. Works with .js module files and .html notebook files.
4
+ *
5
+ * Usage:
6
+ * bun tools/channel/sync-module.ts --module @author/name --source src --target dest.html
7
+ * bun tools/channel/sync-module.ts --module @author/name --source src --target dest.html --watch
8
+ *
9
+ * Source can be:
10
+ * - A .js file containing the module's define() function
11
+ * - A .html notebook file containing a <script id="@author/name"> block
12
+ *
13
+ * If source is a .js file that doesn't exist, extracts the module from target first,
14
+ * creating the .js file as a starting point for editing.
15
+ *
16
+ * Target must be an .html notebook file.
17
+ *
18
+ * This:
19
+ * 1. Reads module content from source (.js file or .html <script> block)
20
+ * 2. If the module <script> already exists in target, replaces its content (upsert)
21
+ * 3. If not, inserts it before the bootloader marker
22
+ * 4. Ensures the module is in bootconf.json mains
23
+ * 5. Updates the hash URL to include the module in the layout
24
+ */
25
+
26
+ import { readFileSync, writeFileSync, existsSync, watch } from "fs";
27
+ import { resolve, extname } from "path";
28
+
29
+ function parseArgs() {
30
+ const args = process.argv.slice(2);
31
+ let moduleName = "";
32
+ let sourcePath = "";
33
+ let targetPath = "";
34
+ let watchMode = false;
35
+
36
+ for (let i = 0; i < args.length; i++) {
37
+ switch (args[i]) {
38
+ case "--module":
39
+ moduleName = args[++i];
40
+ break;
41
+ case "--source":
42
+ sourcePath = args[++i];
43
+ break;
44
+ case "--target":
45
+ targetPath = args[++i];
46
+ break;
47
+ case "--watch":
48
+ watchMode = true;
49
+ break;
50
+ }
51
+ }
52
+
53
+ if (!moduleName || !sourcePath || !targetPath) {
54
+ console.error(
55
+ "Usage: bun tools/channel/sync-module.ts --module <@author/name> --source <file> --target <notebook.html> [--watch]"
56
+ );
57
+ process.exit(1);
58
+ }
59
+
60
+ return {
61
+ moduleName,
62
+ sourcePath: resolve(sourcePath),
63
+ targetPath: resolve(targetPath),
64
+ watchMode,
65
+ };
66
+ }
67
+
68
+ function extractModuleFromHtml(html: string, moduleId: string): string | null {
69
+ const escaped = moduleId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
70
+ const pattern = new RegExp(
71
+ `<script\\s+id="${escaped}"[^>]*>[\\s\\S]*?</script>`
72
+ );
73
+ const m = html.match(pattern);
74
+ return m ? m[0] : null;
75
+ }
76
+
77
+ function extractModuleContent(html: string, moduleId: string): string | null {
78
+ const escaped = moduleId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
79
+ const pattern = new RegExp(
80
+ `<script\\s+id="${escaped}"[^>]*>([\\s\\S]*?)</script>`
81
+ );
82
+ const m = html.match(pattern);
83
+ return m ? m[1].replace(/^\n/, "").replace(/\n$/, "") : null;
84
+ }
85
+
86
+ function readModuleSource(
87
+ sourcePath: string,
88
+ moduleId: string
89
+ ): { content: string; isJs: boolean } {
90
+ const ext = extname(sourcePath).toLowerCase();
91
+
92
+ if (ext === ".js" || ext === ".ts") {
93
+ return { content: readFileSync(sourcePath, "utf8"), isJs: true };
94
+ } else if (ext === ".html") {
95
+ const html = readFileSync(sourcePath, "utf8");
96
+ const content = extractModuleContent(html, moduleId);
97
+ if (!content) {
98
+ console.error(
99
+ `Module ${moduleId} not found in ${sourcePath}`
100
+ );
101
+ process.exit(1);
102
+ }
103
+ return { content, isJs: false };
104
+ } else {
105
+ console.error(`Unsupported source file type: ${ext}`);
106
+ process.exit(1);
107
+ }
108
+ }
109
+
110
+ function inject(
111
+ sourcePath: string,
112
+ targetPath: string,
113
+ moduleId: string
114
+ ): void {
115
+ let html = readFileSync(targetPath, "utf8");
116
+ const { content, isJs } = readModuleSource(sourcePath, moduleId);
117
+
118
+ // Build the script block
119
+ const scriptBlock = `<script id="${moduleId}"\n type="text/plain"\n data-mime="application/javascript"\n>\n${content}\n</script>`;
120
+
121
+ // Check if module already exists in target
122
+ const escaped = moduleId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
123
+ const scriptPattern = new RegExp(
124
+ `<script\\s+id="${escaped}"[^>]*>[\\s\\S]*?</script>`
125
+ );
126
+ const existing = html.match(scriptPattern);
127
+
128
+ if (existing) {
129
+ // Replace in-place using indexOf+slice (avoids $ interpretation in String.replace)
130
+ const idx = html.indexOf(existing[0]);
131
+ html = html.slice(0, idx) + scriptBlock + html.slice(idx + existing[0].length);
132
+ console.log(`Updated existing ${moduleId} module`);
133
+ } else {
134
+ // Insert before bootloader
135
+ const bootconfMarker = "<!-- Bootloader -->";
136
+ const bootconfIdx = html.lastIndexOf(bootconfMarker);
137
+ if (bootconfIdx === -1) {
138
+ console.error("Could not find '<!-- Bootloader -->' marker in HTML");
139
+ process.exit(1);
140
+ }
141
+ html = html.slice(0, bootconfIdx) + scriptBlock + "\n\n" + html.slice(bootconfIdx);
142
+ console.log(`Inserted new ${moduleId} module`);
143
+ }
144
+
145
+ writeFileSync(targetPath, html);
146
+ const size = (html.length / 1024 / 1024).toFixed(2);
147
+ console.log(`Wrote ${targetPath} (${size} MB)`);
148
+ }
149
+
150
+ function extractToJs(targetPath: string, moduleId: string, jsPath: string): void {
151
+ const html = readFileSync(targetPath, "utf8");
152
+ const content = extractModuleContent(html, moduleId);
153
+ if (!content) {
154
+ console.error(`Module ${moduleId} not found in ${targetPath} — cannot extract`);
155
+ process.exit(1);
156
+ }
157
+ writeFileSync(jsPath, content);
158
+ console.log(`Extracted ${moduleId} from ${targetPath} → ${jsPath}`);
159
+ }
160
+
161
+ // CLI
162
+ const { moduleName, sourcePath, targetPath, watchMode } = parseArgs();
163
+
164
+ // If source is .js and doesn't exist, extract from target first
165
+ const sourceExt = extname(sourcePath).toLowerCase();
166
+ if ((sourceExt === ".js" || sourceExt === ".ts") && !existsSync(sourcePath)) {
167
+ console.log(`Source ${sourcePath} not found — extracting from target`);
168
+ extractToJs(targetPath, moduleName, sourcePath);
169
+ }
170
+
171
+ // Initial injection
172
+ inject(sourcePath, targetPath, moduleName);
173
+
174
+ if (watchMode) {
175
+ console.log(`Watching ${sourcePath} for changes...`);
176
+ let debounce: ReturnType<typeof setTimeout> | null = null;
177
+ watch(sourcePath, () => {
178
+ if (debounce) clearTimeout(debounce);
179
+ debounce = setTimeout(() => {
180
+ console.log(
181
+ `\n${new Date().toLocaleTimeString()} — source changed, re-injecting...`
182
+ );
183
+ try {
184
+ inject(sourcePath, targetPath, moduleName);
185
+ } catch (e: any) {
186
+ console.error("Injection failed:", e.message);
187
+ }
188
+ }, 200);
189
+ });
190
+ }
package/inject-module.js DELETED
@@ -1,131 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Injects the @tomlarkworthy/claude-code-pairing module into a lopecode notebook HTML.
4
- *
5
- * Usage: node tools/channel/inject-module.js <input.html> <output.html>
6
- *
7
- * This:
8
- * 1. Reads the source notebook HTML
9
- * 2. Inserts the claude-code-pairing module as a <script> block before the bootloader
10
- * 3. Adds "@tomlarkworthy/claude-code-pairing" to bootconf.json mains
11
- * 4. Updates the hash URL to include the channel module in the layout
12
- * 5. Writes the result to output.html
13
- */
14
-
15
- import { readFileSync, writeFileSync } from "fs";
16
- import { resolve } from "path";
17
-
18
- const [,, inputPath, outputPath] = process.argv;
19
-
20
- if (!inputPath || !outputPath) {
21
- console.error("Usage: node tools/channel/inject-module.js <input.html> <output.html>");
22
- process.exit(1);
23
- }
24
-
25
- const html = readFileSync(resolve(inputPath), "utf8");
26
- const moduleSource = readFileSync(resolve(import.meta.dirname, "claude-code-pairing-module.js"), "utf8");
27
-
28
- // 1. Find the bootconf.json script and insert our module before it
29
- const bootconfMarker = '<!-- Bootloader -->';
30
- const bootconfIdx = html.lastIndexOf(bootconfMarker);
31
- if (bootconfIdx === -1) {
32
- console.error("Could not find '<!-- Bootloader -->' marker in HTML");
33
- process.exit(1);
34
- }
35
-
36
- const moduleScript = `
37
- <script id="@tomlarkworthy/claude-code-pairing"
38
- type="text/plain"
39
- data-mime="application/javascript"
40
- >
41
- ${moduleSource}
42
- </script>
43
-
44
- `;
45
-
46
- let result = html.slice(0, bootconfIdx) + moduleScript + html.slice(bootconfIdx);
47
-
48
- // 2. Find the actual bootconf.json script block
49
- // Look for the specific pattern: <script id="bootconf.json" with type="text/plain" and data-mime="application/json"
50
- // Use lastIndexOf to skip templates embedded in exporter modules
51
- const bootconfPattern = 'id="bootconf.json" \n type="text/plain"\n data-mime="application/json"';
52
- let bootconfScriptStart = result.lastIndexOf(bootconfPattern);
53
- if (bootconfScriptStart === -1) {
54
- // Try alternative formatting
55
- bootconfScriptStart = result.lastIndexOf('id="bootconf.json"');
56
- // Verify it's followed by data-mime="application/json"
57
- const next200 = result.substring(bootconfScriptStart, bootconfScriptStart + 200);
58
- if (!next200.includes('application/json')) {
59
- console.error("Could not find bootconf.json with application/json mime type");
60
- process.exit(1);
61
- }
62
- }
63
- if (bootconfScriptStart === -1) {
64
- console.error("Could not find bootconf.json script block");
65
- process.exit(1);
66
- }
67
- const bootconfContentStart = result.indexOf('>', bootconfScriptStart) + 1;
68
- const bootconfContentEnd = result.indexOf('</script>', bootconfContentStart);
69
- let bootconfContent = result.slice(bootconfContentStart, bootconfContentEnd);
70
-
71
- // Parse the JSON-like content
72
- try {
73
- const bootconf = JSON.parse(bootconfContent);
74
-
75
- // Add claude-code-pairing to mains
76
- if (!bootconf.mains.includes("@tomlarkworthy/claude-code-pairing")) {
77
- bootconf.mains.push("@tomlarkworthy/claude-code-pairing");
78
- }
79
-
80
- // Update hash to include claude-code-pairing in layout
81
- // Lopepage only supports flat R(S,S,...) — no nesting
82
- // Extract existing module references and add ours as a new panel
83
- const currentHash = bootconf.hash || "";
84
- if (!currentHash.includes("claude-code-pairing")) {
85
- // Parse existing modules from hash like R100(S70(@mod1),S30(@mod2))
86
- const moduleRefs = [];
87
- const modulePattern = /S(\d+)\(([^)]+)\)/g;
88
- let m;
89
- while ((m = modulePattern.exec(currentHash)) !== null) {
90
- moduleRefs.push({ weight: parseInt(m[1]), module: m[2] });
91
- }
92
-
93
- if (moduleRefs.length > 0) {
94
- // Scale existing weights to 75% and add claude-code-pairing at 25%
95
- const totalWeight = moduleRefs.reduce((sum, r) => sum + r.weight, 0);
96
- const scaled = moduleRefs.map(r => ({
97
- weight: Math.round((r.weight / totalWeight) * 75),
98
- module: r.module,
99
- }));
100
- scaled.push({ weight: 25, module: "@tomlarkworthy/claude-code-pairing" });
101
- const parts = scaled.map(r => `S${r.weight}(${r.module})`).join(",");
102
- bootconf.hash = `#view=R100(${parts})`;
103
- } else {
104
- // Simple fallback
105
- bootconf.hash = "#view=R100(S75(@tomlarkworthy/debugger),S25(@tomlarkworthy/claude-code-pairing))";
106
- }
107
- }
108
-
109
- const newBootconfContent = "\n" + JSON.stringify(bootconf, null, 2) + "\n";
110
- result = result.slice(0, bootconfContentStart) + newBootconfContent + result.slice(bootconfContentEnd);
111
- } catch (e) {
112
- console.error("Failed to parse bootconf.json:", e.message);
113
- console.error("Content:", bootconfContent.slice(0, 200));
114
- process.exit(1);
115
- }
116
-
117
- // 4. Update the title
118
- const titleRegex = /<title>[^<]*<\/title>/;
119
- const titleMatch = result.match(titleRegex);
120
- if (titleMatch) {
121
- const currentTitle = titleMatch[0].replace(/<\/?title>/g, "");
122
- if (!currentTitle.includes("claude-channel")) {
123
- result = result.replace(titleRegex, `<title>${currentTitle} + claude-channel</title>`);
124
- }
125
- }
126
-
127
- writeFileSync(resolve(outputPath), result);
128
- const inputSize = (html.length / 1024 / 1024).toFixed(2);
129
- const outputSize = (result.length / 1024 / 1024).toFixed(2);
130
- console.log(`Injected @tomlarkworthy/claude-code-pairing into ${outputPath}`);
131
- console.log(`Input: ${inputSize} MB → Output: ${outputSize} MB`);