From d24c7185fc456a521e89fbdb4691e2d0f3e868cc Mon Sep 17 00:00:00 2001 From: Md Ayan Date: Mon, 15 Jun 2026 23:31:16 +0530 Subject: [PATCH] feat: add web capabilities dashboard (#2100) * feat: add web capabilities dashboard with agents, skills, commands, MCPs, rules, and hooks * fix: address code review - XSS, env exposure, port validation, error handling, packaging * add tests for dashboard --- package.json | 4 +- scripts/dashboard-web.js | 775 +++++++++++++++++++++ tests/scripts/dashboard-web.test.js | 785 ++++++++++++++++++++++ tests/scripts/npm-publish-surface.test.js | 1 + 4 files changed, 1564 insertions(+), 1 deletion(-) create mode 100644 scripts/dashboard-web.js create mode 100644 tests/scripts/dashboard-web.test.js diff --git a/package.json b/package.json index 6998ead8..cca10388 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "scripts/preview-pack-smoke.js", "scripts/release-approval-gate.js", "scripts/release-video-suite.js", + "scripts/dashboard-web.js", "scripts/skills-health.js", "scripts/hooks/", "scripts/install-apply.js", @@ -350,7 +351,8 @@ "coverage": "c8 --all --include=\"scripts/**/*.js\" --check-coverage --lines 80 --functions 80 --branches 80 --statements 80 --reporter=text --reporter=lcov node tests/run-all.js", "build:opencode": "node scripts/build-opencode.js", "prepack": "npm run build:opencode", - "dashboard": "python3 ./ecc_dashboard.py" + "dashboard": "python3 ./ecc_dashboard.py", + "dashboard:web": "node scripts/dashboard-web.js" }, "dependencies": { "@iarna/toml": "^2.2.5", diff --git a/scripts/dashboard-web.js b/scripts/dashboard-web.js new file mode 100644 index 00000000..011e24da --- /dev/null +++ b/scripts/dashboard-web.js @@ -0,0 +1,775 @@ +#!/usr/bin/env node +/** + * ECC Capabilities Dashboard — agents, skills, commands, MCPs, rules & hooks + * With multi-language, routing, search suggestions, recently viewed, fine UI + * + * Usage: node scripts/dashboard-web.js [port] + * Open http://localhost:3456 + * + * Contribution: https://github.com/affaan-m/ECC + */ + +const fs = require('fs'); +const path = require('path'); +const http = require('http'); + +function parsePort(v) { + const n = parseInt(String(v), 10); + if (isNaN(n) || n < 1 || n > 65535) { console.error('[ECC] Invalid port: ' + v + ' — using 3456'); return 3456; } + return n; +} +const PORT = parsePort(process.argv[2] || process.env.ECC_DASHBOARD_PORT || '3456'); +const ROOT = path.resolve(__dirname, '..'); + +function readFrontmatter(p) { + try { + const c = fs.readFileSync(p, 'utf8'); + const m = c.match(/^---\n([\s\S]*?)\n---/); + if (!m) return {}; + const fm = {}; + for (const l of m[1].split('\n')) { + const s = l.indexOf(':'); if (s <= 0) continue; + let k = l.slice(0, s).trim(), v = l.slice(s + 1).trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); + if (v.startsWith('[') && v.endsWith(']')) { try { v = JSON.parse(v); } catch { v = v.slice(1, -1).split(',').map(x => x.trim().replace(/["']/g, '')); } } + fm[k] = v; + } + fm._body = c.replace(/^---[\s\S]*?---\n*/, '').trim(); + return fm; + } catch { return {}; } +} +function readSkill(p) { try { const c = fs.readFileSync(p, 'utf8'); const fm = readFrontmatter(p); return { d: fm.description || '', b: c.replace(/^---[\s\S]*?---\n*/, '').trim() }; } catch { return { d: '', b: '' }; } } + +function loadAgents(_root) { + const root = _root || ROOT; + const dir = path.join(root, 'agents'); if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir).filter(f => f.endsWith('.md')).sort().map(f => { + const fm = readFrontmatter(path.join(dir, f)); + return { n: fm.name || f.replace('.md', ''), d: fm.description || '', m: fm.model || 'default', t: Array.isArray(fm.tools) ? fm.tools : [], b: (fm._body || '').slice(0, 1200), f }; + }); +} +function loadSkills(_root) { + const root = _root || ROOT; + const dir = path.join(root, 'skills'); if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir).filter(d => { try { return fs.statSync(path.join(dir, d)).isDirectory(); } catch { return false; } }).sort().map(d => { + const r = readSkill(path.join(dir, d, 'SKILL.md')); return { n: d, d: r.d, b: r.b.slice(0, 1000) }; + }); +} +function loadCommands(_root) { + const root = _root || ROOT; + const dir = path.join(root, 'commands'); if (!fs.existsSync(dir)) return []; + const cm = { plan: 'Planning', 'plan-': 'Planning', 'prp-': 'Git & PR', pr: 'Git & PR', 'review-': 'Review', 'code-': 'Review', build: 'Build', fix: 'Build', test: 'Testing', 'e2e': 'Testing', coverage: 'Testing', quality: 'Testing', session: 'Session', save: 'Session', resume: 'Session', skill: 'Knowledge', learn: 'Knowledge', instinct: 'Knowledge', evolve: 'Knowledge', ecc: 'System', hookify: 'System', model: 'System', setup: 'System', multi: 'Multi-Agent', security: 'Security', harness: 'Security', 'go-': 'Languages', 'rust-': 'Languages', 'cpp-': 'Languages', 'kotlin-': 'Languages', 'flutter-': 'Languages', 'react-': 'Languages', 'python-': 'Languages', 'fastapi-': 'Languages', 'gradle-': 'Languages', gan: 'GAN', marketing: 'Marketing', jira: 'Project', pm2: 'Process', cost: 'Analytics', promote: 'Project', aside: 'Other', santa: 'Fun' }; + return fs.readdirSync(dir).filter(f => f.endsWith('.md')).sort().map(f => { + const fm = readFrontmatter(path.join(dir, f)); + const n = '/' + f.replace('.md', ''); let c = 'Other'; + for (const [p, cat] of Object.entries(cm)) if (f.startsWith(p)) { c = cat; break; } + return { n, f, d: fm.description || fm['argument-hint'] || '', c, b: (fm._body || '').slice(0, 600) }; + }); +} +function loadRules(_root) { + const root = _root || ROOT; + const dir = path.join(root, 'rules'); if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir).filter(d => { try { return fs.statSync(path.join(dir, d)).isDirectory(); } catch { return false; } }).sort().map(l => ({ l, f: fs.readdirSync(path.join(dir, l)).filter(f => f.endsWith('.md')).sort().map(f => f.replace('.md', '')) })); +} +function loadMcps(_root) { + const root = _root || ROOT; + const r = []; + const m = path.join(root, '.mcp.json'); + if (fs.existsSync(m)) { try { const d = JSON.parse(fs.readFileSync(m, 'utf8')); r.push({ f: '.mcp.json', s: Object.entries(d.mcpServers || {}).map(([k, v]) => ({ n: k, cmd: typeof v === 'object' ? (v.command || v.url || '') : String(v), args: v.args || [], env: v.env ? Object.keys(v.env).reduce((a,k)=>{a[k]='••••••'; return a;}, {}) : {}, type: v.type || 'stdio' })) }); } catch (e) { console.error('[ECC] Failed to parse .mcp.json:', e.message); } } + const dir = path.join(root, 'mcp-configs'); + if (fs.existsSync(dir)) { for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.json'))) { try { const d = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); r.push({ f, s: Object.entries(d.mcpServers || {}).map(([k, v]) => ({ n: k, cmd: typeof v === 'object' ? (v.command || v.url || '') : String(v), args: v.args || [], env: v.env ? Object.keys(v.env).reduce((a,k)=>{a[k]='••••••'; return a;}, {}) : {}, type: v.type || 'stdio' })) }); } catch (e) { console.error('[ECC] Failed to parse mcp-configs/' + f + ':', e.message); } } } + return r; +} +function loadHooks(_root) { + const root = _root || ROOT; + const p = path.join(root, 'hooks', 'hooks.json'); if (!fs.existsSync(p)) return []; + try { const d = JSON.parse(fs.readFileSync(p, 'utf8')); const h = []; for (const [ev, es] of Object.entries(d.hooks || {})) for (const e of es || []) h.push({ ev, m: e.matcher || '*', id: e.id || '', d: e.description || '' }); return h; } catch (e) { console.error('[ECC] Failed to parse hooks/hooks.json:', e.message); return []; } +} + +const LANG = { + en: { name:'English', title:'ECC Capabilities', search:'Search agents, skills, commands...', agents:'Agents', skills:'Skills', commands:'Commands', rules:'Rules', mcps:'MCPs', hooks:'Hooks', ruleSets:'Rule Sets', mcpConfigs:'MCP Configs', all:'All', reviewers:'Reviewers', buildResolvers:'Build Resolvers', architects:'Architects', security:'Security', testing:'Testing', patterns:'Patterns', design:'Design', research:'Research', data:'Data', agent:'Agent', devops:'DevOps', description:'Description', details:'Details', tools:'Tools', copied:'Copied', noMcps:'No MCP configs found', checkMcps:'Check mcp-configs/ directory', noHooks:'No hooks configured', recentlyViewed:'Recently Viewed', clearHistory:'Clear', ruleFiles:'rule files', more:'more', servers:'servers', skill:'Skill', workflow:'workflow', event:'Event', matcher:'Matcher', id:'ID', contribution:'Contribution to ECC' }, + pt: { name:'Português', title:'Recursos do ECC', search:'Pesquisar agentes, skills, comandos...', agents:'Agentes', skills:'Skills', commands:'Comandos', rules:'Regras', mcps:'MCPs', hooks:'Hooks', ruleSets:'Conjuntos de Regras', mcpConfigs:'Configs MCP', all:'Todos', reviewers:'Revisores', buildResolvers:'Resolvedores', architects:'Arquitetos', security:'Segurança', testing:'Testes', patterns:'Padrões', design:'Design', research:'Pesquisa', data:'Dados', agent:'Agente', devops:'DevOps', description:'Descrição', details:'Detalhes', tools:'Ferramentas', copied:'Copiado', noMcps:'Nenhuma config MCP encontrada', checkMcps:'Verifique mcp-configs/', noHooks:'Nenhum hook configurado', recentlyViewed:'Vistos Recentemente', clearHistory:'Limpar', ruleFiles:'arquivos de regras', more:'mais', servers:'servidores', skill:'Skill', workflow:'workflow', event:'Evento', matcher:'Corresp.', id:'ID', contribution:'Contribuição ao ECC' }, + zh: { name:'简体中文', title:'ECC 能力', search:'搜索代理、技能、命令...', agents:'代理', skills:'技能', commands:'命令', rules:'规则', mcps:'MCP', hooks:'钩子', ruleSets:'规则集', mcpConfigs:'MCP 配置', all:'全部', reviewers:'审查者', buildResolvers:'构建解析器', architects:'架构师', security:'安全', testing:'测试', patterns:'模式', design:'设计', research:'研究', data:'数据', agent:'代理', devops:'运维', description:'描述', details:'详情', tools:'工具', copied:'已复制', noMcps:'未找到 MCP 配置', checkMcps:'检查 mcp-configs/ 目录', noHooks:'未配置钩子', recentlyViewed:'最近查看', clearHistory:'清除', ruleFiles:'规则文件', more:'更多', servers:'服务器', skill:'技能', workflow:'工作流', event:'事件', matcher:'匹配器', id:'ID', contribution:'对 ECC 的贡献' }, + zht: { name:'繁體中文', title:'ECC 能力', search:'搜索代理、技能、命令...', agents:'代理', skills:'技能', commands:'命令', rules:'規則', mcps:'MCP', hooks:'鉤子', ruleSets:'規則集', mcpConfigs:'MCP 配置', all:'全部', reviewers:'審查者', buildResolvers:'構建解析器', architects:'架構師', security:'安全', testing:'測試', patterns:'模式', design:'設計', research:'研究', data:'數據', agent:'代理', devops:'運維', description:'描述', details:'詳情', tools:'工具', copied:'已複製', noMcps:'未找到 MCP 配置', checkMcps:'檢查 mcp-configs/ 目錄', noHooks:'未配置鉤子', recentlyViewed:'最近查看', clearHistory:'清除', ruleFiles:'規則文件', more:'更多', servers:'服務器', skill:'技能', workflow:'工作流', event:'事件', matcher:'匹配器', id:'ID', contribution:'對 ECC 的貢獻' }, + ja: { name:'日本語', title:'ECC 機能一覧', search:'エージェント、スキル、コマンドを検索...', agents:'エージェント', skills:'スキル', commands:'コマンド', rules:'ルール', mcps:'MCP', hooks:'フック', ruleSets:'ルールセット', mcpConfigs:'MCP設定', all:'すべて', reviewers:'レビュアー', buildResolvers:'ビルド解決', architects:'アーキテクト', security:'セキュリティ', testing:'テスト', patterns:'パターン', design:'デザイン', research:'研究', data:'データ', agent:'エージェント', devops:'DevOps', description:'説明', details:'詳細', tools:'ツール', copied:'コピーしました', noMcps:'MCP設定が見つかりません', checkMcps:'mcp-configs/を確認', noHooks:'フックが設定されていません', recentlyViewed:'最近見た項目', clearHistory:'クリア', ruleFiles:'ルールファイル', more:'もっと見る', servers:'サーバー', skill:'スキル', workflow:'ワークフロー', event:'イベント', matcher:'マッチャー', id:'ID', contribution:'ECCへの貢献' }, + ko: { name:'한국어', title:'ECC 기능', search:'에이전트, 스킬, 명령어 검색...', agents:'에이전트', skills:'스킬', commands:'명령어', rules:'규칙', mcps:'MCP', hooks:'훅', ruleSets:'규칙 세트', mcpConfigs:'MCP 설정', all:'전체', reviewers:'리뷰어', buildResolvers:'빌드 해결사', architects:'아키텍트', security:'보안', testing:'테스트', patterns:'패턴', design:'디자인', research:'연구', data:'데이터', agent:'에이전트', devops:'DevOps', description:'설명', details:'세부정보', tools:'도구', copied:'복사됨', noMcps:'MCP 설정을 찾을 수 없음', checkMcps:'mcp-configs/ 확인', noHooks:'훅이 설정되지 않음', recentlyViewed:'최근 본 항목', clearHistory:'지우기', ruleFiles:'규칙 파일', more:'더보기', servers:'서버', skill:'스킬', workflow:'워크플로우', event:'이벤트', matcher:'매처', id:'ID', contribution:'ECC에 기여' }, + tr: { name:'Türkçe', title:'ECC Yetenekleri', search:'Ajan, beceri, komut ara...', agents:'Ajanlar', skills:'Beceriler', commands:'Komutlar', rules:'Kurallar', mcps:'MCP\'ler', hooks:'Kancalar', ruleSets:'Kural Setleri', mcpConfigs:'MCP Yapılandırmaları', all:'Tümü', reviewers:'İnceleyenler', buildResolvers:'Derleme Çözücüler', architects:'Mimarlar', security:'Güvenlik', testing:'Test', patterns:'Desenler', design:'Tasarım', research:'Araştırma', data:'Veri', agent:'Ajan', devops:'DevOps', description:'Açıklama', details:'Detaylar', tools:'Araçlar', copied:'Kopyalandı', noMcps:'MCP yapılandırması bulunamadı', checkMcps:'mcp-configs/ dizinini kontrol edin', noHooks:'Kanca yapılandırılmamış', recentlyViewed:'Son Görüntülenenler', clearHistory:'Temizle', ruleFiles:'kural dosyası', more:'daha fazla', servers:'sunucu', skill:'Beceri', workflow:'iş akışı', event:'Olay', matcher:'Eşleştirici', id:'ID', contribution:'ECC\'ye Katkı' }, + ru: { name:'Русский', title:'Возможности ECC', search:'Поиск агентов, навыков, команд...', agents:'Агенты', skills:'Навыки', commands:'Команды', rules:'Правила', mcps:'MCP', hooks:'Хуки', ruleSets:'Наборы правил', mcpConfigs:'MCP конфиги', all:'Все', reviewers:'Ревьюеры', buildResolvers:'Сборщики', architects:'Архитекторы', security:'Безопасность', testing:'Тестирование', patterns:'Паттерны', design:'Дизайн', research:'Исследования', data:'Данные', agent:'Агент', devops:'DevOps', description:'Описание', details:'Детали', tools:'Инструменты', copied:'Скопировано', noMcps:'MCP конфиги не найдены', checkMcps:'Проверьте mcp-configs/', noHooks:'Хуки не настроены', recentlyViewed:'Недавние', clearHistory:'Очистить', ruleFiles:'файлов правил', more:'ещё', servers:'серверов', skill:'Навык', workflow:'воркфлоу', event:'Событие', matcher:'Матчер', id:'ID', contribution:'Вклад в ECC' }, + vi: { name:'Tiếng Việt', title:'Năng lực ECC', search:'Tìm kiếm agent, kỹ năng, lệnh...', agents:'Agent', skills:'Kỹ năng', commands:'Lệnh', rules:'Luật', mcps:'MCP', hooks:'Hook', ruleSets:'Bộ luật', mcpConfigs:'Cấu hình MCP', all:'Tất cả', reviewers:'Người đánh giá', buildResolvers:'Trình giải quyết build', architects:'Kiến trúc sư', security:'Bảo mật', testing:'Kiểm thử', patterns:'Mẫu', design:'Thiết kế', research:'Nghiên cứu', data:'Dữ liệu', agent:'Agent', devops:'DevOps', description:'Mô tả', details:'Chi tiết', tools:'Công cụ', copied:'Đã sao chép', noMcps:'Không tìm thấy cấu hình MCP', checkMcps:'Kiểm tra mcp-configs/', noHooks:'Chưa có hook nào', recentlyViewed:'Đã xem gần đây', clearHistory:'Xóa', ruleFiles:'tệp luật', more:'thêm', servers:'máy chủ', skill:'Kỹ năng', workflow:'quy trình', event:'Sự kiện', matcher:'Bộ so khớp', id:'ID', contribution:'Đóng góp cho ECC' }, + th: { name:'ไทย', title:'ความสามารถ ECC', search:'ค้นหาเอเจนต์ ทักษะ คำสั่ง...', agents:'เอเจนต์', skills:'ทักษะ', commands:'คำสั่ง', rules:'กฎ', mcps:'MCP', hooks:'ฮุค', ruleSets:'ชุดกฎ', mcpConfigs:'การตั้งค่า MCP', all:'ทั้งหมด', reviewers:'ผู้ตรวจสอบ', buildResolvers:'ตัวแก้ไขบิลด์', architects:'สถาปนิก', security:'ความปลอดภัย', testing:'การทดสอบ', patterns:'รูปแบบ', design:'ออกแบบ', research:'วิจัย', data:'ข้อมูล', agent:'เอเจนต์', devops:'DevOps', description:'คำอธิบาย', details:'รายละเอียด', tools:'เครื่องมือ', copied:'คัดลอกแล้ว', noMcps:'ไม่พบการตั้งค่า MCP', checkMcps:'ตรวจสอบ mcp-configs/', noHooks:'ไม่มีการตั้งค่าฮุค', recentlyViewed:'ที่ดูล่าสุด', clearHistory:'ล้าง', ruleFiles:'ไฟล์กฎ', more:'เพิ่มเติม', servers:'เซิร์ฟเวอร์', skill:'ทักษะ', workflow:'เวิร์กโฟลว์', event:'เหตุการณ์', matcher:'ตัวจับคู่', id:'ID', contribution:'มีส่วนร่วมกับ ECC' }, + de: { name:'Deutsch', title:'ECC-Funktionen', search:'Agenten, Fähigkeiten, Befehle suchen...', agents:'Agenten', skills:'Fähigkeiten', commands:'Befehle', rules:'Regeln', mcps:'MCPs', hooks:'Hooks', ruleSets:'Regelsätze', mcpConfigs:'MCP-Konfigurationen', all:'Alle', reviewers:'Prüfer', buildResolvers:'Build-Resolver', architects:'Architekten', security:'Sicherheit', testing:'Tests', patterns:'Muster', design:'Design', research:'Forschung', data:'Daten', agent:'Agent', devops:'DevOps', description:'Beschreibung', details:'Details', tools:'Werkzeuge', copied:'Kopiert', noMcps:'Keine MCP-Konfigurationen gefunden', checkMcps:'Prüfen Sie mcp-configs/', noHooks:'Keine Hooks konfiguriert', recentlyViewed:'Zuletzt angesehen', clearHistory:'Löschen', ruleFiles:'Regeldateien', more:'mehr', servers:'Server', skill:'Fähigkeit', workflow:'Workflow', event:'Ereignis', matcher:'Matcher', id:'ID', contribution:'Beitrag zu ECC' }, +}; +const LANG_KEYS = Object.keys(LANG); + +function renderHTML(data) { + // data passed from Node.js - use for static template values + const ag = JSON.stringify(data.agents).replace(/ + + + + +ECC Capabilities + + + +
+ +
+
+ +
+ +
+
+ +
+
+ + + +
+ +
+ + + +`; + /* eslint-enable no-useless-escape */ +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + if (url.pathname === '/api/data') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ agents: loadAgents(), skills: loadSkills(), commands: loadCommands(), rules: loadRules(), mcps: loadMcps(), hooks: loadHooks() })); + } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(renderHTML({ agents: loadAgents(), skills: loadSkills(), commands: loadCommands(), rules: loadRules(), mcps: loadMcps(), hooks: loadHooks() })); +}); + +if (require.main === module) { + server.listen(PORT, () => { + console.log(`\n 🧩 ECC Capabilities → http://localhost:${PORT}\n`); + try { const { spawn } = require('child_process'); const p = process.platform; const c = p === 'darwin' ? 'open' : p === 'win32' ? 'start' : 'xdg-open'; if (c === 'start') spawn('cmd', ['/c', 'start', `http://localhost:${PORT}`], { stdio: 'ignore' }); else spawn(c, [`http://localhost:${PORT}`], { stdio: 'ignore' }); } catch { /* best-effort auto-open */ } + }); +} + +module.exports = { parsePort, readFrontmatter, readSkill, loadAgents, loadSkills, loadCommands, loadRules, loadMcps, loadHooks, renderHTML, LANG, LANG_KEYS, server }; diff --git a/tests/scripts/dashboard-web.test.js b/tests/scripts/dashboard-web.test.js new file mode 100644 index 00000000..8118a06c --- /dev/null +++ b/tests/scripts/dashboard-web.test.js @@ -0,0 +1,785 @@ +/** + * Tests for scripts/dashboard-web.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const http = require('http'); + +const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'dashboard-web.js'); + +let testRoot; +let testPassed = 0; +let testFailed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + testPassed++; + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + testFailed++; + return false; + } +} + +function createTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function cleanup(dirPath) { + fs.rmSync(dirPath, { recursive: true, force: true }); +} + +function writeFile(rootDir, relativePath, content) { + const targetPath = path.join(rootDir, relativePath); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, content); +} + +// ===================== parsePort ===================== + +test('parsePort returns 3456 for undefined', () => { + const { parsePort } = require(SCRIPT); + assert.strictEqual(parsePort(undefined), 3456); +}); + +test('parsePort returns numeric port for valid string', () => { + const { parsePort } = require(SCRIPT); + assert.strictEqual(parsePort('8080'), 8080); + assert.strictEqual(parsePort('3456'), 3456); +}); + +test('parsePort returns numeric port for numeric input', () => { + const { parsePort } = require(SCRIPT); + assert.strictEqual(parsePort(8080), 8080); +}); + +test('parsePort returns 3456 for port below 1', () => { + const { parsePort } = require(SCRIPT); + assert.strictEqual(parsePort('-1'), 3456); + assert.strictEqual(parsePort('0'), 3456); +}); + +test('parsePort returns 3456 for port above 65535', () => { + const { parsePort } = require(SCRIPT); + assert.strictEqual(parsePort('70000'), 3456); + assert.strictEqual(parsePort('65536'), 3456); +}); + +test('parsePort accepts boundary ports 1 and 65535', () => { + const { parsePort } = require(SCRIPT); + assert.strictEqual(parsePort('1'), 1); + assert.strictEqual(parsePort('65535'), 65535); +}); + +test('parsePort returns 3456 for non-numeric string', () => { + const { parsePort } = require(SCRIPT); + assert.strictEqual(parsePort('abc'), 3456); + assert.strictEqual(parsePort(''), 3456); +}); + +// ===================== readFrontmatter ===================== + +test('readFrontmatter parses simple frontmatter', () => { + const { readFrontmatter } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, 'test.md', [ + '---', + 'name: test-agent', + 'description: A test agent description', + 'model: claude-sonnet-4-6', + '---', + '# Body content', + 'This is the body.', + ].join('\n')); + + const fm = readFrontmatter(path.join(testRoot, 'test.md')); + assert.strictEqual(fm.name, 'test-agent'); + assert.strictEqual(fm.description, 'A test agent description'); + assert.strictEqual(fm.model, 'claude-sonnet-4-6'); + assert.ok(fm._body.includes('# Body content')); + cleanup(testRoot); +}); + +test('readFrontmatter parses array tools field', () => { + const { readFrontmatter } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, 'agent.md', [ + '---', + 'name: array-agent', + 'tools: [Bash, Read, Write]', + '---', + 'body', + ].join('\n')); + + const fm = readFrontmatter(path.join(testRoot, 'agent.md')); + assert.strictEqual(fm.name, 'array-agent'); + assert.ok(Array.isArray(fm.tools)); + assert.deepStrictEqual(fm.tools, ['Bash', 'Read', 'Write']); + cleanup(testRoot); +}); + +test('readFrontmatter handles quoted values', () => { + const { readFrontmatter } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, 'test.md', [ + '---', + 'name: "quoted-name"', + "description: 'single-quoted-desc'", + '---', + 'body', + ].join('\n')); + + const fm = readFrontmatter(path.join(testRoot, 'test.md')); + assert.strictEqual(fm.name, 'quoted-name'); + assert.strictEqual(fm.description, 'single-quoted-desc'); + cleanup(testRoot); +}); + +test('readFrontmatter returns empty object for file without frontmatter', () => { + const { readFrontmatter } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, 'no-fm.md', '# Just a heading\nNo frontmatter here.'); + + const fm = readFrontmatter(path.join(testRoot, 'no-fm.md')); + assert.deepStrictEqual(fm, {}); + cleanup(testRoot); +}); + +test('readFrontmatter returns empty object for missing file', () => { + const { readFrontmatter } = require(SCRIPT); + const fm = readFrontmatter('/nonexistent/path/test.md'); + assert.deepStrictEqual(fm, {}); +}); + +// ===================== readSkill ===================== + +test('readSkill parses skill frontmatter and body', () => { + const { readSkill } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, 'SKILL.md', [ + '---', + 'name: test-skill', + 'description: A test skill', + '---', + '# Skill Workflow', + 'Step 1: Do this.', + ].join('\n')); + + const skill = readSkill(path.join(testRoot, 'SKILL.md')); + assert.strictEqual(skill.d, 'A test skill'); + assert.ok(skill.b.includes('# Skill Workflow')); + assert.ok(!skill.b.includes('---')); // frontmatter stripped from body + cleanup(testRoot); +}); + +test('readSkill returns empty defaults for missing file', () => { + const { readSkill } = require(SCRIPT); + const skill = readSkill('/nonexistent/skill/SKILL.md'); + assert.strictEqual(skill.d, ''); + assert.strictEqual(skill.b, ''); +}); + +// ===================== loadAgents ===================== + +test('loadAgents returns empty array for missing directory', () => { + const { loadAgents } = require(SCRIPT); + const agents = loadAgents('/nonexistent/path'); + assert.deepStrictEqual(agents, []); +}); + +test('loadAgents loads agent markdown files', () => { + const { loadAgents } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, 'agents/typescript-reviewer.md', [ + '---', + 'name: typescript-reviewer', + 'description: Reviews TypeScript code', + 'model: claude-sonnet-4-6', + 'tools: [Bash, Read, Write, Grep]', + '---', + '# TypeScript Reviewer', + 'You are a TypeScript code reviewer.', + ].join('\n')); + writeFile(testRoot, 'agents/python-reviewer.md', [ + '---', + 'name: python-reviewer', + 'description: Reviews Python code', + 'model: claude-opus-4-8', + 'tools: [Bash, Read]', + '---', + '# Python Reviewer', + ].join('\n')); + + const agents = loadAgents(testRoot); + assert.strictEqual(agents.length, 2); + assert.strictEqual(agents[0].n, 'python-reviewer'); // alphabetical sort + assert.strictEqual(agents[1].n, 'typescript-reviewer'); + assert.strictEqual(agents[1].m, 'claude-sonnet-4-6'); + assert.strictEqual(agents[1].d, 'Reviews TypeScript code'); + assert.deepStrictEqual(agents[1].t, ['Bash', 'Read', 'Write', 'Grep']); + assert.ok(agents[1].b.length > 0); + cleanup(testRoot); +}); + +test('loadAgents defaults missing fields', () => { + const { loadAgents } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, 'agents/minimal.md', [ + '# Minimal Agent', + 'No frontmatter at all.', + ].join('\n')); + + const agents = loadAgents(testRoot); + assert.strictEqual(agents.length, 1); + assert.strictEqual(agents[0].n, 'minimal'); + assert.strictEqual(agents[0].m, 'default'); + assert.strictEqual(agents[0].d, ''); + assert.deepStrictEqual(agents[0].t, []); + cleanup(testRoot); +}); + +test('loadAgents ignores non-markdown files', () => { + const { loadAgents } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, 'agents/agent.md', '---\nname: real-agent\n---\nbody'); + writeFile(testRoot, 'agents/README.txt', 'not an agent'); + + const agents = loadAgents(testRoot); + assert.strictEqual(agents.length, 1); + assert.strictEqual(agents[0].n, 'real-agent'); + cleanup(testRoot); +}); + +// ===================== loadSkills ===================== + +test('loadSkills returns empty array for missing directory', () => { + const { loadSkills } = require(SCRIPT); + const skills = loadSkills('/nonexistent/path'); + assert.deepStrictEqual(skills, []); +}); + +test('loadSkills loads skill directories with SKILL.md', () => { + const { loadSkills } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, 'skills/seo-audit/SKILL.md', [ + '---', + 'name: seo-audit', + 'description: Full website SEO audit', + '---', + '# SEO Audit Workflow', + ].join('\n')); + writeFile(testRoot, 'skills/code-review/SKILL.md', [ + '---', + 'name: code-review', + 'description: Review code changes', + '---', + '# Code Review Workflow', + ].join('\n')); + + const skills = loadSkills(testRoot); + assert.strictEqual(skills.length, 2); + assert.strictEqual(skills[0].n, 'code-review'); // alphabetical sort + assert.strictEqual(skills[1].n, 'seo-audit'); + assert.strictEqual(skills[1].d, 'Full website SEO audit'); + assert.ok(skills[1].b.length > 0); + cleanup(testRoot); +}); + +test('loadSkills ignores non-directories', () => { + const { loadSkills } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, 'skills/README.md', 'no skill here'); + writeFile(testRoot, 'skills/real-skill/SKILL.md', '---\ndescription: A real skill\n---\nbody'); + + const skills = loadSkills(testRoot); + assert.strictEqual(skills.length, 1); + assert.strictEqual(skills[0].n, 'real-skill'); + cleanup(testRoot); +}); + +// ===================== loadCommands ===================== + +test('loadCommands returns empty array for missing directory', () => { + const { loadCommands } = require(SCRIPT); + const commands = loadCommands('/nonexistent/path'); + assert.deepStrictEqual(commands, []); +}); + +test('loadCommands loads command markdown files with category detection', () => { + const { loadCommands } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, 'commands/pr.md', [ + '---', + 'description: Create a pull request', + '---', + 'body', + ].join('\n')); + writeFile(testRoot, 'commands/go-test.md', [ + '---', + 'description: Run Go tests', + '---', + 'body', + ].join('\n')); + writeFile(testRoot, 'commands/unknown-cmd.md', [ + '---', + 'description: Some unknown command', + '---', + 'body', + ].join('\n')); + + const commands = loadCommands(testRoot); + assert.strictEqual(commands.length, 3); + + const prCmd = commands.find(c => c.n === '/pr'); + assert.ok(prCmd); + assert.strictEqual(prCmd.c, 'Git & PR'); + assert.strictEqual(prCmd.d, 'Create a pull request'); + + const goCmd = commands.find(c => c.n === '/go-test'); + assert.ok(goCmd); + assert.strictEqual(goCmd.c, 'Languages'); + + const unknownCmd = commands.find(c => c.n === '/unknown-cmd'); + assert.ok(unknownCmd); + assert.strictEqual(unknownCmd.c, 'Other'); + cleanup(testRoot); +}); + +// ===================== loadRules ===================== + +test('loadRules returns empty array for missing directory', () => { + const { loadRules } = require(SCRIPT); + const rules = loadRules('/nonexistent/path'); + assert.deepStrictEqual(rules, []); +}); + +test('loadRules loads language directories with rule files', () => { + const { loadRules } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, 'rules/python/coding-style.md', ''); + writeFile(testRoot, 'rules/python/testing.md', ''); + writeFile(testRoot, 'rules/python/patterns.md', ''); + writeFile(testRoot, 'rules/typescript/coding-style.md', ''); + writeFile(testRoot, 'rules/typescript/testing.md', ''); + + const rules = loadRules(testRoot); + assert.strictEqual(rules.length, 2); + + const pyRules = rules.find(r => r.l === 'python'); + assert.ok(pyRules); + assert.strictEqual(pyRules.f.length, 3); + assert.ok(pyRules.f.includes('coding-style')); + assert.ok(pyRules.f.includes('testing')); + assert.ok(pyRules.f.includes('patterns')); + + const tsRules = rules.find(r => r.l === 'typescript'); + assert.ok(tsRules); + assert.strictEqual(tsRules.f.length, 2); + cleanup(testRoot); +}); + +test('loadRules ignores non-directories in rules folder', () => { + const { loadRules } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, 'rules/README.md', 'no rules here'); + writeFile(testRoot, 'rules/go/testing.md', ''); + + const rules = loadRules(testRoot); + assert.strictEqual(rules.length, 1); + assert.strictEqual(rules[0].l, 'go'); + assert.strictEqual(rules[0].f.length, 1); + assert.strictEqual(rules[0].f[0], 'testing'); + cleanup(testRoot); +}); + +// ===================== loadMcps ===================== + +test('loadMcps returns empty array when no configs exist', () => { + const { loadMcps } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + + const mcps = loadMcps(testRoot); + assert.deepStrictEqual(mcps, []); + cleanup(testRoot); +}); + +test('loadMcps loads .mcp.json config', () => { + const { loadMcps } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, '.mcp.json', JSON.stringify({ + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js'], + env: { SECRET: 'real-secret' }, + type: 'stdio', + }, + }, + })); + + const mcps = loadMcps(testRoot); + assert.strictEqual(mcps.length, 1); + assert.strictEqual(mcps[0].f, '.mcp.json'); + assert.strictEqual(mcps[0].s.length, 1); + assert.strictEqual(mcps[0].s[0].n, 'test-server'); + assert.strictEqual(mcps[0].s[0].cmd, 'node'); + assert.deepStrictEqual(mcps[0].s[0].args, ['server.js']); + assert.strictEqual(mcps[0].s[0].type, 'stdio'); + // Env vars should be masked + assert.strictEqual(mcps[0].s[0].env.SECRET, '••••••'); + cleanup(testRoot); +}); + +test('loadMcps loads mcp-configs/ directory files', () => { + const { loadMcps } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, 'mcp-configs/brave.json', JSON.stringify({ + mcpServers: { + 'brave-search': { + command: 'npx', + args: ['@anthropic/mcp-brave'], + type: 'stdio', + }, + }, + })); + + const mcps = loadMcps(testRoot); + assert.strictEqual(mcps.length, 1); + assert.strictEqual(mcps[0].f, 'brave.json'); + assert.strictEqual(mcps[0].s.length, 1); + assert.strictEqual(mcps[0].s[0].n, 'brave-search'); + cleanup(testRoot); +}); + +test('loadMcps masks environment variables', () => { + const { loadMcps } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, 'mcp-configs/with-env.json', JSON.stringify({ + mcpServers: { + server: { + command: 'python', + env: { API_KEY: 'super-secret-key', DEBUG: 'true' }, + }, + }, + })); + + const mcps = loadMcps(testRoot); + assert.strictEqual(mcps[0].s[0].env.API_KEY, '••••••'); + assert.strictEqual(mcps[0].s[0].env.DEBUG, '••••••'); + cleanup(testRoot); +}); + +test('loadMcps handles url-based MCP servers', () => { + const { loadMcps } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, '.mcp.json', JSON.stringify({ + mcpServers: { + 'remote-server': { + url: 'https://example.com/mcp', + type: 'sse', + }, + }, + })); + + const mcps = loadMcps(testRoot); + assert.strictEqual(mcps[0].s[0].cmd, 'https://example.com/mcp'); + assert.strictEqual(mcps[0].s[0].type, 'sse'); + cleanup(testRoot); +}); + +test('loadMcps handles malformed JSON gracefully', () => { + const { loadMcps } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, '.mcp.json', '{not valid json}'); + + const mcps = loadMcps(testRoot); + assert.deepStrictEqual(mcps, []); // returns empty array on parse error + cleanup(testRoot); +}); + +// ===================== loadHooks ===================== + +test('loadHooks returns empty array when hooks.json missing', () => { + const { loadHooks } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + + const hooks = loadHooks(testRoot); + assert.deepStrictEqual(hooks, []); + cleanup(testRoot); +}); + +test('loadHooks loads hook definitions', () => { + const { loadHooks } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, 'hooks/hooks.json', JSON.stringify({ + hooks: { + 'post-commit': [ + { matcher: '*.js', id: 'lint-js', description: 'Lint JS files after commit' }, + { matcher: '*.py', id: 'lint-py', description: 'Lint Python files' }, + ], + 'pre-push': [ + { matcher: '*', id: 'run-tests', description: 'Run all tests before push' }, + ], + }, + })); + + const hooks = loadHooks(testRoot); + assert.strictEqual(hooks.length, 3); + const lintJs = hooks.find(h => h.id === 'lint-js'); + assert.ok(lintJs); + assert.strictEqual(lintJs.ev, 'post-commit'); + assert.strictEqual(lintJs.m, '*.js'); + assert.strictEqual(lintJs.d, 'Lint JS files after commit'); + cleanup(testRoot); +}); + +test('loadHooks handles malformed JSON gracefully', () => { + const { loadHooks } = require(SCRIPT); + testRoot = createTempDir('ecc-test-'); + writeFile(testRoot, 'hooks/hooks.json', '{invalid}'); + + const hooks = loadHooks(testRoot); + assert.deepStrictEqual(hooks, []); + cleanup(testRoot); +}); + +// ===================== LANG ===================== + +test('LANG object has all expected language keys', () => { + const { LANG, LANG_KEYS } = require(SCRIPT); + assert.ok(LANG_KEYS.length >= 10); // at least 10 languages + // Verify key languages are present + assert.ok(LANG.en); + assert.ok(LANG.pt); + assert.ok(LANG.zh); + assert.ok(LANG.de); + assert.strictEqual(LANG_KEYS.length, Object.keys(LANG).length); +}); + +test('LANG English has all required keys', () => { + const { LANG } = require(SCRIPT); + const en = LANG.en; + assert.ok(en.title); + assert.ok(en.search); + assert.ok(en.agents); + assert.ok(en.skills); + assert.ok(en.commands); + assert.ok(en.rules); + assert.ok(en.mcps); + assert.ok(en.hooks); + assert.ok(en.all); + assert.ok(en.description); + assert.ok(en.tools); + assert.ok(en.copied); +}); + +// ===================== renderHTML ===================== + +test('renderHTML returns valid HTML string', () => { + const { renderHTML } = require(SCRIPT); + const data = { + agents: [], + skills: [], + commands: [], + rules: [], + mcps: [], + hooks: [], + }; + const html = renderHTML(data); + assert.ok(typeof html === 'string'); + assert.ok(html.startsWith('')); + assert.ok(html.includes('')); +}); + +test('renderHTML includes agent data as JSON', () => { + const { renderHTML } = require(SCRIPT); + const data = { + agents: [{ n: 'test-agent', d: 'Test desc', m: 'claude-sonnet-4-6', t: ['Bash'], b: 'body', f: 'test.md' }], + skills: [], + commands: [], + rules: [], + mcps: [], + hooks: [], + }; + const html = renderHTML(data); + assert.ok(html.includes('test-agent')); + assert.ok(html.includes('claude-sonnet-4-6')); +}); + +test('renderHTML escapes HTML in data values', () => { + const { renderHTML } = require(SCRIPT); + const data = { + agents: [], + skills: [{ n: '', d: 'Skill & description', b: '' }], + commands: [], + rules: [], + mcps: [], + hooks: [], + }; + const html = renderHTML(data); + // The JSON serialization with .replace(/ not