create-byan-agent 2.8.0 → 2.9.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/src/webui/api.js CHANGED
@@ -7,15 +7,46 @@ let yanstaller = null;
7
7
  let detector = null;
8
8
  let backuper = null;
9
9
  let sttEngine = null;
10
+ let AgentPackager = null;
11
+ let ConversationExporter = null;
12
+ let GistClient = null;
10
13
 
11
14
  try { yanstaller = require('../../lib/yanstaller'); } catch { yanstaller = null; }
12
15
  try { detector = require('../../lib/yanstaller/detector'); } catch { detector = null; }
13
16
  try { backuper = require('../../lib/yanstaller/backuper'); } catch { backuper = null; }
14
17
  try { sttEngine = require('../../lib/stt/engine'); } catch { sttEngine = null; }
18
+ try { AgentPackager = require('../../lib/exchange/agent-packager'); } catch { AgentPackager = null; }
19
+ try { ConversationExporter = require('../../lib/exchange/conversation-exporter'); } catch { ConversationExporter = null; }
20
+ try { GistClient = require('../../lib/exchange/gist-client'); } catch { GistClient = null; }
15
21
 
16
22
  const fs = require('fs');
17
23
  const path = require('path');
18
24
 
25
+ const { createBridge } = require('./chat/bridge');
26
+ const { detectCLIs, detectAgents } = require('./chat/cli-detector');
27
+ const SessionManager = require('./chat/session-manager');
28
+
29
+ const activeBridges = new Map();
30
+ let sessionManagerInstance = null;
31
+
32
+ function getSessionManager(server) {
33
+ if (!sessionManagerInstance) {
34
+ sessionManagerInstance = new SessionManager(server.projectRoot);
35
+ }
36
+ return sessionManagerInstance;
37
+ }
38
+
39
+ function sendToSessionClients(server, sessionId, data) {
40
+ const payload = JSON.stringify(data);
41
+ for (const client of server.clients) {
42
+ if (client.readyState === 1) {
43
+ if (!client._chatSessionId || client._chatSessionId === sessionId) {
44
+ client.send(payload);
45
+ }
46
+ }
47
+ }
48
+ }
49
+
19
50
  function json(res, statusCode, data) {
20
51
  res.writeHead(statusCode);
21
52
  res.end(JSON.stringify(data));
@@ -242,12 +273,408 @@ const routes = {
242
273
  } catch (err) {
243
274
  json(res, 500, { error: err.message });
244
275
  }
276
+ },
277
+
278
+ // --- Chat routes ---
279
+
280
+ 'POST chat/start': async (req, res, server) => {
281
+ const { cli, agent, model } = req.body || {};
282
+ const sm = getSessionManager(server);
283
+
284
+ const cliName = cli || 'claude';
285
+ const session = sm.create(cliName, agent || null);
286
+
287
+ try {
288
+ const bridge = createBridge(cliName, {
289
+ projectRoot: server.projectRoot,
290
+ agent: agent || null,
291
+ model: model || null,
292
+ onChunk: (chunk) => {
293
+ sendToSessionClients(server, session.id, {
294
+ type: 'chat',
295
+ sessionId: session.id,
296
+ chunk,
297
+ role: 'assistant',
298
+ });
299
+ },
300
+ onToolUse: (tool) => {
301
+ sendToSessionClients(server, session.id, {
302
+ type: 'chat-tool',
303
+ sessionId: session.id,
304
+ tool,
305
+ });
306
+ },
307
+ onComplete: (result) => {
308
+ sendToSessionClients(server, session.id, {
309
+ type: 'chat-complete',
310
+ sessionId: session.id,
311
+ result,
312
+ });
313
+ },
314
+ onError: (err) => {
315
+ sendToSessionClients(server, session.id, {
316
+ type: 'chat-error',
317
+ sessionId: session.id,
318
+ error: err.message,
319
+ });
320
+ },
321
+ });
322
+
323
+ await bridge.start();
324
+ activeBridges.set(session.id, bridge);
325
+
326
+ json(res, 200, { sessionId: session.id, cli: cliName });
327
+ } catch (err) {
328
+ sm.delete(session.id);
329
+ json(res, 500, { error: err.message });
330
+ }
331
+ },
332
+
333
+ 'POST chat/send': async (req, res, server) => {
334
+ const { sessionId, message } = req.body || {};
335
+
336
+ if (!sessionId || !message) {
337
+ json(res, 400, { error: 'sessionId and message are required' });
338
+ return;
339
+ }
340
+
341
+ const bridge = activeBridges.get(sessionId);
342
+ if (!bridge) {
343
+ json(res, 404, { error: 'No active bridge for session' });
344
+ return;
345
+ }
346
+
347
+ const sm = getSessionManager(server);
348
+ sm.addMessage(sessionId, 'user', message);
349
+
350
+ json(res, 200, { status: 'streaming' });
351
+
352
+ try {
353
+ await bridge.send(message);
354
+ } catch (err) {
355
+ sendToSessionClients(server, sessionId, {
356
+ type: 'chat-error',
357
+ sessionId,
358
+ error: err.message,
359
+ });
360
+ }
361
+ },
362
+
363
+ 'POST chat/stop': async (req, res) => {
364
+ const { sessionId } = req.body || {};
365
+
366
+ if (!sessionId) {
367
+ json(res, 400, { error: 'sessionId is required' });
368
+ return;
369
+ }
370
+
371
+ const bridge = activeBridges.get(sessionId);
372
+ if (bridge) {
373
+ await bridge.stop();
374
+ activeBridges.delete(sessionId);
375
+ }
376
+
377
+ json(res, 200, { status: 'stopped' });
378
+ },
379
+
380
+ 'GET chat/sessions': async (req, res, server) => {
381
+ const sm = getSessionManager(server);
382
+ json(res, 200, { sessions: sm.list() });
383
+ },
384
+
385
+ 'GET chat/session/:id': async (req, res, server) => {
386
+ const sm = getSessionManager(server);
387
+ const session = sm.load(req.params.id);
388
+ if (!session) {
389
+ json(res, 404, { error: 'Session not found' });
390
+ return;
391
+ }
392
+ json(res, 200, { session });
393
+ },
394
+
395
+ 'DELETE chat/session/:id': async (req, res, server) => {
396
+ const sm = getSessionManager(server);
397
+ const bridge = activeBridges.get(req.params.id);
398
+ if (bridge) {
399
+ await bridge.stop();
400
+ activeBridges.delete(req.params.id);
401
+ }
402
+ sm.delete(req.params.id);
403
+ json(res, 200, { status: 'deleted' });
404
+ },
405
+
406
+ // --- Agent routes ---
407
+
408
+ 'GET agents': async (req, res, server) => {
409
+ const agents = await detectAgents(server.projectRoot);
410
+ json(res, 200, { agents });
411
+ },
412
+
413
+ 'GET agents/:name': async (req, res, server) => {
414
+ const agents = await detectAgents(server.projectRoot);
415
+ const agent = agents.find((a) => a.id === req.params.name || a.name === req.params.name);
416
+ if (!agent) {
417
+ json(res, 404, { error: 'Agent not found' });
418
+ return;
419
+ }
420
+ json(res, 200, { agent });
421
+ },
422
+
423
+ // --- CLI detection ---
424
+
425
+ 'GET cli/detect': async (req, res) => {
426
+ const clis = await detectCLIs();
427
+ json(res, 200, { clis });
428
+ },
429
+
430
+ // --- Agent Exchange routes ---
431
+
432
+ 'GET agents/exportable': async (req, res, server) => {
433
+ if (!AgentPackager) {
434
+ json(res, 503, { error: 'Exchange module not available' });
435
+ return;
436
+ }
437
+ try {
438
+ const packager = new AgentPackager(server.projectRoot);
439
+ const agents = await packager.listExportableAgents();
440
+ json(res, 200, { agents });
441
+ } catch (err) {
442
+ json(res, 500, { error: err.message });
443
+ }
444
+ },
445
+
446
+ 'POST agents/export': async (req, res, server) => {
447
+ if (!AgentPackager) {
448
+ json(res, 503, { error: 'Exchange module not available' });
449
+ return;
450
+ }
451
+ const { agentName, compress, author } = req.body || {};
452
+ if (!agentName) {
453
+ json(res, 400, { error: 'Missing agentName' });
454
+ return;
455
+ }
456
+ try {
457
+ const packager = new AgentPackager(server.projectRoot);
458
+ const buffer = await packager.exportAgent(agentName, { compress, author });
459
+ const ext = compress ? '.byan-agent.gz' : '.byan-agent';
460
+ const filename = `${sanitizeForFilename(agentName)}${ext}`;
461
+ const contentType = compress ? 'application/gzip' : 'application/json';
462
+ res.writeHead(200, {
463
+ 'Content-Type': contentType,
464
+ 'Content-Disposition': `attachment; filename="${filename}"`,
465
+ 'Content-Length': buffer.length
466
+ });
467
+ res.end(buffer);
468
+ } catch (err) {
469
+ json(res, 500, { error: err.message });
470
+ }
471
+ },
472
+
473
+ 'POST agents/import': async (req, res, server) => {
474
+ if (!AgentPackager) {
475
+ json(res, 503, { error: 'Exchange module not available' });
476
+ return;
477
+ }
478
+ try {
479
+ const upload = await readUploadedFile(req);
480
+ const packager = new AgentPackager(server.projectRoot);
481
+ const opts = {
482
+ targetModule: upload.fields.targetModule || null,
483
+ overwrite: upload.fields.overwrite === 'true' || upload.fields.overwrite === true
484
+ };
485
+ const result = await packager.importAgent(upload.buffer, opts);
486
+ json(res, 200, result);
487
+ } catch (err) {
488
+ json(res, 500, { error: err.message });
489
+ }
490
+ },
491
+
492
+ 'POST agents/import-url': async (req, res, server) => {
493
+ if (!AgentPackager || !GistClient) {
494
+ json(res, 503, { error: 'Exchange module not available' });
495
+ return;
496
+ }
497
+ const { url } = req.body || {};
498
+ if (!url) {
499
+ json(res, 400, { error: 'Missing url' });
500
+ return;
501
+ }
502
+ try {
503
+ const gist = new GistClient();
504
+ const pkg = url.includes('gist.github.com')
505
+ ? await gist.importFromGist(url)
506
+ : await gist.importFromURL(url);
507
+ const packager = new AgentPackager(server.projectRoot);
508
+ const buffer = Buffer.from(JSON.stringify(pkg), 'utf8');
509
+ const result = await packager.importAgent(buffer);
510
+ json(res, 200, result);
511
+ } catch (err) {
512
+ json(res, 500, { error: err.message });
513
+ }
514
+ },
515
+
516
+ 'POST agents/validate': async (req, res, server) => {
517
+ if (!AgentPackager) {
518
+ json(res, 503, { error: 'Exchange module not available' });
519
+ return;
520
+ }
521
+ try {
522
+ const upload = await readUploadedFile(req);
523
+ const packager = new AgentPackager(server.projectRoot);
524
+ const result = await packager.validate(upload.buffer);
525
+ json(res, 200, result);
526
+ } catch (err) {
527
+ json(res, 500, { error: err.message });
528
+ }
529
+ },
530
+
531
+ // --- Conversation Exchange routes ---
532
+
533
+ 'POST chat/export': async (req, res, server) => {
534
+ if (!ConversationExporter) {
535
+ json(res, 503, { error: 'Exchange module not available' });
536
+ return;
537
+ }
538
+ const { session, format } = req.body || {};
539
+ if (!session) {
540
+ json(res, 400, { error: 'Missing session data' });
541
+ return;
542
+ }
543
+ try {
544
+ const exporter = new ConversationExporter();
545
+ let content, filename, contentType;
546
+ switch (format) {
547
+ case 'markdown':
548
+ content = exporter.exportMarkdown(session);
549
+ filename = `chat-${sanitizeForFilename(session.agent || 'byan')}-${Date.now()}.md`;
550
+ contentType = 'text/markdown; charset=utf-8';
551
+ break;
552
+ case 'template':
553
+ content = exporter.exportTemplate(session);
554
+ filename = `template-${sanitizeForFilename(session.agent || 'byan')}-${Date.now()}.byan-template`;
555
+ contentType = 'application/json; charset=utf-8';
556
+ break;
557
+ default:
558
+ content = exporter.exportJSON(session);
559
+ filename = `chat-${sanitizeForFilename(session.agent || 'byan')}-${Date.now()}.byan-chat`;
560
+ contentType = 'application/json; charset=utf-8';
561
+ break;
562
+ }
563
+ const buf = Buffer.from(content, 'utf8');
564
+ res.writeHead(200, {
565
+ 'Content-Type': contentType,
566
+ 'Content-Disposition': `attachment; filename="${filename}"`,
567
+ 'Content-Length': buf.length
568
+ });
569
+ res.end(buf);
570
+ } catch (err) {
571
+ json(res, 500, { error: err.message });
572
+ }
573
+ },
574
+
575
+ 'POST chat/import': async (req, res) => {
576
+ if (!ConversationExporter) {
577
+ json(res, 503, { error: 'Exchange module not available' });
578
+ return;
579
+ }
580
+ try {
581
+ const upload = await readUploadedFile(req);
582
+ const exporter = new ConversationExporter();
583
+ const content = upload.buffer.toString('utf8');
584
+ let result;
585
+ if (upload.filename && upload.filename.includes('.byan-template')) {
586
+ const template = exporter.importTemplate(content);
587
+ result = { success: true, type: 'template', data: template };
588
+ } else {
589
+ const session = exporter.importJSON(content);
590
+ result = { success: true, type: 'chat', sessionId: session.id, data: session };
591
+ }
592
+ json(res, 200, result);
593
+ } catch (err) {
594
+ json(res, 500, { error: err.message });
595
+ }
596
+ },
597
+
598
+ // --- Gist routes ---
599
+
600
+ 'POST gist/export': async (req, res, server) => {
601
+ if (!AgentPackager || !GistClient) {
602
+ json(res, 503, { error: 'Exchange module not available' });
603
+ return;
604
+ }
605
+ const { agentName, author } = req.body || {};
606
+ if (!agentName) {
607
+ json(res, 400, { error: 'Missing agentName' });
608
+ return;
609
+ }
610
+ try {
611
+ const packager = new AgentPackager(server.projectRoot);
612
+ const buffer = await packager.exportAgent(agentName, { author });
613
+ const pkg = JSON.parse(buffer.toString('utf8'));
614
+ const gist = new GistClient();
615
+ const gistUrl = await gist.exportToGist(pkg);
616
+ json(res, 200, { success: true, gistUrl });
617
+ } catch (err) {
618
+ json(res, 500, { error: err.message });
619
+ }
620
+ },
621
+
622
+ 'POST gist/import': async (req, res, server) => {
623
+ if (!AgentPackager || !GistClient) {
624
+ json(res, 503, { error: 'Exchange module not available' });
625
+ return;
626
+ }
627
+ const { url } = req.body || {};
628
+ if (!url) {
629
+ json(res, 400, { error: 'Missing url' });
630
+ return;
631
+ }
632
+ try {
633
+ const gist = new GistClient();
634
+ const pkg = await gist.importFromGist(url);
635
+ const packager = new AgentPackager(server.projectRoot);
636
+ const buffer = Buffer.from(JSON.stringify(pkg), 'utf8');
637
+ const result = await packager.importAgent(buffer);
638
+ json(res, 200, result);
639
+ } catch (err) {
640
+ json(res, 500, { error: err.message });
641
+ }
245
642
  }
246
643
  };
247
644
 
248
645
  function resolve(method, route) {
249
646
  const key = `${method} ${route}`;
250
- return routes[key] || null;
647
+ if (routes[key]) return routes[key];
648
+
649
+ for (const pattern of Object.keys(routes)) {
650
+ const [pMethod, pRoute] = pattern.split(' ', 2);
651
+ if (pMethod !== method) continue;
652
+ if (!pRoute || !pRoute.includes(':')) continue;
653
+
654
+ const pParts = pRoute.split('/');
655
+ const rParts = route.split('/');
656
+ if (pParts.length !== rParts.length) continue;
657
+
658
+ const params = {};
659
+ let match = true;
660
+ for (let i = 0; i < pParts.length; i++) {
661
+ if (pParts[i].startsWith(':')) {
662
+ params[pParts[i].slice(1)] = rParts[i];
663
+ } else if (pParts[i] !== rParts[i]) {
664
+ match = false;
665
+ break;
666
+ }
667
+ }
668
+
669
+ if (match) {
670
+ return (req, res, server) => {
671
+ req.params = params;
672
+ return routes[pattern](req, res, server);
673
+ };
674
+ }
675
+ }
676
+
677
+ return null;
251
678
  }
252
679
 
253
680
  function sleep(ms) {
@@ -290,4 +717,112 @@ function writeBaseConfig(projectRoot, config) {
290
717
  fs.writeFileSync(configPath, content, 'utf8');
291
718
  }
292
719
 
720
+ function sanitizeForFilename(name) {
721
+ return String(name).replace(/[^a-zA-Z0-9_\-]/g, '').substring(0, 100) || 'file';
722
+ }
723
+
724
+ function readUploadedFile(req) {
725
+ return new Promise((resolve, reject) => {
726
+ const contentType = (req.headers && req.headers['content-type']) || '';
727
+
728
+ if (req.body && req.body._fileBuffer) {
729
+ resolve({
730
+ buffer: Buffer.from(req.body._fileBuffer, 'base64'),
731
+ filename: sanitizeUploadFilename(req.body._fileName || ''),
732
+ fields: req.body
733
+ });
734
+ return;
735
+ }
736
+
737
+ const chunks = [];
738
+ req.removeAllListeners('data');
739
+ req.removeAllListeners('end');
740
+ req.on('data', chunk => chunks.push(chunk));
741
+ req.on('error', reject);
742
+ req.on('end', () => {
743
+ const raw = Buffer.concat(chunks);
744
+ try {
745
+ resolve(parseUploadBody(raw, contentType));
746
+ } catch (err) {
747
+ reject(err);
748
+ }
749
+ });
750
+
751
+ if (req._body !== undefined) {
752
+ const raw = Buffer.from(typeof req._body === 'string' ? req._body : JSON.stringify(req.body || {}), 'utf8');
753
+ try {
754
+ resolve(parseUploadBody(raw, contentType));
755
+ } catch (err) {
756
+ reject(err);
757
+ }
758
+ }
759
+ });
760
+ }
761
+
762
+ function parseUploadBody(raw, contentType) {
763
+ if (contentType.includes('multipart/form-data')) {
764
+ const boundaryMatch = contentType.match(/boundary=([^\s;]+)/);
765
+ if (!boundaryMatch) {
766
+ throw new Error('Missing multipart boundary');
767
+ }
768
+ return extractMultipartFile(raw, boundaryMatch[1]);
769
+ }
770
+
771
+ if (contentType.includes('application/json')) {
772
+ try {
773
+ const parsed = JSON.parse(raw.toString('utf8'));
774
+ if (parsed._fileBuffer) {
775
+ return {
776
+ buffer: Buffer.from(parsed._fileBuffer, 'base64'),
777
+ filename: sanitizeUploadFilename(parsed._fileName || ''),
778
+ fields: parsed
779
+ };
780
+ }
781
+ } catch { /* not JSON with fileBuffer, treat as raw */ }
782
+ }
783
+
784
+ return { buffer: raw, filename: '', fields: {} };
785
+ }
786
+
787
+ function extractMultipartFile(raw, boundary) {
788
+ const rawStr = raw.toString('binary');
789
+ const parts = rawStr.split('--' + boundary);
790
+ const fields = {};
791
+ let fileResult = null;
792
+
793
+ for (const part of parts) {
794
+ if (part.trim() === '' || part.trim() === '--') continue;
795
+
796
+ const headerEnd = part.indexOf('\r\n\r\n');
797
+ if (headerEnd === -1) continue;
798
+
799
+ const headers = part.substring(0, headerEnd);
800
+ const body = part.substring(headerEnd + 4).replace(/\r\n$/, '');
801
+
802
+ const nameMatch = headers.match(/name="([^"]+)"/);
803
+ if (!nameMatch) continue;
804
+ const fieldName = nameMatch[1];
805
+
806
+ if (headers.includes('filename=')) {
807
+ const filenameMatch = headers.match(/filename="([^"]+)"/);
808
+ fileResult = {
809
+ buffer: Buffer.from(body, 'binary'),
810
+ filename: sanitizeUploadFilename(filenameMatch ? filenameMatch[1] : '')
811
+ };
812
+ } else {
813
+ fields[fieldName] = body;
814
+ }
815
+ }
816
+
817
+ if (!fileResult) {
818
+ throw new Error('No file found in multipart upload');
819
+ }
820
+ fileResult.fields = fields;
821
+ return fileResult;
822
+ }
823
+
824
+ function sanitizeUploadFilename(name) {
825
+ return String(name).replace(/[^a-zA-Z0-9_.\-]/g, '').substring(0, 200);
826
+ }
827
+
293
828
  module.exports = { resolve, routes };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Unified Bridge interface for CLI adapters.
3
+ * Each CLI adapter extends Bridge and implements: start(), send(), stop()
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ class Bridge {
10
+ constructor(options = {}) {
11
+ this.projectRoot = options.projectRoot || process.cwd();
12
+ this.agent = options.agent || null;
13
+ this.model = options.model || null;
14
+ this.onChunk = options.onChunk || (() => {});
15
+ this.onToolUse = options.onToolUse || (() => {});
16
+ this.onComplete = options.onComplete || (() => {});
17
+ this.onError = options.onError || (() => {});
18
+ this.process = null;
19
+ this.active = false;
20
+ }
21
+
22
+ async start() { throw new Error('Not implemented'); }
23
+ async send(message) { throw new Error('Not implemented'); }
24
+ async stop() { throw new Error('Not implemented'); }
25
+
26
+ resolveAgent(agentName) {
27
+ if (!agentName) return null;
28
+
29
+ const candidates = [
30
+ path.join(this.projectRoot, '.github', 'agents', `bmad-agent-${agentName}.md`),
31
+ path.join(this.projectRoot, '_byan', 'agents', `${agentName}.md`),
32
+ ];
33
+
34
+ const bmadModules = ['core', 'bmm', 'bmb', 'tea', 'cis'];
35
+ for (const mod of bmadModules) {
36
+ candidates.push(
37
+ path.join(this.projectRoot, '_bmad', mod, 'agents', `${agentName}.md`)
38
+ );
39
+ }
40
+
41
+ for (const candidate of candidates) {
42
+ try {
43
+ if (fs.existsSync(candidate)) return candidate;
44
+ } catch {
45
+ continue;
46
+ }
47
+ }
48
+
49
+ return null;
50
+ }
51
+
52
+ _killProcess(proc, timeoutMs = 5000) {
53
+ if (!proc || proc.exitCode !== null) return Promise.resolve();
54
+
55
+ return new Promise((resolve) => {
56
+ const timer = setTimeout(() => {
57
+ try { proc.kill('SIGKILL'); } catch { /* already dead */ }
58
+ resolve();
59
+ }, timeoutMs);
60
+
61
+ proc.once('exit', () => {
62
+ clearTimeout(timer);
63
+ resolve();
64
+ });
65
+
66
+ try { proc.kill('SIGTERM'); } catch { clearTimeout(timer); resolve(); }
67
+ });
68
+ }
69
+ }
70
+
71
+ function createBridge(cliName, options) {
72
+ const name = (cliName || '').toLowerCase().trim();
73
+
74
+ switch (name) {
75
+ case 'claude': {
76
+ const ClaudeAdapter = require('./claude-adapter');
77
+ return new ClaudeAdapter(options);
78
+ }
79
+ case 'copilot': {
80
+ const CopilotAdapter = require('./copilot-adapter');
81
+ return new CopilotAdapter(options);
82
+ }
83
+ case 'codex': {
84
+ const CodexAdapter = require('./codex-adapter');
85
+ return new CodexAdapter(options);
86
+ }
87
+ default:
88
+ throw new Error(`Unknown CLI adapter: ${cliName}`);
89
+ }
90
+ }
91
+
92
+ module.exports = { Bridge, createBridge };