@edryslabs/edrys-dev 0.0.0-stage → 0.1.1
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/LICENSE +27 -0
- package/README.md +84 -2
- package/dist/chat.js +136 -0
- package/dist/index.js +86 -0
- package/dist/mcp.js +587 -0
- package/dist/modules.js +130 -0
- package/dist/origin.js +5 -0
- package/dist/server.js +260 -0
- package/dist/sync.js +15 -0
- package/dist/watch.js +44 -0
- package/package.json +58 -4
- package/views/panel.js +277 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 edrys-labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
The MCP server (src/mcp.ts) and the agent chat follow LiaScript DevServer
|
|
26
|
+
(https://github.com/LiaScript/LiaScript-DevServer), MIT License,
|
|
27
|
+
Copyright (c) 2021 LiaScript.
|
package/README.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# edrys-dev
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Develop an [edrys-Lite](https://github.com/edrys-labs/edrys-Lite) lab on your own machine. `edrys-dev` serves edrys-Lite from localhost and keeps a class in step with your lab file: save `lab.yml` and the class updates in every open tab.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g @edryslabs/edrys-dev
|
|
7
|
+
edrys-dev lab.yml --open
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Or without installing: `npx @edryslabs/edrys-dev lab.yml --open`. Needs Node.js 20 or newer.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
edrys-dev: /home/you/my-lab/lab.yml
|
|
14
|
+
http://localhost:3210/?dev
|
|
15
|
+
class open, share link:
|
|
16
|
+
http://localhost:3210/?/classroom/…
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- The tab opened at `/?dev` owns the class. It imports the lab the first time and republishes it, signed, whenever the file changes. The same lab file reopens the same class after a restart.
|
|
20
|
+
- To try the lab as a student or station, open the share link in another browser or a private window. Each browser has its own identity, so it joins as a student, not as the owner.
|
|
21
|
+
- The class connects the way it will for your students: with the lab's own `communicationConfig`, or else edrys-Lite's default transport. With `--local` it syncs over edrys-dev's own port instead, which works offline; the share link then carries the setting (`#comm=…`).
|
|
22
|
+
- The lab file is the source of truth. Changes made in the class's Settings dialog are overwritten on the next save of the file.
|
|
23
|
+
|
|
24
|
+
When the lab is done, publish `lab.yml` as usual (for example with a deploy link to https://edrys-labs.github.io).
|
|
25
|
+
|
|
26
|
+
## Local modules
|
|
27
|
+
|
|
28
|
+
Put modules you are writing into a `modules/` folder next to the lab, one per subfolder, and use their local address in `lab.yml`:
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
modules:
|
|
32
|
+
- url: http://localhost:3211/my-module/index.html
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
edrys-dev serves that folder on the port after its own (3211 by default; another origin, as real modules have). Saving a file in `modules/my-module/` reloads that module in the class tab, without touching the others, and reloads student and station tabs. Its HTML pages get a small script that reports uncaught errors and `console.error` to edrys-dev, for the agent. Such a lab only works on your machine: replace the local addresses with the published ones before you deploy it.
|
|
36
|
+
|
|
37
|
+
## With an AI agent (`--mcp`)
|
|
38
|
+
|
|
39
|
+
With `--mcp`, edrys-dev is an MCP server over stdio: an agent such as Claude Code starts it, edits `lab.yml` with its own file tools and hears from the browser when the class could not take the lab. Put it into the lab folder's `.mcp.json`; do not start `--mcp` yourself:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"mcpServers": {
|
|
44
|
+
"edrys": { "command": "npx", "args": ["-y", "@edryslabs/edrys-dev", "--mcp", "lab.yml"] }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
With edrys-dev installed globally, `"command": "edrys-dev", "args": ["--mcp", "lab.yml"]` starts faster.
|
|
50
|
+
|
|
51
|
+
Tools: `get_state` (lab file, class URL, share link, the class: rooms, users, modules), `goto` (move the class tab to a room, open a student or station tab), `chat` and `read_chat`, `read_messages` (module messages the class tab saw, and errors of local modules), `read_module_state` (what the modules share in a room), `run_in_module` (JavaScript in a local module's frame, only when you ask for it), `wait_for_event`. Browser events reach Claude Code as channel messages; agents without channels read the event stream in `get_state.events` (e.g. with a Monitor) or call `wait_for_event`. If the port is taken, the next free one is used; the agent finds the URL with `get_state`.
|
|
52
|
+
|
|
53
|
+
The class tab gets a chat panel (🤖, bottom right) where you and the agent talk; it is not the class's own chat, nobody else sees it. The agent can ask with buttons or checkboxes there. The chat is saved in `.edrys-dev/chat.json` next to the lab, so a restarted agent can read it; add `.edrys-dev/` to the lab's `.gitignore`.
|
|
54
|
+
|
|
55
|
+
The tools show the agent whether the lab works; what to write comes from the [edrys skills](https://github.com/edrys-labs/skills), recommended with edrys-dev: `edrys-lab` for labs, `edrys-module` for modules. Both say how to use edrys-dev when it is connected.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npx skills add edrys-labs/skills
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Options
|
|
62
|
+
|
|
63
|
+
| Option | Default | |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `[lab.yml]`, `-i, --input <file>` | `./lab.yml` | lab file, YAML or JSON |
|
|
66
|
+
| `-p, --port <port>` | `3210` | the next free port is used if it is taken |
|
|
67
|
+
| `--host <host>` | `127.0.0.1` | address to listen on |
|
|
68
|
+
| `--local` | off | sync on edrys-dev's own port instead of edrys-Lite's default transport |
|
|
69
|
+
| `-m, --modules <dir>` | `./modules` next to the lab, if it exists | local modules, one per subfolder, see above |
|
|
70
|
+
| `-o, --open` | off | open the lab in the browser |
|
|
71
|
+
| `--mcp` | off | serve an AI agent over stdio, see above |
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm install
|
|
77
|
+
npm run build # tsc → dist/
|
|
78
|
+
npm test # builds, then runs test/*.test.js
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
edrys-Lite comes from npm as [`@edryslabs/edrys-lite`](https://www.npmjs.com/package/@edryslabs/edrys-lite), pinned to one version: its `dist/` is what edrys-dev serves. To try changes to edrys-Lite, build it (`npm run build` there) and link the checkout with `npm install ../edrys-Lite`; pin the published version again afterwards. The same goes for [`@edryslabs/websocket-server`](https://www.npmjs.com/package/@edryslabs/websocket-server), an optional dependency that only `--local` uses.
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
[MIT](LICENSE). The MCP server and the agent chat follow [LiaScript DevServer](https://github.com/LiaScript/LiaScript-DevServer) (MIT).
|
package/dist/chat.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { EventEmitter } from 'node:events';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import express from 'express';
|
|
8
|
+
import { sameOrigin } from './origin.js';
|
|
9
|
+
// what the saved chat keeps, oldest first out
|
|
10
|
+
const MAX_ENTRIES = 500;
|
|
11
|
+
/**
|
|
12
|
+
* The agent chat over the class tab: its history, saved next to the lab in
|
|
13
|
+
* .edrys-dev/chat.json, and the panel's endpoints. Not the class's own
|
|
14
|
+
* (signed) chat: only the author and the agent see it.
|
|
15
|
+
*
|
|
16
|
+
* Emits `user` (entry) when the user typed, `answer` (entry) when they
|
|
17
|
+
* answered a form, and `connection` (boolean) when the panel came or went.
|
|
18
|
+
*/
|
|
19
|
+
export class Chat extends EventEmitter {
|
|
20
|
+
file;
|
|
21
|
+
entries = [];
|
|
22
|
+
/** The one form the user can still answer. Forms of earlier runs are closed. */
|
|
23
|
+
open = null;
|
|
24
|
+
status = '';
|
|
25
|
+
panels = new Set();
|
|
26
|
+
constructor(file) {
|
|
27
|
+
super();
|
|
28
|
+
this.file = file;
|
|
29
|
+
try {
|
|
30
|
+
this.entries = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
31
|
+
}
|
|
32
|
+
catch { }
|
|
33
|
+
}
|
|
34
|
+
get connected() {
|
|
35
|
+
return this.panels.size > 0;
|
|
36
|
+
}
|
|
37
|
+
/** Posts a message; returns the id of its form if it has inputs. */
|
|
38
|
+
say(markdown, inputs = [], status) {
|
|
39
|
+
const ask = inputs.length ? 'ask-' + crypto.randomBytes(4).toString('hex') : undefined;
|
|
40
|
+
const entry = { t: Date.now(), type: 'ai', markdown, ...(ask ? { ask, inputs } : {}) };
|
|
41
|
+
// only the newest form stays open
|
|
42
|
+
if (ask) {
|
|
43
|
+
this.closeForm();
|
|
44
|
+
this.open = ask;
|
|
45
|
+
}
|
|
46
|
+
this.add(entry);
|
|
47
|
+
this.setStatus(status ?? '');
|
|
48
|
+
return ask;
|
|
49
|
+
}
|
|
50
|
+
setStatus(status) {
|
|
51
|
+
this.status = status;
|
|
52
|
+
this.send({ type: 'status', text: status });
|
|
53
|
+
}
|
|
54
|
+
/** Withdraws the open form; returns its id, or null if there was none. */
|
|
55
|
+
closeForm() {
|
|
56
|
+
const closed = this.open;
|
|
57
|
+
this.open = null;
|
|
58
|
+
if (closed)
|
|
59
|
+
this.send({ type: 'close', ask: closed });
|
|
60
|
+
return closed;
|
|
61
|
+
}
|
|
62
|
+
routes(app) {
|
|
63
|
+
app.get('/edrys-dev/chat/events', (req, res) => {
|
|
64
|
+
res.writeHead(200, {
|
|
65
|
+
'Content-Type': 'text/event-stream',
|
|
66
|
+
'Cache-Control': 'no-cache',
|
|
67
|
+
Connection: 'keep-alive',
|
|
68
|
+
});
|
|
69
|
+
// a reload shows the whole chat again, with the open form and status
|
|
70
|
+
res.write(`data: ${JSON.stringify({ type: 'history', entries: this.entries, open: this.open, status: this.status })}\n\n`);
|
|
71
|
+
this.panels.add(res);
|
|
72
|
+
if (this.panels.size === 1)
|
|
73
|
+
this.emit('connection', true);
|
|
74
|
+
req.on('close', () => {
|
|
75
|
+
this.panels.delete(res);
|
|
76
|
+
if (this.panels.size === 0)
|
|
77
|
+
this.emit('connection', false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
app.post('/edrys-dev/chat', express.json(), (req, res) => {
|
|
81
|
+
if (!sameOrigin(req))
|
|
82
|
+
return res.sendStatus(403);
|
|
83
|
+
const { text, ask, values } = req.body ?? {};
|
|
84
|
+
if (typeof text === 'string' && text.trim()) {
|
|
85
|
+
const entry = { t: Date.now(), type: 'user', text: text.slice(0, 10000) };
|
|
86
|
+
this.add(entry);
|
|
87
|
+
this.emit('user', entry);
|
|
88
|
+
return res.sendStatus(204);
|
|
89
|
+
}
|
|
90
|
+
if (typeof ask === 'string' && ask === this.open && values && typeof values === 'object') {
|
|
91
|
+
this.open = null;
|
|
92
|
+
const entry = { t: Date.now(), type: 'answer', ask, values };
|
|
93
|
+
this.add(entry);
|
|
94
|
+
this.emit('answer', entry);
|
|
95
|
+
return res.sendStatus(204);
|
|
96
|
+
}
|
|
97
|
+
// e.g. a form that was closed in the meantime
|
|
98
|
+
res.sendStatus(409);
|
|
99
|
+
});
|
|
100
|
+
for (const [file, source] of Object.entries(PANEL_FILES)) {
|
|
101
|
+
// dotfiles: sendFile checks the whole absolute path, and installs often
|
|
102
|
+
// live under one (~/.nvm, ~/.npm/_npx); the path is ours, not the request's
|
|
103
|
+
app.get('/edrys-dev/' + file, (req, res) => res.sendFile(source(), { dotfiles: 'allow' }));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
add(entry) {
|
|
107
|
+
this.entries.push(entry);
|
|
108
|
+
if (this.entries.length > MAX_ENTRIES)
|
|
109
|
+
this.entries.splice(0, this.entries.length - MAX_ENTRIES);
|
|
110
|
+
this.send({ type: 'entry', entry });
|
|
111
|
+
this.save();
|
|
112
|
+
}
|
|
113
|
+
send(message) {
|
|
114
|
+
for (const panel of this.panels)
|
|
115
|
+
panel.write(`data: ${JSON.stringify(message)}\n\n`);
|
|
116
|
+
}
|
|
117
|
+
save() {
|
|
118
|
+
try {
|
|
119
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
120
|
+
fs.writeFileSync(this.file, JSON.stringify(this.entries));
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
console.error('edrys-dev: could not save the chat:', error.message);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const require = createRequire(import.meta.url);
|
|
128
|
+
const views = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'views');
|
|
129
|
+
// browser builds that set window.marked and window.DOMPurify
|
|
130
|
+
const PANEL_FILES = {
|
|
131
|
+
'panel.js': () => path.join(views, 'panel.js'),
|
|
132
|
+
'marked.js': () => path.join(path.dirname(require.resolve('marked/package.json')), 'lib', 'marked.umd.js'),
|
|
133
|
+
'purify.js': () => require.resolve('dompurify/purify.min.js'),
|
|
134
|
+
};
|
|
135
|
+
/** The script tag that brings the panel into the edrys-Lite page. */
|
|
136
|
+
export const PANEL_SCRIPT = '<script src="/edrys-dev/panel.js" defer></script>';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import open from 'open';
|
|
6
|
+
import { startDevServer } from './server.js';
|
|
7
|
+
import { startMcp } from './mcp.js';
|
|
8
|
+
const HELP = `edrys-dev: develop an edrys-Lite lab locally
|
|
9
|
+
|
|
10
|
+
Usage: edrys-dev [lab.yml] [options]
|
|
11
|
+
|
|
12
|
+
-i, --input <file> lab file (YAML or JSON), default ./lab.yml
|
|
13
|
+
-p, --port <port> port, default 3210 (the next free one if taken)
|
|
14
|
+
--host <host> address to listen on, default 127.0.0.1
|
|
15
|
+
--local sync on this server instead of edrys-Lite's default
|
|
16
|
+
transport (works offline)
|
|
17
|
+
-m, --modules <dir> local modules, one per subfolder, served on the next
|
|
18
|
+
port (default ./modules next to the lab, if it exists);
|
|
19
|
+
a save there reloads that module in the class
|
|
20
|
+
-o, --open open the lab in the browser
|
|
21
|
+
--mcp serve an AI agent over stdio (MCP); started by the
|
|
22
|
+
agent from its MCP config, not by hand
|
|
23
|
+
-h, --help show this help
|
|
24
|
+
|
|
25
|
+
The tab opened at /?dev owns the class and republishes it whenever the lab
|
|
26
|
+
file changes. Open its share link in another browser to join as a student.`;
|
|
27
|
+
const { values, positionals } = parseArgs({
|
|
28
|
+
allowPositionals: true,
|
|
29
|
+
options: {
|
|
30
|
+
input: { type: 'string', short: 'i' },
|
|
31
|
+
port: { type: 'string', short: 'p', default: '3210' },
|
|
32
|
+
host: { type: 'string', default: '127.0.0.1' },
|
|
33
|
+
local: { type: 'boolean', default: false },
|
|
34
|
+
modules: { type: 'string', short: 'm' },
|
|
35
|
+
open: { type: 'boolean', short: 'o', default: false },
|
|
36
|
+
mcp: { type: 'boolean', default: false },
|
|
37
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
// with --mcp, stdout carries the protocol
|
|
41
|
+
const say = values.mcp ? console.error : console.log;
|
|
42
|
+
if (values.help) {
|
|
43
|
+
say(HELP);
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
const lab = path.resolve(values.input ?? positionals[0] ?? 'lab.yml');
|
|
47
|
+
if (!fs.existsSync(lab)) {
|
|
48
|
+
console.error(`edrys-dev: no lab file at ${lab}\n\n${HELP}`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
const nextToLab = path.join(path.dirname(lab), 'modules');
|
|
52
|
+
const modules = values.modules ? path.resolve(values.modules) : fs.existsSync(nextToLab) ? nextToLab : undefined;
|
|
53
|
+
if (modules && !fs.statSync(modules, { throwIfNoEntry: false })?.isDirectory()) {
|
|
54
|
+
console.error(`edrys-dev: no modules folder at ${modules}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
const server = await startDevServer({
|
|
58
|
+
lab,
|
|
59
|
+
port: Number(values.port),
|
|
60
|
+
host: values.host,
|
|
61
|
+
local: values.local,
|
|
62
|
+
modules,
|
|
63
|
+
});
|
|
64
|
+
server.bus.on('report', (report) => {
|
|
65
|
+
switch (report.type) {
|
|
66
|
+
case 'open':
|
|
67
|
+
say(`class open, share link:\n ${report.url}`);
|
|
68
|
+
break;
|
|
69
|
+
case 'error':
|
|
70
|
+
console.error(`lab error: ${report.message}`);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
const host = ['127.0.0.1', '0.0.0.0', '::'].includes(values.host) ? 'localhost' : values.host;
|
|
75
|
+
const url = `http://${host}:${server.port}/?dev`;
|
|
76
|
+
say(`edrys-dev: ${lab}\n ${url}`);
|
|
77
|
+
if (server.modules)
|
|
78
|
+
say(`modules: ${server.modules.dir}\n ${server.modules.url}<name>/...`);
|
|
79
|
+
if (values.mcp)
|
|
80
|
+
startMcp(server, { lab, url });
|
|
81
|
+
if (values.open)
|
|
82
|
+
await open(url);
|
|
83
|
+
process.on('SIGINT', async () => {
|
|
84
|
+
await server.close();
|
|
85
|
+
process.exit(0);
|
|
86
|
+
});
|