Files
EJClaw/src/runtime-inventory.ts
Eyejoker 1c5be2094d Fix runtime inventory MCP JSON parsing (#131)
* 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

* Fix runtime inventory MCP JSON parsing
2026-05-04 02:38:21 +09:00

257 lines
6.2 KiB
TypeScript

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;
}
interface McpConfigSummary {
ejclawConfigured: boolean;
serverCount: number;
}
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 isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function summarizeTomlMcpConfig(content: string): McpConfigSummary {
const serverMatches = content.match(/^\s*\[mcp_servers\.[^\]]+\]/gm) ?? [];
return {
ejclawConfigured: /^\s*\[mcp_servers\.ejclaw(?:\.[^\]]+)?\]/m.test(content),
serverCount: serverMatches.length,
};
}
function summarizeJsonMcpConfig(content: string): McpConfigSummary | null {
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
return null;
}
if (!isRecord(parsed)) return null;
const servers = parsed.mcpServers ?? parsed.mcp_servers;
if (!isRecord(servers)) {
return {
ejclawConfigured: false,
serverCount: 0,
};
}
const names = Object.keys(servers);
return {
ejclawConfigured: names.includes('ejclaw'),
serverCount: names.length,
};
}
function readMcpSnapshot(
label: string,
configPath: string,
): RuntimeMcpSnapshot {
let content = '';
if (fs.existsSync(configPath)) {
try {
content = fs.readFileSync(configPath, 'utf-8');
} catch {
content = '';
}
}
const summary =
path.extname(configPath) === '.json'
? (summarizeJsonMcpConfig(content) ?? summarizeTomlMcpConfig(content))
: summarizeTomlMcpConfig(content);
return {
configPath: pathSnapshot(label, configPath),
...summary,
};
}
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),
},
};
}