@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.
- package/README.md +205 -0
- package/claude-code-pairing-module.js +292 -139
- package/lopecode-channel.ts +224 -11
- package/package.json +3 -2
- package/sync-module.ts +190 -0
- package/inject-module.js +0 -131
package/lopecode-channel.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
"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
|
-
"
|
|
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`);
|