@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edryslabs/edrys-dev",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Develop edrys-Lite labs locally: serves edrys-Lite, syncs on its own port and republishes the class whenever the lab file changes",
5
5
  "type": "module",
6
6
  "bin": {
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