@edryslabs/edrys-dev 0.1.2 → 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/dist/chat.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import crypto from 'node:crypto';
4
5
  import { EventEmitter } from 'node:events';
@@ -6,59 +7,307 @@ import { createRequire } from 'node:module';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import express from 'express';
8
9
  import { sameOrigin } from './origin.js';
9
- // what the saved chat keeps, oldest first out
10
+ // what a saved chat keeps, oldest first out
10
11
  const MAX_ENTRIES = 500;
12
+ // ids become file names
13
+ const ID = /^[\w-]{1,64}$/;
14
+ // what a panel gets on (re)load, in characters of JSON: 500 long messages
15
+ // could be 25 MB; read_chat has the rest
16
+ const HISTORY_MAX = 1_000_000;
17
+ // a burst of messages is saved once
18
+ const SAVE_MS = 300;
19
+ // The progress line says what the server knows: the message waits until an
20
+ // agent takes it (Chat.activity()), and after a while without any sign of the
21
+ // agent a hint below says so. As in LiaScript DevServer (src/lib.ts there).
22
+ export const WAITING = 'Waiting for the agent…';
23
+ export const THINKING = 'Thinking…';
24
+ // the server cannot know whether an idle agent listens (a channel wakes it)
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
+ const QUIET = 'The agent is still working, this can take a while.';
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
+ }
40
+ /**
41
+ * `values` if they answer `inputs`: only their ids, each with one of its
42
+ * options, a checkbox with a list of them. A radio or buttons input left
43
+ * empty is '' or missing.
44
+ */
45
+ export function validAnswer(inputs, values) {
46
+ if (!values || typeof values !== 'object' || Array.isArray(values))
47
+ return null;
48
+ for (const [id, value] of Object.entries(values)) {
49
+ const input = inputs.find((i) => i.id === id);
50
+ if (!input)
51
+ return null;
52
+ if (input.type === 'checkbox') {
53
+ if (!Array.isArray(value) || new Set(value).size !== value.length)
54
+ return null;
55
+ if (!value.every((v) => input.options.includes(v)))
56
+ return null;
57
+ }
58
+ else if (value !== '' && !input.options.includes(value))
59
+ return null;
60
+ }
61
+ return values;
62
+ }
63
+ // UTC time and a random part: sorted by time, and no clash between two runs
64
+ function newId(t = Date.now()) {
65
+ return new Date(t).toISOString().slice(0, 19).replace(/:/g, '-') + '-' + crypto.randomBytes(2).toString('hex');
66
+ }
67
+ /**
68
+ * The entries of a saved list, numbered upwards also if an older edrys-dev
69
+ * saved them without numbers; [] for a file edited by hand or of another tool.
70
+ */
71
+ function readEntries(list) {
72
+ if (!Array.isArray(list))
73
+ return [];
74
+ const entries = list.filter((e) => e && typeof e === 'object' && typeof e.type === 'string').slice(-MAX_ENTRIES);
75
+ let next = 1;
76
+ for (const entry of entries) {
77
+ if (!Number.isInteger(entry.n) || entry.n < next)
78
+ entry.n = next;
79
+ next = entry.n + 1;
80
+ }
81
+ return entries;
82
+ }
83
+ /** The first message of the user, else the first line of the agent, else the date. */
84
+ export function title(session) {
85
+ const user = session.entries.find((e) => e.type === 'user');
86
+ const ai = session.entries.find((e) => e.type === 'ai');
87
+ let text = user?.type === 'user' ? user.text : '';
88
+ if (!text && ai?.type === 'ai') {
89
+ text = ai.markdown.split('\n').map((line) => line.replace(/[#*_`>]/g, '').trim()).find(Boolean) ?? '';
90
+ }
91
+ text = text.replace(/\s+/g, ' ').trim();
92
+ if (!text)
93
+ return new Date(session.created).toISOString().slice(0, 16).replace('T', ' ');
94
+ return text.length > 60 ? text.slice(0, 59) + '…' : text;
95
+ }
96
+ function summary(session) {
97
+ return {
98
+ id: session.id,
99
+ title: title(session),
100
+ updated: session.entries.at(-1)?.t ?? session.created,
101
+ count: session.entries.length,
102
+ };
103
+ }
11
104
  /**
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.
105
+ * The agent chat over the class tab: its chats, saved next to the lab in
106
+ * .edrys-dev/chats/<id>.json, and the panel's endpoints. Not the class's own
107
+ * (signed) chat: only the author and the agent see it. It continues the chat
108
+ * that changed last; a new one gets its file with its first entry.
15
109
  *
16
110
  * Emits `user` (entry) when the user typed, `answer` (entry) when they
17
- * answered a form, and `connection` (boolean) when the panel came or went.
111
+ * answered a form, `session` ({id, title, resumed}) when they opened another
112
+ * chat, and `connection` (boolean) when the panel came, or was gone for
113
+ * `settleMs`: a reload of the class tab is no going.
18
114
  */
19
115
  export class Chat extends EventEmitter {
20
- file;
21
- entries = [];
116
+ settleMs;
117
+ hintMs;
118
+ /** The chat the panel shows. */
119
+ session;
22
120
  /** The one form the user can still answer. Forms of earlier runs are closed. */
23
121
  open = null;
122
+ /** The inputs of the open form. */
123
+ form = [];
24
124
  status = '';
125
+ hint = '';
126
+ lab = null;
127
+ // a message of the user waits for the first sign of an agent
128
+ pending = false;
129
+ quiet;
130
+ next = 1;
25
131
  panels = new Set();
26
- constructor(file) {
132
+ reported = false;
133
+ settling;
134
+ saveTimer;
135
+ // chats with entries not saved yet
136
+ dirty = new Set();
137
+ // one write after the other, never two at once into the same file
138
+ saving = Promise.resolve();
139
+ dir;
140
+ // the saved chats, for the menu
141
+ summaries = new Map();
142
+ // the chats read in this run: a file may still lag behind them
143
+ loaded = new Map();
144
+ /** `folder` is .edrys-dev next to the lab. */
145
+ constructor(folder, settleMs = 0, hintMs = HINT_MS) {
27
146
  super();
28
- this.file = file;
147
+ this.settleMs = settleMs;
148
+ this.hintMs = hintMs;
149
+ this.dir = path.join(folder, 'chats');
150
+ this.migrate(path.join(folder, 'chat.json'));
151
+ let names = [];
152
+ try {
153
+ names = fs.readdirSync(this.dir);
154
+ }
155
+ catch { }
156
+ for (const name of names) {
157
+ const session = name.endsWith('.json') && this.load(name.slice(0, -5));
158
+ if (session)
159
+ this.summaries.set(session.id, summary(session));
160
+ }
161
+ const latest = this.list()[0];
162
+ this.session = (latest && this.load(latest.id)) || { id: newId(), created: Date.now(), entries: [] };
163
+ this.next = (this.session.entries.at(-1)?.n ?? 0) + 1;
164
+ }
165
+ /** The entries of the chat the panel shows. */
166
+ get entries() {
167
+ return this.session.entries;
168
+ }
169
+ /** The saved chats, the one changed last first. */
170
+ list() {
171
+ return [...this.summaries.values()].sort((a, b) => b.updated - a.updated);
172
+ }
173
+ /** The chat `id`, the current one without; undefined if there is none. */
174
+ read(id) {
175
+ return id === undefined || id === this.session.id ? this.session : this.load(id);
176
+ }
177
+ /**
178
+ * Shows the saved chat `id`, or with null a new one (unless the current one
179
+ * is new still). False if there is no such chat.
180
+ */
181
+ switchTo(id) {
182
+ let next;
183
+ if (id === null) {
184
+ if (!this.session.entries.length)
185
+ return true;
186
+ next = { id: newId(), created: Date.now(), entries: [] };
187
+ }
188
+ else {
189
+ if (id === this.session.id)
190
+ return true;
191
+ next = this.summaries.has(id) ? this.load(id) : undefined;
192
+ if (!next)
193
+ return false;
194
+ }
195
+ // the forms and the progress line belong to the chat before
196
+ this.open = null;
197
+ this.form = [];
198
+ this.pending = false;
199
+ this.status = this.hint = '';
200
+ clearTimeout(this.quiet);
201
+ this.session = next;
202
+ this.next = (next.entries.at(-1)?.n ?? 0) + 1;
203
+ const history = `data: ${JSON.stringify(this.history())}\n\n`;
204
+ for (const panel of this.panels)
205
+ panel.write(history);
206
+ this.emit('session', { id: next.id, title: title(next), resumed: next.entries.length > 0 });
207
+ return true;
208
+ }
209
+ // the chat.json of an older edrys-dev becomes the first saved chat
210
+ migrate(file) {
29
211
  try {
30
- this.entries = JSON.parse(fs.readFileSync(file, 'utf8'));
212
+ const entries = readEntries(JSON.parse(fs.readFileSync(file, 'utf8')));
213
+ if (!entries.length)
214
+ return;
215
+ const session = { id: newId(entries[0].t), created: entries[0].t, entries };
216
+ fs.mkdirSync(this.dir, { recursive: true });
217
+ fs.writeFileSync(path.join(this.dir, session.id + '.json'), JSON.stringify(session));
218
+ fs.rmSync(file);
31
219
  }
32
220
  catch { }
33
221
  }
222
+ load(id) {
223
+ if (!ID.test(id))
224
+ return undefined;
225
+ const known = this.loaded.get(id);
226
+ if (known)
227
+ return known;
228
+ try {
229
+ const saved = JSON.parse(fs.readFileSync(path.join(this.dir, id + '.json'), 'utf8'));
230
+ if (saved?.id !== id || !Array.isArray(saved.entries))
231
+ return undefined;
232
+ const entries = readEntries(saved.entries);
233
+ const created = Number.isFinite(saved.created) ? saved.created : entries[0]?.t ?? 0;
234
+ const session = { id, created, entries };
235
+ this.loaded.set(id, session);
236
+ return session;
237
+ }
238
+ catch {
239
+ return undefined;
240
+ }
241
+ }
242
+ /** Whether a panel is open, or was until less than `settleMs` ago; what it missed comes with the history. */
34
243
  get connected() {
35
- return this.panels.size > 0;
244
+ return this.reported;
36
245
  }
37
246
  /** Posts a message; returns the id of its form if it has inputs. */
38
247
  say(markdown, inputs = [], status) {
39
248
  const ask = inputs.length ? 'ask-' + crypto.randomBytes(4).toString('hex') : undefined;
40
- const entry = { t: Date.now(), type: 'ai', markdown, ...(ask ? { ask, inputs } : {}) };
249
+ const entry = { t: Date.now(), n: this.next++, type: 'ai', markdown, ...(ask ? { ask, inputs } : {}) };
41
250
  // only the newest form stays open
42
251
  if (ask) {
43
252
  this.closeForm();
44
253
  this.open = ask;
254
+ this.form = inputs;
45
255
  }
46
256
  this.add(entry);
47
257
  this.setStatus(status ?? '');
48
258
  return ask;
49
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
+ }
50
267
  setStatus(status) {
51
268
  this.status = status;
52
269
  this.send({ type: 'status', text: status });
270
+ this.watchQuiet();
271
+ }
272
+ /**
273
+ * A sign of the agent: a tool call, or an event handed to it. A message
274
+ * that waited for it is now being worked on.
275
+ */
276
+ activity() {
277
+ this.pending = false;
278
+ if (this.status === WAITING)
279
+ this.setStatus(THINKING);
280
+ else
281
+ this.watchQuiet();
282
+ }
283
+ /** The entries of the user (typed or answered) after entry `n`. */
284
+ newerThan(n) {
285
+ return this.entries.filter((e) => e.n > n && e.type !== 'ai');
53
286
  }
54
287
  /** Withdraws the open form; returns its id, or null if there was none. */
55
- closeForm() {
288
+ closeForm(reason) {
56
289
  const closed = this.open;
57
290
  this.open = null;
58
291
  if (closed)
59
- this.send({ type: 'close', ask: closed });
292
+ this.send({ type: 'close', ask: closed, ...(reason ? { reason } : {}) });
60
293
  return closed;
61
294
  }
295
+ // restarts the clock whenever something happens while a progress line shows
296
+ watchQuiet() {
297
+ clearTimeout(this.quiet);
298
+ if (this.hint)
299
+ this.send({ type: 'hint', text: (this.hint = '') });
300
+ if (!this.status)
301
+ return;
302
+ this.quiet = setTimeout(() => this.send({ type: 'hint', text: (this.hint = this.pending ? NO_REACTION : QUIET) }), this.pending ? this.hintMs.noReaction : this.hintMs.quiet);
303
+ // a pending hint must not keep a stopped edrys-dev alive
304
+ this.quiet.unref();
305
+ }
306
+ // the user sees at once that the message arrived, until the agent takes it
307
+ waitForAgent() {
308
+ this.pending = true;
309
+ this.setStatus(WAITING);
310
+ }
62
311
  routes(app) {
63
312
  app.get('/edrys-dev/chat/events', (req, res) => {
64
313
  res.writeHead(200, {
@@ -66,31 +315,50 @@ export class Chat extends EventEmitter {
66
315
  'Cache-Control': 'no-cache',
67
316
  Connection: 'keep-alive',
68
317
  });
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`);
318
+ // a reload shows the chat again, with the open form and status
319
+ res.write(`data: ${JSON.stringify(this.history())}\n\n`);
71
320
  this.panels.add(res);
72
- if (this.panels.size === 1)
73
- this.emit('connection', true);
321
+ clearTimeout(this.settling);
322
+ if (!this.reported)
323
+ this.emit('connection', (this.reported = true));
74
324
  req.on('close', () => {
75
325
  this.panels.delete(res);
76
- if (this.panels.size === 0)
77
- this.emit('connection', false);
326
+ if (this.panels.size)
327
+ return;
328
+ clearTimeout(this.settling);
329
+ this.settling = setTimeout(() => {
330
+ if (!this.panels.size && this.reported)
331
+ this.emit('connection', (this.reported = false));
332
+ }, this.settleMs);
78
333
  });
79
334
  });
80
335
  app.post('/edrys-dev/chat', express.json(), (req, res) => {
81
336
  if (!sameOrigin(req))
82
337
  return res.sendStatus(403);
83
338
  const { text, ask, values } = req.body ?? {};
339
+ // the menu: another saved chat, or null for a new one
340
+ if (req.body && Object.hasOwn(req.body, 'session')) {
341
+ const { session } = req.body;
342
+ if (session !== null && typeof session !== 'string')
343
+ return res.sendStatus(400);
344
+ return res.sendStatus(this.switchTo(session) ? 204 : 404);
345
+ }
84
346
  if (typeof text === 'string' && text.trim()) {
85
- const entry = { t: Date.now(), type: 'user', text: text.slice(0, 10000) };
347
+ const entry = { t: Date.now(), n: this.next++, type: 'user', text: text.slice(0, 10000) };
86
348
  this.add(entry);
349
+ this.waitForAgent();
87
350
  this.emit('user', entry);
88
351
  return res.sendStatus(204);
89
352
  }
90
- if (typeof ask === 'string' && ask === this.open && values && typeof values === 'object') {
353
+ if (typeof ask === 'string' && ask === this.open) {
354
+ // the agent gets what its form offered, or nothing
355
+ const answer = validAnswer(this.form, values);
356
+ if (!answer)
357
+ return res.sendStatus(400);
91
358
  this.open = null;
92
- const entry = { t: Date.now(), type: 'answer', ask, values };
359
+ const entry = { t: Date.now(), n: this.next++, type: 'answer', ask, values: answer };
93
360
  this.add(entry);
361
+ this.waitForAgent();
94
362
  this.emit('answer', entry);
95
363
  return res.sendStatus(204);
96
364
  }
@@ -99,20 +367,77 @@ export class Chat extends EventEmitter {
99
367
  });
100
368
  }
101
369
  add(entry) {
102
- this.entries.push(entry);
103
- if (this.entries.length > MAX_ENTRIES)
104
- this.entries.splice(0, this.entries.length - MAX_ENTRIES);
370
+ const session = this.session;
371
+ session.entries.push(entry);
372
+ if (session.entries.length > MAX_ENTRIES)
373
+ session.entries.splice(0, session.entries.length - MAX_ENTRIES);
374
+ this.loaded.set(session.id, session);
105
375
  this.send({ type: 'entry', entry });
376
+ // the menu changes with a new chat's first entry, or its first message of the user
377
+ const before = this.summaries.get(session.id)?.title;
378
+ this.summaries.set(session.id, summary(session));
379
+ if (this.summaries.get(session.id).title !== before)
380
+ this.send({ type: 'sessions', sessions: this.list() });
106
381
  this.save();
107
382
  }
383
+ // what a panel gets first, and after a switch
384
+ history() {
385
+ const entries = this.recent();
386
+ return {
387
+ type: 'history',
388
+ session: { id: this.session.id, title: title(this.session) },
389
+ sessions: this.list(),
390
+ entries,
391
+ skipped: this.entries.length - entries.length,
392
+ open: this.open,
393
+ status: this.status,
394
+ hint: this.hint,
395
+ lab: this.lab,
396
+ };
397
+ }
108
398
  send(message) {
109
399
  for (const panel of this.panels)
110
400
  panel.write(`data: ${JSON.stringify(message)}\n\n`);
111
401
  }
402
+ /** The newest entries that fit into HISTORY_MAX, at least the last one. */
403
+ recent() {
404
+ let start = this.entries.length;
405
+ let size = 0;
406
+ while (start > 0) {
407
+ size += JSON.stringify(this.entries[start - 1]).length;
408
+ if (size > HISTORY_MAX && start < this.entries.length)
409
+ break;
410
+ start--;
411
+ }
412
+ return this.entries.slice(start);
413
+ }
112
414
  save() {
415
+ this.dirty.add(this.session);
416
+ this.saveTimer ??= setTimeout(() => this.writeDirty(), SAVE_MS);
417
+ }
418
+ writeDirty() {
419
+ clearTimeout(this.saveTimer);
420
+ this.saveTimer = undefined;
421
+ const sessions = [...this.dirty];
422
+ this.dirty.clear();
423
+ this.saving = this.saving.then(async () => {
424
+ for (const session of sessions)
425
+ await this.write(session);
426
+ });
427
+ }
428
+ /** Writes what is not saved yet, e.g. before edrys-dev exits. */
429
+ async flush() {
430
+ if (this.saveTimer)
431
+ this.writeDirty();
432
+ await this.saving;
433
+ }
434
+ // a copy renamed over the file: a crash midway leaves the old chat, not half a one
435
+ async write(session) {
436
+ const file = path.join(this.dir, session.id + '.json');
113
437
  try {
114
- fs.mkdirSync(path.dirname(this.file), { recursive: true });
115
- fs.writeFileSync(this.file, JSON.stringify(this.entries));
438
+ await fsp.mkdir(this.dir, { recursive: true });
439
+ await fsp.writeFile(file + '.tmp', JSON.stringify(session));
440
+ await fsp.rename(file + '.tmp', file);
116
441
  }
117
442
  catch (error) {
118
443
  console.error('edrys-dev: could not save the chat:', error.message);
package/dist/index.js CHANGED
@@ -5,13 +5,18 @@ import { parseArgs } from 'node:util';
5
5
  import open from 'open';
6
6
  import { startDevServer } from './server.js';
7
7
  import { startMcp } from './mcp.js';
8
+ import { reachable } from './origin.js';
8
9
  const HELP = `edrys-dev: develop an edrys-Lite lab locally
9
10
 
10
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
11
14
 
12
15
  -i, --input <file> lab file (YAML or JSON), default ./lab.yml
13
16
  -p, --port <port> port, default 3210 (the next free one if taken)
14
- --host <host> address to listen on, default 127.0.0.1
17
+ --host <host> address to listen on, default 127.0.0.1; another one
18
+ (e.g. 0.0.0.0) lets others on the network in, with
19
+ --mcp also into the agent chat
15
20
  --local sync on this server instead of edrys-Lite's default
16
21
  transport (works offline)
17
22
  -m, --modules <dir> local modules, one per subfolder, served on the next
@@ -43,7 +48,18 @@ if (values.help) {
43
48
  say(HELP);
44
49
  process.exit(0);
45
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
+ }
46
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
+ }
47
63
  if (!fs.existsSync(lab)) {
48
64
  console.error(`edrys-dev: no lab file at ${lab}\n\n${HELP}`);
49
65
  process.exit(1);
@@ -62,6 +78,14 @@ const server = await startDevServer({
62
78
  modules,
63
79
  agent: values.mcp,
64
80
  });
81
+ // Ctrl+C, or a process manager or agent client stopping it; the chat is saved
82
+ // first. Before anything is printed: whoever waits for that may stop it at once.
83
+ for (const signal of ['SIGINT', 'SIGTERM']) {
84
+ process.once(signal, async () => {
85
+ await server.close();
86
+ process.exit(0);
87
+ });
88
+ }
65
89
  server.bus.on('report', (report) => {
66
90
  switch (report.type) {
67
91
  case 'open':
@@ -77,11 +101,11 @@ const url = `http://${host}:${server.port}/?dev`;
77
101
  say(`edrys-dev: ${lab}\n ${url}`);
78
102
  if (server.modules)
79
103
  say(`modules: ${server.modules.dir}\n ${server.modules.url}<name>/...`);
104
+ if (reachable(values.host)) {
105
+ console.error(`edrys-dev: listening on ${values.host}, not only on this machine: others on the network can open the class` +
106
+ (values.mcp ? ' and type to the agent in its chat' : ''));
107
+ }
80
108
  if (values.mcp)
81
109
  startMcp(server, { lab, url });
82
110
  if (values.open)
83
111
  await open(url);
84
- process.on('SIGINT', async () => {
85
- await server.close();
86
- process.exit(0);
87
- });
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
+ }