@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 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
- # Temporary Holding Version
1
+ # edrys-dev
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ });