@edryslabs/edrys-dev 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/dist/chat.js +21 -0
- package/dist/index.js +13 -0
- package/dist/init.js +108 -0
- package/dist/server.js +6 -1
- package/package.json +1 -1
- package/views/panel.js +27 -0
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ You need [Node.js](https://nodejs.org) 20 or newer.
|
|
|
22
22
|
|
|
23
23
|
Edit lab.yml, save it, and watch this text change.
|
|
24
24
|
width: full
|
|
25
|
+
height: tall
|
|
25
26
|
```
|
|
26
27
|
|
|
27
28
|
2. Start edrys-dev in that folder:
|
|
@@ -89,6 +90,16 @@ edrys-dev can work together with an AI coding agent such as Claude Code or GitHu
|
|
|
89
90
|
|
|
90
91
|
### Setup
|
|
91
92
|
|
|
93
|
+
The quick way, for a new lab:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npx @edryslabs/edrys-dev init my-lab
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
This makes the folder `my-lab` with a starter `lab.yml`, the agent configuration for Claude Code (`.mcp.json`) and VS Code Copilot (`.vscode/mcp.json`), instructions for the agent (`AGENTS.md`, `CLAUDE.md`), Claude Code settings that let the agent use edrys-dev and edit `lab.yml` and `modules/` without asking each time (`.claude/settings.json`), and a `.gitignore`. In a folder that already has some of these files, it keeps them. If the edrys skills (step 2 below) are not installed yet, it offers to install them for you. Then open the folder in your agent and write "start".
|
|
100
|
+
|
|
101
|
+
By hand:
|
|
102
|
+
|
|
92
103
|
1. Create a file `.mcp.json` in your lab folder:
|
|
93
104
|
|
|
94
105
|
```json
|
|
@@ -107,12 +118,14 @@ edrys-dev can work together with an AI coding agent such as Claude Code or GitHu
|
|
|
107
118
|
npx skills add edrys-labs/skills
|
|
108
119
|
```
|
|
109
120
|
|
|
110
|
-
3. Start your agent in the lab folder. **The agent starts edrys-dev itself**; don't start it by hand as well. Ask the agent for the class address if your browser doesn't open it.
|
|
121
|
+
3. Start your agent in the lab folder. **The agent starts edrys-dev itself**; don't start it by hand as well. If the folder has no `lab.yml` yet, edrys-dev creates a starter lab there. Ask the agent for the class address if your browser doesn't open it.
|
|
111
122
|
|
|
112
123
|
### Talking to the agent from the class
|
|
113
124
|
|
|
114
125
|
The class tab has a chat button (🤖, bottom right). What you write there goes straight to the agent. Only you see this chat; it is not the class chat. The agent can answer with text and also with buttons or checkboxes for you to pick from.
|
|
115
126
|
|
|
127
|
+
When the class cannot take your lab file, for example after a typing mistake, a red note in the chat says so in plain words, with edrys-Lite's message under "Details". It goes away once the file loads again.
|
|
128
|
+
|
|
116
129
|
Under your message you see "Waiting for the agent…" until the agent picks it up, then "Thinking…". If nothing happens for 30 seconds, the agent may not be listening. Your message is kept and answered once it is.
|
|
117
130
|
|
|
118
131
|
Some agents don't notice new messages on their own while they are idle. In that case start the `chat` prompt of the edrys server once (in VS Code Copilot: type `/edrys.chat`, or `/mcp.edrys.chat` in older versions), and the agent keeps waiting for your messages.
|
package/dist/chat.js
CHANGED
|
@@ -25,6 +25,18 @@ export const THINKING = 'Thinking…';
|
|
|
25
25
|
const NO_REACTION = 'The agent has not reacted yet, maybe it is not connected. Your message is saved, it answers once it starts.';
|
|
26
26
|
const QUIET = 'The agent is still working, this can take a while.';
|
|
27
27
|
const HINT_MS = { noReaction: 30_000, quiet: 60_000 };
|
|
28
|
+
const STILL = 'The class still shows the last version that worked. Ask the agent here to fix it.';
|
|
29
|
+
/** A LabProblem from the message of edrys-Lite's DevBridge (fetchLab there). */
|
|
30
|
+
export function explain(message) {
|
|
31
|
+
// js-yaml names line and column: "could not parse <path>: <what> (4:4)\n\n<snippet>"
|
|
32
|
+
const line = /^could not parse .*?: .*? \((\d+):\d+\)/.exec(message)?.[1];
|
|
33
|
+
const text = line
|
|
34
|
+
? `There is a typing mistake in the lab file, around line ${line}.`
|
|
35
|
+
: message.startsWith('not a classroom')
|
|
36
|
+
? 'The lab file is not a lab that edrys can read.'
|
|
37
|
+
: 'The class could not load the lab file.';
|
|
38
|
+
return { text: `${text} ${STILL}`, detail: message.slice(0, 2000) };
|
|
39
|
+
}
|
|
28
40
|
/**
|
|
29
41
|
* `values` if they answer `inputs`: only their ids, each with one of its
|
|
30
42
|
* options, a checkbox with a list of them. A radio or buttons input left
|
|
@@ -111,6 +123,7 @@ export class Chat extends EventEmitter {
|
|
|
111
123
|
form = [];
|
|
112
124
|
status = '';
|
|
113
125
|
hint = '';
|
|
126
|
+
lab = null;
|
|
114
127
|
// a message of the user waits for the first sign of an agent
|
|
115
128
|
pending = false;
|
|
116
129
|
quiet;
|
|
@@ -244,6 +257,13 @@ export class Chat extends EventEmitter {
|
|
|
244
257
|
this.setStatus(status ?? '');
|
|
245
258
|
return ask;
|
|
246
259
|
}
|
|
260
|
+
/** The lab file does not load (a problem), or loads again (null): the panel shows it until then. */
|
|
261
|
+
labProblem(problem) {
|
|
262
|
+
if (JSON.stringify(problem) === JSON.stringify(this.lab))
|
|
263
|
+
return;
|
|
264
|
+
this.lab = problem;
|
|
265
|
+
this.send({ type: 'lab', problem });
|
|
266
|
+
}
|
|
247
267
|
setStatus(status) {
|
|
248
268
|
this.status = status;
|
|
249
269
|
this.send({ type: 'status', text: status });
|
|
@@ -372,6 +392,7 @@ export class Chat extends EventEmitter {
|
|
|
372
392
|
open: this.open,
|
|
373
393
|
status: this.status,
|
|
374
394
|
hint: this.hint,
|
|
395
|
+
lab: this.lab,
|
|
375
396
|
};
|
|
376
397
|
}
|
|
377
398
|
send(message) {
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,8 @@ import { reachable } from './origin.js';
|
|
|
9
9
|
const HELP = `edrys-dev: develop an edrys-Lite lab locally
|
|
10
10
|
|
|
11
11
|
Usage: edrys-dev [lab.yml] [options]
|
|
12
|
+
edrys-dev init [folder] set up a new lab for an AI agent: lab.yml,
|
|
13
|
+
agent config and instructions
|
|
12
14
|
|
|
13
15
|
-i, --input <file> lab file (YAML or JSON), default ./lab.yml
|
|
14
16
|
-p, --port <port> port, default 3210 (the next free one if taken)
|
|
@@ -46,7 +48,18 @@ if (values.help) {
|
|
|
46
48
|
say(HELP);
|
|
47
49
|
process.exit(0);
|
|
48
50
|
}
|
|
51
|
+
if (positionals[0] === 'init' && !values.input) {
|
|
52
|
+
const { init } = await import('./init.js');
|
|
53
|
+
await init(positionals[1] ?? '.');
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
49
56
|
const lab = path.resolve(values.input ?? positionals[0] ?? 'lab.yml');
|
|
57
|
+
// an agent started in an empty folder gets a lab to work on, not a failed server
|
|
58
|
+
if (!fs.existsSync(lab) && values.mcp && /\.ya?ml$/i.test(lab) && fs.statSync(path.dirname(lab), { throwIfNoEntry: false })?.isDirectory()) {
|
|
59
|
+
const { starterLab } = await import('./init.js');
|
|
60
|
+
fs.writeFileSync(lab, starterLab(path.basename(path.dirname(lab))), { flag: 'wx' });
|
|
61
|
+
say(`edrys-dev: no lab file yet, created ${lab}`);
|
|
62
|
+
}
|
|
50
63
|
if (!fs.existsSync(lab)) {
|
|
51
64
|
console.error(`edrys-dev: no lab file at ${lab}\n\n${HELP}`);
|
|
52
65
|
process.exit(1);
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// edrys-dev init: a lab folder that an AI agent can start in, without writing
|
|
2
|
+
// any config by hand. Files that exist are kept.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import readline from 'node:readline/promises';
|
|
8
|
+
const SERVER = { command: 'npx', args: ['-y', '@edryslabs/edrys-dev', '--mcp', 'lab.yml'] };
|
|
9
|
+
const SETTINGS = {
|
|
10
|
+
enabledMcpjsonServers: ['edrys'],
|
|
11
|
+
permissions: { allow: ['mcp__edrys', 'Edit(/lab.yml)', 'Edit(/modules/**)'] },
|
|
12
|
+
};
|
|
13
|
+
/** A lab with a welcome page; also what `--mcp` starts with when there is no lab file. */
|
|
14
|
+
export const starterLab = (name) => `name: ${JSON.stringify(name)}
|
|
15
|
+
meta:
|
|
16
|
+
description: Made with edrys-dev.
|
|
17
|
+
modules:
|
|
18
|
+
- url: https://edrys-labs.github.io/module-markdown-it/index.html
|
|
19
|
+
config:
|
|
20
|
+
content: |
|
|
21
|
+
# Welcome
|
|
22
|
+
|
|
23
|
+
Open the 🤖 chat (bottom right) and tell the agent what this lab should do.
|
|
24
|
+
width: full
|
|
25
|
+
height: tall
|
|
26
|
+
`;
|
|
27
|
+
const agents = (name) => `# ${name}
|
|
28
|
+
|
|
29
|
+
This folder is an edrys-Lite lab: \`lab.yml\` is the classroom configuration, \`modules/\` (if there) holds modules written for it. The user works in the class in their browser and talks to you through the chat there; they may not look at this session.
|
|
30
|
+
|
|
31
|
+
When a session starts:
|
|
32
|
+
|
|
33
|
+
1. Call \`get_state\` of the edrys MCP server. If \`connected\` is false, open its \`url\` in the browser (browser automation or the system's open command), or tell the user the address.
|
|
34
|
+
2. If \`messages\` is above 0, call \`read_chat\` and pick up where the chat left off.
|
|
35
|
+
3. Serve the chat until the user says stop: answer every chat event with the \`chat\` tool, as the edrys server's instructions say.
|
|
36
|
+
|
|
37
|
+
The user may not be a programmer. Answer in plain words, show no YAML or code in the chat unless they ask, and offer choices as chat forms (buttons).
|
|
38
|
+
|
|
39
|
+
If the edrys-lab and edrys-module skills are installed, use them to write the lab and its modules.
|
|
40
|
+
`;
|
|
41
|
+
const FILES = [
|
|
42
|
+
['lab.yml', starterLab],
|
|
43
|
+
['.mcp.json', () => JSON.stringify({ mcpServers: { edrys: SERVER } }, null, 2) + '\n'],
|
|
44
|
+
['.vscode/mcp.json', () => JSON.stringify({ servers: { edrys: { type: 'stdio', ...SERVER } } }, null, 2) + '\n'],
|
|
45
|
+
['AGENTS.md', agents],
|
|
46
|
+
// Claude Code reads CLAUDE.md, which imports AGENTS.md
|
|
47
|
+
['CLAUDE.md', () => '@AGENTS.md\n'],
|
|
48
|
+
// no prompts for the edrys server and for edits of the lab and its modules
|
|
49
|
+
['.claude/settings.json', () => JSON.stringify(SETTINGS, null, 2) + '\n'],
|
|
50
|
+
];
|
|
51
|
+
// The edrys skills, for every lab of this user: in ~/.agents/skills, which
|
|
52
|
+
// Copilot reads, linked into ~/.claude/skills. In the lab folder they would be
|
|
53
|
+
// published with it.
|
|
54
|
+
const SKILLS = ['-y', 'skills', 'add', 'edrys-labs/skills', '-g', '--skill', '*', '--agent', 'claude-code', 'github-copilot', '-y'];
|
|
55
|
+
const SKILLS_HINT = 'npx skills add edrys-labs/skills';
|
|
56
|
+
function skillsInstalled() {
|
|
57
|
+
return ['.claude', '.agents'].some((dir) => ['edrys-lab', 'edrys-module'].every((name) => fs.existsSync(path.join(os.homedir(), dir, 'skills', name))));
|
|
58
|
+
}
|
|
59
|
+
async function ask(question) {
|
|
60
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
61
|
+
const answer = (await rl.question(question)).trim();
|
|
62
|
+
rl.close();
|
|
63
|
+
return answer;
|
|
64
|
+
}
|
|
65
|
+
export async function init(folder, say = console.log) {
|
|
66
|
+
const dir = path.resolve(folder);
|
|
67
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
68
|
+
let name = path.basename(dir);
|
|
69
|
+
if (process.stdin.isTTY && !fs.existsSync(path.join(dir, 'lab.yml'))) {
|
|
70
|
+
name = (await ask(`Name of your lab (${name}): `)) || name;
|
|
71
|
+
}
|
|
72
|
+
for (const [file, content] of FILES) {
|
|
73
|
+
const target = path.join(dir, file);
|
|
74
|
+
if (fs.existsSync(target)) {
|
|
75
|
+
say(` kept ${file} (already there)`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
79
|
+
fs.writeFileSync(target, content(name));
|
|
80
|
+
say(` created ${file}`);
|
|
81
|
+
}
|
|
82
|
+
const ignore = path.join(dir, '.gitignore');
|
|
83
|
+
const lines = fs.existsSync(ignore) ? fs.readFileSync(ignore, 'utf8') : null;
|
|
84
|
+
if (lines === null) {
|
|
85
|
+
fs.writeFileSync(ignore, '.edrys-dev/\n');
|
|
86
|
+
say(' created .gitignore');
|
|
87
|
+
}
|
|
88
|
+
else if (!lines.split(/\r?\n/).some((line) => ['.edrys-dev', '.edrys-dev/', '/.edrys-dev/'].includes(line.trim()))) {
|
|
89
|
+
fs.appendFileSync(ignore, (lines === '' || lines.endsWith('\n') ? '' : '\n') + '.edrys-dev/\n');
|
|
90
|
+
say(' added .edrys-dev/ to .gitignore');
|
|
91
|
+
}
|
|
92
|
+
if (!skillsInstalled()) {
|
|
93
|
+
const yes = process.stdin.isTTY &&
|
|
94
|
+
/^(y|yes|)$/i.test(await ask('\nInstall the edrys skills, so your agent knows how labs and modules are written? (Y/n) '));
|
|
95
|
+
// npx is npx.cmd on Windows, which needs a shell
|
|
96
|
+
const run = yes && spawnSync('npx', SKILLS, { stdio: 'inherit', shell: process.platform === 'win32' });
|
|
97
|
+
if (run && run.status === 0)
|
|
98
|
+
say(' installed the edrys skills');
|
|
99
|
+
else
|
|
100
|
+
say(`\n${run ? 'Could not install the edrys skills. ' : ''}To install them later: ${SKILLS_HINT}`);
|
|
101
|
+
}
|
|
102
|
+
say(`
|
|
103
|
+
Your lab is ready in ${dir}
|
|
104
|
+
|
|
105
|
+
Next: open this folder in your AI agent (Claude Code, or VS Code with Copilot)
|
|
106
|
+
and write "start". The agent starts edrys-dev and opens the class in your
|
|
107
|
+
browser; from then on you can talk to it in the 🤖 chat there.`);
|
|
108
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -9,7 +9,7 @@ import { unpack } from 'msgpackr';
|
|
|
9
9
|
import { readSaved, watchFile, watchModules } from './watch.js';
|
|
10
10
|
import { moduleApp } from './modules.js';
|
|
11
11
|
import { hostGuard, hostNames, sameOrigin } from './origin.js';
|
|
12
|
-
import { Chat, panelFiles, panelScript } from './chat.js';
|
|
12
|
+
import { Chat, explain, panelFiles, panelScript } from './chat.js';
|
|
13
13
|
// entries the log keeps, oldest first out
|
|
14
14
|
const LOG_MAX = 300;
|
|
15
15
|
// a body in the log is cut to this many characters of JSON
|
|
@@ -128,6 +128,11 @@ export async function startDevServer(options) {
|
|
|
128
128
|
const { type, ...state } = report;
|
|
129
129
|
classState = state;
|
|
130
130
|
}
|
|
131
|
+
// the user may not see the terminal or the agent: the chat panel says it too
|
|
132
|
+
if (report.type === 'error')
|
|
133
|
+
chat.labProblem(explain(String(report.message)));
|
|
134
|
+
if (report.type === 'applied')
|
|
135
|
+
chat.labProblem(null);
|
|
131
136
|
if (report.type === 'messages' && Array.isArray(report.messages)) {
|
|
132
137
|
for (const m of report.messages)
|
|
133
138
|
addLog({ ...m, body: readableBody(m.body), kind: 'message' });
|
package/package.json
CHANGED
package/views/panel.js
CHANGED
|
@@ -75,6 +75,11 @@
|
|
|
75
75
|
border: 1px solid var(--accent); background: transparent; color: var(--accent);
|
|
76
76
|
}
|
|
77
77
|
.msg form[aria-disabled="true"] { opacity: .5; pointer-events: none; }
|
|
78
|
+
.problem { margin: 0 12px 8px; padding: 8px 10px; border-radius: 8px; font-size: 13px; line-height: 1.4;
|
|
79
|
+
border: 1px solid #e0452b; background: rgba(224,69,43,.1); }
|
|
80
|
+
.problem[hidden] { display: none; }
|
|
81
|
+
.problem summary { cursor: pointer; color: var(--muted); font-size: 12px; margin-top: 4px; }
|
|
82
|
+
.problem pre { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, monospace; }
|
|
78
83
|
.status { padding: 0 12px 6px; font-size: 12px; color: var(--muted); min-height: 1em; }
|
|
79
84
|
.hint { padding: 0 12px 6px; font-size: 12px; color: var(--muted); font-style: italic; }
|
|
80
85
|
.hint:empty { display: none; }
|
|
@@ -107,6 +112,7 @@
|
|
|
107
112
|
<small title="edrys-dev: not the class's chat">only you see this</small>
|
|
108
113
|
</header>
|
|
109
114
|
<div class="log" role="log" aria-live="polite"></div>
|
|
115
|
+
<div class="problem" role="alert" hidden></div>
|
|
110
116
|
<div class="status" aria-live="polite"></div>
|
|
111
117
|
<div class="hint" aria-live="polite"></div>
|
|
112
118
|
<form class="compose">
|
|
@@ -123,6 +129,7 @@
|
|
|
123
129
|
const log = root.querySelector('.log')
|
|
124
130
|
const statusLine = root.querySelector('.status')
|
|
125
131
|
const hintLine = root.querySelector('.hint')
|
|
132
|
+
const problemBox = root.querySelector('.problem')
|
|
126
133
|
const compose = root.querySelector('.compose')
|
|
127
134
|
const textarea = root.querySelector('textarea')
|
|
128
135
|
const menu = root.querySelector('#sessions')
|
|
@@ -287,6 +294,22 @@
|
|
|
287
294
|
})
|
|
288
295
|
}
|
|
289
296
|
|
|
297
|
+
// the lab file does not load: until it does, in plain words, edrys-Lite's message folded
|
|
298
|
+
function showProblem(problem) {
|
|
299
|
+
problemBox.hidden = !problem
|
|
300
|
+
if (!problem) return problemBox.replaceChildren()
|
|
301
|
+
const text = document.createElement('div')
|
|
302
|
+
text.textContent = problem.text
|
|
303
|
+
const details = document.createElement('details')
|
|
304
|
+
const summary = document.createElement('summary')
|
|
305
|
+
summary.textContent = 'Details'
|
|
306
|
+
const pre = document.createElement('pre')
|
|
307
|
+
pre.textContent = problem.detail
|
|
308
|
+
details.append(summary, pre)
|
|
309
|
+
problemBox.replaceChildren(text, details)
|
|
310
|
+
if (!panel.classList.contains('open')) toggle.classList.add('unread')
|
|
311
|
+
}
|
|
312
|
+
|
|
290
313
|
function show(entry) {
|
|
291
314
|
const div = document.createElement('div')
|
|
292
315
|
div.className = 'msg ' + entry.type
|
|
@@ -325,6 +348,10 @@
|
|
|
325
348
|
statusLine.textContent = message.status
|
|
326
349
|
hintLine.textContent = message.hint || ''
|
|
327
350
|
toggle.classList.remove('unread')
|
|
351
|
+
showProblem(message.lab)
|
|
352
|
+
break
|
|
353
|
+
case 'lab':
|
|
354
|
+
showProblem(message.problem)
|
|
328
355
|
break
|
|
329
356
|
case 'entry':
|
|
330
357
|
if (message.entry.type === 'ai' && message.entry.ask) open = message.entry.ask
|