From 2da6052effb56680d2c39fc336b758a609692843 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 4 May 2026 02:13:42 +0900 Subject: [PATCH] Add read-only runtime inventory settings (#130) * add gated codex goals support * sync README SDK versions * bump claude agent sdk * add codex goals settings toggle * reuse status dashboard message and simplify settings actions * refine settings page UX * fix settings nav hash routing * add dashboard UX verification * refine dashboard settings and inbox UX * remove inbox top-level navigation * remove dashboard health top-level navigation * fix: allow runtime image attachment paths * feat: configure attachment allowlist dirs * fix: clean duplicate dashboard status messages * fix: poll dashboard duplicate cleanup between status updates * fix: delete dashboard duplicates on create * Refine settings IA with tabbed sections * Add read-only runtime inventory settings --- .../src/RuntimeInventorySettings.css | 130 +++++++++++ .../src/RuntimeInventorySettings.tsx | 174 ++++++++++++++ apps/dashboard/src/SettingsPanel.test.ts | 5 +- apps/dashboard/src/SettingsPanel.tsx | 5 + apps/dashboard/src/SettingsPanelChrome.tsx | 5 + apps/dashboard/src/api.ts | 50 +++++ scripts/dashboard-ux.ts | 108 +++++++++ src/runtime-inventory.test.ts | 91 ++++++++ src/runtime-inventory.ts | 212 ++++++++++++++++++ src/web-dashboard-settings-routes.test.ts | 92 ++++++++ src/web-dashboard-settings-routes.ts | 19 ++ 11 files changed, 890 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/src/RuntimeInventorySettings.css create mode 100644 apps/dashboard/src/RuntimeInventorySettings.tsx create mode 100644 src/runtime-inventory.test.ts create mode 100644 src/runtime-inventory.ts diff --git a/apps/dashboard/src/RuntimeInventorySettings.css b/apps/dashboard/src/RuntimeInventorySettings.css new file mode 100644 index 0000000..a35d15e --- /dev/null +++ b/apps/dashboard/src/RuntimeInventorySettings.css @@ -0,0 +1,130 @@ +.runtime-inventory { + display: grid; + gap: 18px; +} + +.runtime-summary-card, +.runtime-agent-card, +.runtime-skill-card { + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 20px; + background: rgba(15, 23, 42, 0.48); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.runtime-summary-card { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + padding: 18px; +} + +.runtime-summary-card > div { + display: grid; + gap: 6px; + min-width: 0; +} + +.runtime-summary-card strong { + color: var(--text); + font-size: 1rem; +} + +.runtime-summary-card code, +.runtime-path-row code, +.runtime-skill-card code { + color: var(--muted); + font-size: 0.78rem; + overflow-wrap: anywhere; +} + +.runtime-agent-card { + padding: 18px; +} + +.runtime-card-head, +.runtime-skill-card header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.runtime-card-head h4 { + margin: 0; +} + +.runtime-path-list, +.runtime-skill-list { + display: grid; + gap: 10px; + margin: 14px 0 0; + padding: 0; + list-style: none; +} + +.runtime-path-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + border-radius: 14px; + background: rgba(15, 23, 42, 0.38); + padding: 12px; +} + +.runtime-path-row > span { + display: grid; + gap: 4px; + min-width: 0; +} + +.runtime-skill-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; +} + +.runtime-skill-card { + padding: 14px; +} + +.runtime-skill-card header > div { + display: grid; + gap: 4px; + min-width: 0; +} + +.runtime-skill-list li { + display: grid; + gap: 3px; + border-top: 1px solid rgba(148, 163, 184, 0.12); + padding-top: 9px; +} + +.runtime-skill-list li:first-child { + border-top: 0; + padding-top: 0; +} + +.runtime-skill-list span { + color: var(--muted); + font-size: 0.82rem; + line-height: 1.4; +} + +@media (max-width: 980px) { + .runtime-summary-card, + .runtime-skill-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 720px) { + .runtime-card-head, + .runtime-skill-card header, + .runtime-path-row { + flex-direction: column; + } +} diff --git a/apps/dashboard/src/RuntimeInventorySettings.tsx b/apps/dashboard/src/RuntimeInventorySettings.tsx new file mode 100644 index 0000000..3b4f51b --- /dev/null +++ b/apps/dashboard/src/RuntimeInventorySettings.tsx @@ -0,0 +1,174 @@ +import { useEffect, useState } from 'react'; + +import { + fetchRuntimeInventory, + type RuntimeAgentInventory, + type RuntimeInventorySnapshot, + type RuntimePathSnapshot, + type RuntimeSkillDirSnapshot, +} from './api'; +import { SettingsSectionHeading } from './SettingsPanelChrome'; +import './RuntimeInventorySettings.css'; + +function ExistsBadge({ exists }: { exists: boolean }) { + return ( + + {exists ? '감지됨' : '없음'} + + ); +} + +function PathRow({ item }: { item: RuntimePathSnapshot }) { + return ( +
  • + + {item.label} + {item.path} + + +
  • + ); +} + +function SkillDirCard({ dir }: { dir: RuntimeSkillDirSnapshot }) { + const preview = dir.skills.slice(0, 6); + return ( +
    +
    +
    + {dir.label} + {dir.path} +
    + + {dir.count} skills + +
    + {preview.length === 0 ? ( +

    표시할 SKILL.md 없음

    + ) : ( + + )} + {dir.count > preview.length ? ( + + 외 {dir.count - preview.length}개 더 있음 + + ) : null} +
    + ); +} + +function AgentInventoryCard({ + title, + inventory, +}: { + title: string; + inventory: RuntimeAgentInventory; +}) { + return ( +
    +
    +

    {title}

    + + MCP {inventory.mcp.ejclawConfigured ? '연결' : '미감지'} + +
    + +

    + MCP servers {inventory.mcp.serverCount}개 · EJClaw section{' '} + {inventory.mcp.ejclawConfigured ? '있음' : '없음'} +

    +
    + {inventory.skillDirs.map((dir) => ( + + ))} +
    +
    + ); +} + +export function RuntimeInventorySettings() { + const [snapshot, setSnapshot] = useState( + null, + ); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + fetchRuntimeInventory() + .then((value) => { + if (cancelled) return; + setSnapshot(value); + setError(null); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + }); + return () => { + cancelled = true; + }; + }, []); + + return ( +
    + + {error ?

    {error}

    : null} + {!snapshot ? ( +

    런타임 정보를 불러오는 중…

    + ) : ( +
    +
    +
    + Current service + {snapshot.service.id} + + {snapshot.service.agentType} · session{' '} + {snapshot.service.sessionScope} + +
    +
    + Project + {snapshot.projectRoot} + data {snapshot.dataDir} +
    +
    + + + + +
    +
    +

    EJClaw bridge

    + +
    +
      + + +
    +
    +
    + )} +
    + ); +} diff --git a/apps/dashboard/src/SettingsPanel.test.ts b/apps/dashboard/src/SettingsPanel.test.ts index b6d934e..de8af66 100644 --- a/apps/dashboard/src/SettingsPanel.test.ts +++ b/apps/dashboard/src/SettingsPanel.test.ts @@ -31,21 +31,24 @@ describe('SettingsPanel', () => { expect(html).toContain('English'); }); - it('renders model, MoA, fast mode, and account controls', () => { + it('renders model, runtime, MoA, fast mode, and account controls', () => { const html = renderToStaticMarkup(createElement(SettingsPanel, baseProps)); expect(html).toContain('role="tablist"'); expect(html).toContain('role="tabpanel"'); expect(html).toContain('aria-selected="true"'); expect(html).toContain('data-settings-target="settings-models"'); + expect(html).toContain('data-settings-target="settings-runtime"'); expect(html).toContain('data-settings-target="settings-moa"'); expect(html).toContain('data-settings-target="settings-codex"'); expect(html).toContain('data-settings-target="settings-accounts"'); + expect(html).toContain('aria-controls="settings-runtime"'); expect(html).toContain('aria-controls="settings-codex"'); expect(html).not.toContain('href="#settings-codex"'); expect(html).toContain('settings-apply-card'); expect(html).not.toContain('settings-apply-bar'); expect(html).toContain('저장 후 재시작'); + expect(html).toContain('런타임'); expect(html).toContain('Claude'); expect(html).toContain('계정'); expect(html).toContain('스택 재시작'); diff --git a/apps/dashboard/src/SettingsPanel.tsx b/apps/dashboard/src/SettingsPanel.tsx index 5727c3b..0f41c53 100644 --- a/apps/dashboard/src/SettingsPanel.tsx +++ b/apps/dashboard/src/SettingsPanel.tsx @@ -22,6 +22,7 @@ import { } from './api'; import { type Locale, type Messages } from './i18n'; import { MoaSettingsPanel } from './MoaSettingsPanel'; +import { RuntimeInventorySettings } from './RuntimeInventorySettings'; import { GeneralSettings, SettingsApplyCard, @@ -83,6 +84,10 @@ export function SettingsPanel({ + + diff --git a/apps/dashboard/src/SettingsPanelChrome.tsx b/apps/dashboard/src/SettingsPanelChrome.tsx index d092c3d..1bca50a 100644 --- a/apps/dashboard/src/SettingsPanelChrome.tsx +++ b/apps/dashboard/src/SettingsPanelChrome.tsx @@ -7,6 +7,11 @@ export const SETTINGS_NAV_ITEMS = [ title: '모델', detail: 'owner · reviewer · arbiter', }, + { + targetId: 'settings-runtime', + title: '런타임', + detail: 'skills · MCP · config', + }, { targetId: 'settings-moa', title: 'MoA', detail: '참조 모델 · 연결 테스트' }, { targetId: 'settings-codex', title: 'Codex', detail: 'fast mode · /goal' }, { targetId: 'settings-accounts', title: '계정', detail: 'Claude · Codex' }, diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index baf96e6..d7e5776 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -392,6 +392,52 @@ export interface CodexFeatureSnapshot { goals: boolean; } +export interface RuntimePathSnapshot { + label: string; + path: string; + exists: boolean; +} + +export interface RuntimeSkillSummary { + name: string; + description: string | null; + path: string; +} + +export interface RuntimeSkillDirSnapshot extends RuntimePathSnapshot { + count: number; + skills: RuntimeSkillSummary[]; +} + +export interface RuntimeMcpSnapshot { + configPath: RuntimePathSnapshot; + ejclawConfigured: boolean; + serverCount: number; +} + +export interface RuntimeAgentInventory { + configFiles: RuntimePathSnapshot[]; + skillDirs: RuntimeSkillDirSnapshot[]; + mcp: RuntimeMcpSnapshot; +} + +export interface RuntimeInventorySnapshot { + generatedAt: string; + projectRoot: string; + dataDir: string; + service: { + id: string; + sessionScope: string; + agentType: string; + }; + codex: RuntimeAgentInventory; + claude: RuntimeAgentInventory; + ejclaw: { + runnerSkillDir: RuntimeSkillDirSnapshot; + mcpServer: RuntimePathSnapshot; + }; +} + export interface MoaReferenceStatus { model: string; checkedAt: string; @@ -546,6 +592,10 @@ export async function fetchCodexFeatures(): Promise { return fetchJson('/api/settings/codex-features'); } +export async function fetchRuntimeInventory(): Promise { + return fetchJson('/api/settings/runtime-inventory'); +} + export async function updateCodexFeatures( input: Partial, ): Promise { diff --git a/scripts/dashboard-ux.ts b/scripts/dashboard-ux.ts index cb81f7f..e14ad6e 100644 --- a/scripts/dashboard-ux.ts +++ b/scripts/dashboard-ux.ts @@ -47,6 +47,12 @@ async function main() { await assertVisible(page.locator('.settings-panel')); await assertVisible(page.locator('#settings-codex')); + await openSettingsSection(page, 'settings-runtime'); + assert.equal(page.url(), originalUrl); + await assertVisible(page.locator('#settings-runtime')); + await page.getByText('Runtime inventory').waitFor(); + await page.getByText('EJClaw bridge').waitFor(); + await page .locator( '.settings-nav button[data-settings-target="settings-accounts"]', @@ -308,6 +314,11 @@ async function handleMockApi(route: Route, state: MockApiState) { return; } + if (method === 'GET' && url.pathname === '/api/settings/runtime-inventory') { + await fulfillJson(route, runtimeInventoryFixture()); + return; + } + if (url.pathname === '/api/settings/codex-features') { if (method === 'GET') { await fulfillJson(route, state.codexFeatures); @@ -475,6 +486,103 @@ function mockStatusSnapshot() { }; } +function runtimeSkill(name: string, description: string, skillPath: string) { + return { name, description, path: skillPath }; +} + +function runtimeInventoryFixture() { + const codexConfig = { + label: 'Codex config.toml', + path: '/home/.codex/config.toml', + exists: true, + }; + const claudeSettings = { + label: 'Claude settings.json', + path: '/home/.claude/settings.json', + exists: true, + }; + + return { + generatedAt: '2026-05-04T00:00:00.000Z', + projectRoot: '/repo', + dataDir: '/repo/data', + service: { + id: 'codex-main', + sessionScope: 'codex-main', + agentType: 'codex', + }, + codex: { + configFiles: [ + codexConfig, + { + label: 'Codex auth.json', + path: '/home/.codex/auth.json', + exists: true, + }, + ], + skillDirs: [ + { + label: 'Codex user skills', + path: '/home/.agents/skills', + exists: true, + count: 1, + skills: [ + runtimeSkill( + 'agent-browser', + 'Browser automation', + '/home/.agents/skills/agent-browser', + ), + ], + }, + ], + mcp: { configPath: codexConfig, ejclawConfigured: true, serverCount: 1 }, + }, + claude: { + configFiles: [claudeSettings], + skillDirs: [ + { + label: 'Claude user skills', + path: '/home/.claude/skills', + exists: true, + count: 1, + skills: [ + runtimeSkill( + 'review-helper', + 'Review workflow', + '/home/.claude/skills/review-helper', + ), + ], + }, + ], + mcp: { + configPath: claudeSettings, + ejclawConfigured: false, + serverCount: 0, + }, + }, + ejclaw: { + runnerSkillDir: { + label: 'EJClaw runner skills', + path: '/repo/runners/skills', + exists: true, + count: 1, + skills: [ + runtimeSkill( + 'agent-browser', + 'Browser automation', + '/repo/runners/skills/agent-browser', + ), + ], + }, + mcpServer: { + label: 'EJClaw IPC MCP server', + path: '/repo/runners/agent-runner/dist/ipc-mcp-stdio.js', + exists: true, + }, + }, + }; +} + function parseJsonBody(body: string | null): Record { if (!body) return {}; const parsed = JSON.parse(body) as unknown; diff --git a/src/runtime-inventory.test.ts b/src/runtime-inventory.test.ts new file mode 100644 index 0000000..e214aa1 --- /dev/null +++ b/src/runtime-inventory.test.ts @@ -0,0 +1,91 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { getRuntimeInventory } from './runtime-inventory.js'; + +describe('runtime inventory', () => { + let tempDir: string; + let homeDir: string; + let projectRoot: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-runtime-inv-')); + homeDir = path.join(tempDir, 'home'); + projectRoot = path.join(tempDir, 'repo'); + fs.mkdirSync(homeDir, { recursive: true }); + fs.mkdirSync(projectRoot, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('summarizes safe Codex and Claude runtime metadata without reading secrets', () => { + const codexDir = path.join(homeDir, '.codex'); + const claudeDir = path.join(homeDir, '.claude'); + fs.mkdirSync(codexDir, { recursive: true }); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync( + path.join(codexDir, 'config.toml'), + '[mcp_servers.ejclaw]\ncommand = "node"\n[mcp_servers.other]\ncommand = "x"\n', + ); + fs.writeFileSync( + path.join(claudeDir, 'settings.json'), + '{"fastMode":true,"secret":"should-not-leak"}\n', + ); + fs.writeFileSync( + path.join(codexDir, 'auth.json'), + '{"OPENAI_API_KEY":"sk-secret"}\n', + ); + + const codexSkill = path.join(homeDir, '.agents', 'skills', 'browser'); + fs.mkdirSync(codexSkill, { recursive: true }); + fs.writeFileSync( + path.join(codexSkill, 'SKILL.md'), + '---\nname: browser\ndescription: Browser automation\n---\n# Body\n', + ); + + const runnerSkill = path.join(projectRoot, 'runners', 'skills', 'runner'); + fs.mkdirSync(runnerSkill, { recursive: true }); + fs.writeFileSync( + path.join(runnerSkill, 'SKILL.md'), + '---\nname: runner\ndescription: Runner skill\n---\n', + ); + const mcpServerPath = path.join( + projectRoot, + 'runners', + 'agent-runner', + 'dist', + 'ipc-mcp-stdio.js', + ); + fs.mkdirSync(path.dirname(mcpServerPath), { recursive: true }); + fs.writeFileSync(mcpServerPath, 'console.log("mcp");\n'); + + const snapshot = getRuntimeInventory({ + generatedAt: '2026-05-04T00:00:00.000Z', + homeDir, + projectRoot, + }); + + expect(snapshot.projectRoot).toBe(projectRoot); + expect(snapshot.codex.mcp).toMatchObject({ + ejclawConfigured: true, + serverCount: 2, + }); + expect(snapshot.codex.skillDirs[0]).toMatchObject({ + count: 1, + skills: [{ name: 'browser', description: 'Browser automation' }], + }); + expect(snapshot.ejclaw.runnerSkillDir).toMatchObject({ + count: 1, + skills: [{ name: 'runner', description: 'Runner skill' }], + }); + + const serialized = JSON.stringify(snapshot); + expect(serialized).not.toContain('sk-secret'); + expect(serialized).not.toContain('should-not-leak'); + }); +}); diff --git a/src/runtime-inventory.ts b/src/runtime-inventory.ts new file mode 100644 index 0000000..f6d83c1 --- /dev/null +++ b/src/runtime-inventory.ts @@ -0,0 +1,212 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + CURRENT_RUNTIME_AGENT_TYPE, + DATA_DIR, + SERVICE_ID, + SERVICE_SESSION_SCOPE, +} from './config.js'; + +export interface RuntimePathSnapshot { + label: string; + path: string; + exists: boolean; +} + +export interface RuntimeSkillSummary { + name: string; + description: string | null; + path: string; +} + +export interface RuntimeSkillDirSnapshot extends RuntimePathSnapshot { + count: number; + skills: RuntimeSkillSummary[]; +} + +export interface RuntimeMcpSnapshot { + configPath: RuntimePathSnapshot; + ejclawConfigured: boolean; + serverCount: number; +} + +export interface RuntimeAgentInventory { + configFiles: RuntimePathSnapshot[]; + skillDirs: RuntimeSkillDirSnapshot[]; + mcp: RuntimeMcpSnapshot; +} + +export interface RuntimeInventorySnapshot { + generatedAt: string; + projectRoot: string; + dataDir: string; + service: { + id: string; + sessionScope: string; + agentType: string; + }; + codex: RuntimeAgentInventory; + claude: RuntimeAgentInventory; + ejclaw: { + runnerSkillDir: RuntimeSkillDirSnapshot; + mcpServer: RuntimePathSnapshot; + }; +} + +interface RuntimeInventoryOptions { + homeDir?: string; + projectRoot?: string; + generatedAt?: string; +} + +function pathSnapshot(label: string, filePath: string): RuntimePathSnapshot { + return { + label, + path: filePath, + exists: fs.existsSync(filePath), + }; +} + +function parseSkillMetadata(skillMdPath: string): { + name: string | null; + description: string | null; +} { + let content: string; + try { + content = fs.readFileSync(skillMdPath, 'utf-8').slice(0, 8000); + } catch { + return { name: null, description: null }; + } + + const name = content.match(/^name:\s*(.+?)\s*$/m)?.[1]?.trim() ?? null; + const description = + content.match(/^description:\s*(.+?)\s*$/m)?.[1]?.trim() ?? null; + return { name, description }; +} + +function readSkillDir(label: string, dirPath: string): RuntimeSkillDirSnapshot { + if (!fs.existsSync(dirPath)) { + return { + ...pathSnapshot(label, dirPath), + count: 0, + skills: [], + }; + } + + const skills = fs + .readdirSync(dirPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const skillDir = path.join(dirPath, entry.name); + const skillMdPath = path.join(skillDir, 'SKILL.md'); + if (!fs.existsSync(skillMdPath)) return null; + const metadata = parseSkillMetadata(skillMdPath); + return { + name: metadata.name || entry.name, + description: metadata.description, + path: skillDir, + }; + }) + .filter((skill): skill is RuntimeSkillSummary => Boolean(skill)) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { + ...pathSnapshot(label, dirPath), + count: skills.length, + skills, + }; +} + +function readMcpSnapshot( + label: string, + configPath: string, +): RuntimeMcpSnapshot { + let content = ''; + if (fs.existsSync(configPath)) { + try { + content = fs.readFileSync(configPath, 'utf-8'); + } catch { + content = ''; + } + } + + const serverMatches = content.match(/^\s*\[mcp_servers\.[^\]]+\]/gm) ?? []; + return { + configPath: pathSnapshot(label, configPath), + ejclawConfigured: /^\s*\[mcp_servers\.ejclaw(?:\.[^\]]+)?\]/m.test(content), + serverCount: serverMatches.length, + }; +} + +export function getRuntimeInventory( + options: RuntimeInventoryOptions = {}, +): RuntimeInventorySnapshot { + const homeDir = options.homeDir ?? os.homedir(); + const projectRoot = options.projectRoot ?? process.cwd(); + const codexConfigPath = path.join(homeDir, '.codex', 'config.toml'); + const claudeSettingsPath = path.join(homeDir, '.claude', 'settings.json'); + const runnerSkillDir = path.join(projectRoot, 'runners', 'skills'); + const mcpServerPath = path.join( + projectRoot, + 'runners', + 'agent-runner', + 'dist', + 'ipc-mcp-stdio.js', + ); + + const ejclawRunnerSkills = readSkillDir( + 'EJClaw runner skills', + runnerSkillDir, + ); + + return { + generatedAt: options.generatedAt ?? new Date().toISOString(), + projectRoot, + dataDir: DATA_DIR, + service: { + id: SERVICE_ID, + sessionScope: SERVICE_SESSION_SCOPE, + agentType: CURRENT_RUNTIME_AGENT_TYPE, + }, + codex: { + configFiles: [ + pathSnapshot('Codex config.toml', codexConfigPath), + pathSnapshot( + 'Codex auth.json', + path.join(homeDir, '.codex', 'auth.json'), + ), + ], + skillDirs: [ + readSkillDir( + 'Codex user skills', + path.join(homeDir, '.agents', 'skills'), + ), + ejclawRunnerSkills, + ], + mcp: readMcpSnapshot('Codex config.toml', codexConfigPath), + }, + claude: { + configFiles: [ + pathSnapshot('Claude settings.json', claudeSettingsPath), + pathSnapshot( + 'Claude credentials', + path.join(homeDir, '.claude', '.credentials.json'), + ), + ], + skillDirs: [ + readSkillDir( + 'Claude user skills', + path.join(homeDir, '.claude', 'skills'), + ), + ejclawRunnerSkills, + ], + mcp: readMcpSnapshot('Claude settings.json', claudeSettingsPath), + }, + ejclaw: { + runnerSkillDir: ejclawRunnerSkills, + mcpServer: pathSnapshot('EJClaw IPC MCP server', mcpServerPath), + }, + }; +} diff --git a/src/web-dashboard-settings-routes.test.ts b/src/web-dashboard-settings-routes.test.ts index 84ee2af..ebdaeaa 100644 --- a/src/web-dashboard-settings-routes.test.ts +++ b/src/web-dashboard-settings-routes.test.ts @@ -11,6 +11,7 @@ import type { FastModeSnapshot, ModelConfigSnapshot, } from './settings-store.js'; +import type { RuntimeInventorySnapshot } from './runtime-inventory.js'; import type { MoaSettingsSnapshot } from './settings-store-moa.js'; function jsonResponse(value: unknown, init?: ResponseInit): Response { @@ -44,6 +45,88 @@ const modelConfig: ModelConfigSnapshot = { const fastMode: FastModeSnapshot = { codex: true, claude: false }; const codexFeatures: CodexFeatureSnapshot = { goals: false }; +const runtimeInventory: RuntimeInventorySnapshot = { + generatedAt: '2026-05-04T00:00:00.000Z', + projectRoot: '/repo', + dataDir: '/repo/data', + service: { + id: 'codex-main', + sessionScope: 'codex-main', + agentType: 'codex', + }, + codex: { + configFiles: [ + { + label: 'Codex config.toml', + path: '/home/.codex/config.toml', + exists: true, + }, + ], + skillDirs: [ + { + label: 'Codex user skills', + path: '/home/.agents/skills', + exists: true, + count: 1, + skills: [ + { + name: 'agent-browser', + description: 'Browser automation', + path: '/home/.agents/skills/agent-browser', + }, + ], + }, + ], + mcp: { + configPath: { + label: 'Codex config.toml', + path: '/home/.codex/config.toml', + exists: true, + }, + ejclawConfigured: true, + serverCount: 1, + }, + }, + claude: { + configFiles: [ + { + label: 'Claude settings.json', + path: '/home/.claude/settings.json', + exists: true, + }, + ], + skillDirs: [], + mcp: { + configPath: { + label: 'Claude settings.json', + path: '/home/.claude/settings.json', + exists: true, + }, + ejclawConfigured: false, + serverCount: 0, + }, + }, + ejclaw: { + runnerSkillDir: { + label: 'EJClaw runner skills', + path: '/repo/runners/skills', + exists: true, + count: 1, + skills: [ + { + name: 'agent-browser', + description: 'Browser automation', + path: '/repo/runners/skills/agent-browser', + }, + ], + }, + mcpServer: { + label: 'EJClaw IPC MCP server', + path: '/repo/runners/agent-runner/dist/ipc-mcp-stdio.js', + exists: true, + }, + }, +}; const moaSettings: MoaSettingsSnapshot = { enabled: true, @@ -113,6 +196,7 @@ function makeDeps( getFastMode: () => fastMode, getModelConfig: () => modelConfig, getMoaSettings: () => moaSettings, + getRuntimeInventory: () => runtimeInventory, listClaudeAccounts: () => [makeClaudeAccount()], listCodexAccounts: () => [makeCodexAccount()], refreshAllCodexAccounts: async () => ({ refreshed: [1], failed: [] }), @@ -159,6 +243,14 @@ describe('web dashboard settings routes', () => { expect(mode?.status).toBe(200); await expect(mode?.json()).resolves.toEqual(fastMode); + const inventory = await route('/api/settings/runtime-inventory'); + expect(inventory?.status).toBe(200); + await expect(inventory?.json()).resolves.toMatchObject({ + service: { id: 'codex-main', agentType: 'codex' }, + codex: { mcp: { ejclawConfigured: true } }, + ejclaw: { runnerSkillDir: { count: 1 } }, + }); + const features = await route('/api/settings/codex-features'); expect(features?.status).toBe(200); await expect(features?.json()).resolves.toEqual(codexFeatures); diff --git a/src/web-dashboard-settings-routes.ts b/src/web-dashboard-settings-routes.ts index fcec7fe..a4ca32e 100644 --- a/src/web-dashboard-settings-routes.ts +++ b/src/web-dashboard-settings-routes.ts @@ -19,6 +19,10 @@ import { type FastModeSnapshot, type ModelConfigSnapshot, } from './settings-store.js'; +import { + getRuntimeInventory, + type RuntimeInventorySnapshot, +} from './runtime-inventory.js'; import { checkMoaModel, getMoaSettings, @@ -40,6 +44,7 @@ export interface SettingsRouteDependencies { getFastMode: typeof getFastMode; getModelConfig: typeof getModelConfig; getMoaSettings: typeof getMoaSettings; + getRuntimeInventory: typeof getRuntimeInventory; listClaudeAccounts: typeof listClaudeAccounts; listCodexAccounts: typeof listCodexAccounts; refreshAllCodexAccounts: typeof refreshAllCodexAccounts; @@ -67,6 +72,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = { getFastMode, getModelConfig, getMoaSettings, + getRuntimeInventory, listClaudeAccounts, listCodexAccounts, refreshAllCodexAccounts, @@ -207,6 +213,15 @@ function handleAccountsRoute( }); } +function handleRuntimeInventoryRoute( + request: Request, + jsonResponse: JsonResponse, + deps: SettingsRouteDependencies, +): Response | null { + if (!readMethod(request.method)) return null; + return jsonResponse(deps.getRuntimeInventory()); +} + async function handleClaudeAccountAddRoute( request: Request, jsonResponse: JsonResponse, @@ -320,6 +335,9 @@ export async function handleSettingsRoute({ if (url.pathname === '/api/settings/models') { return handleModelSettingsRoute(request, jsonResponse, deps); } + if (url.pathname === '/api/settings/runtime-inventory') { + return handleRuntimeInventoryRoute(request, jsonResponse, deps); + } if (url.pathname === '/api/settings/fast-mode') { return handleFastModeRoute(request, jsonResponse, deps); } @@ -366,4 +384,5 @@ export type { FastModeSnapshot, ModelConfigSnapshot, MoaSettingsSnapshot, + RuntimeInventorySnapshot, };