Add comprehensive tests for tokenizer, performance benchmarks, and TreeSitter parser functionality

- Implemented unit tests for the Tokenizer class, covering various text inputs, edge cases, and fallback mechanisms.
- Created performance benchmarks comparing tiktoken and pure Python implementations for token counting.
- Developed extensive tests for TreeSitterSymbolParser across Python, JavaScript, and TypeScript, ensuring accurate symbol extraction and parsing.
- Added configuration documentation for MCP integration and custom prompts, enhancing usability and flexibility.
- Introduced a refactor script for GraphAnalyzer to streamline future improvements.
This commit is contained in:
catlog22
2025-12-15 14:36:09 +08:00
parent 82dcafff00
commit 0fe16963cd
49 changed files with 9307 additions and 438 deletions

View File

@@ -0,0 +1,183 @@
/**
* Centralized Storage Paths Configuration
* Single source of truth for all CCW storage locations
*
* All data is stored under ~/.ccw/ with project isolation via SHA256 hash
*/
import { homedir } from 'os';
import { join, resolve } from 'path';
import { createHash } from 'crypto';
import { existsSync, mkdirSync } from 'fs';
// Environment variable override for custom storage location
const CCW_DATA_DIR = process.env.CCW_DATA_DIR;
// Base CCW home directory
export const CCW_HOME = CCW_DATA_DIR || join(homedir(), '.ccw');
/**
* Calculate project identifier from project path
* Uses SHA256 hash truncated to 16 chars for uniqueness + readability
* @param projectPath - Absolute or relative project path
* @returns 16-character hex string project ID
*/
export function getProjectId(projectPath: string): string {
const absolutePath = resolve(projectPath);
const hash = createHash('sha256').update(absolutePath).digest('hex');
return hash.substring(0, 16);
}
/**
* Ensure a directory exists, creating it if necessary
* @param dirPath - Directory path to ensure
*/
export function ensureStorageDir(dirPath: string): void {
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true });
}
}
/**
* Global storage paths (not project-specific)
*/
export const GlobalPaths = {
/** Root CCW home directory */
root: () => CCW_HOME,
/** Config directory */
config: () => join(CCW_HOME, 'config'),
/** Global settings file */
settings: () => join(CCW_HOME, 'config', 'settings.json'),
/** Recent project paths file */
recentPaths: () => join(CCW_HOME, 'config', 'recent-paths.json'),
/** Databases directory */
databases: () => join(CCW_HOME, 'db'),
/** MCP templates database */
mcpTemplates: () => join(CCW_HOME, 'db', 'mcp-templates.db'),
/** Logs directory */
logs: () => join(CCW_HOME, 'logs'),
};
/**
* Project-specific storage paths
*/
export interface ProjectPaths {
/** Project root in CCW storage */
root: string;
/** CLI history directory */
cliHistory: string;
/** CLI history database file */
historyDb: string;
/** Memory store directory */
memory: string;
/** Memory store database file */
memoryDb: string;
/** Cache directory */
cache: string;
/** Dashboard cache file */
dashboardCache: string;
/** Config directory */
config: string;
/** CLI config file */
cliConfig: string;
}
/**
* Get storage paths for a specific project
* @param projectPath - Project root path
* @returns Object with all project-specific paths
*/
export function getProjectPaths(projectPath: string): ProjectPaths {
const projectId = getProjectId(projectPath);
const projectDir = join(CCW_HOME, 'projects', projectId);
return {
root: projectDir,
cliHistory: join(projectDir, 'cli-history'),
historyDb: join(projectDir, 'cli-history', 'history.db'),
memory: join(projectDir, 'memory'),
memoryDb: join(projectDir, 'memory', 'memory.db'),
cache: join(projectDir, 'cache'),
dashboardCache: join(projectDir, 'cache', 'dashboard-data.json'),
config: join(projectDir, 'config'),
cliConfig: join(projectDir, 'config', 'cli-config.json'),
};
}
/**
* Unified StoragePaths object combining global and project paths
*/
export const StoragePaths = {
global: GlobalPaths,
project: getProjectPaths,
};
/**
* Legacy storage paths (for backward compatibility detection)
*/
export const LegacyPaths = {
/** Old recent paths file location */
recentPaths: () => join(homedir(), '.ccw-recent-paths.json'),
/** Old project-local CLI history */
cliHistory: (projectPath: string) => join(projectPath, '.workflow', '.cli-history'),
/** Old project-local memory store */
memory: (projectPath: string) => join(projectPath, '.workflow', '.memory'),
/** Old project-local cache */
cache: (projectPath: string) => join(projectPath, '.workflow', '.ccw-cache'),
/** Old project-local CLI config */
cliConfig: (projectPath: string) => join(projectPath, '.workflow', 'cli-config.json'),
};
/**
* Check if legacy storage exists for a project
* Useful for migration warnings or detection
* @param projectPath - Project root path
* @returns true if any legacy storage is present
*/
export function isLegacyStoragePresent(projectPath: string): boolean {
return (
existsSync(LegacyPaths.cliHistory(projectPath)) ||
existsSync(LegacyPaths.memory(projectPath)) ||
existsSync(LegacyPaths.cache(projectPath)) ||
existsSync(LegacyPaths.cliConfig(projectPath))
);
}
/**
* Get CCW home directory (for external use)
*/
export function getCcwHome(): string {
return CCW_HOME;
}
/**
* Initialize global storage directories
* Creates the base directory structure if not present
*/
export function initializeGlobalStorage(): void {
ensureStorageDir(GlobalPaths.config());
ensureStorageDir(GlobalPaths.databases());
ensureStorageDir(GlobalPaths.logs());
}
/**
* Initialize project storage directories
* @param projectPath - Project root path
*/
export function initializeProjectStorage(projectPath: string): void {
const paths = getProjectPaths(projectPath);
ensureStorageDir(paths.cliHistory);
ensureStorageDir(paths.memory);
ensureStorageDir(paths.cache);
ensureStorageDir(paths.config);
}

View File

@@ -1,5 +1,6 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from 'fs';
import { join, dirname } from 'path';
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
interface CacheEntry<T> {
data: T;
@@ -265,6 +266,16 @@ export class CacheManager<T> {
}
}
/**
* Extract project path from workflow directory
* @param workflowDir - Path to .workflow directory (e.g., /project/.workflow)
* @returns Project root path
*/
function extractProjectPath(workflowDir: string): string {
// workflowDir is typically {projectPath}/.workflow
return workflowDir.replace(/[\/\\]\.workflow$/, '') || workflowDir;
}
/**
* Create a cache manager for dashboard data
* @param workflowDir - Path to .workflow directory
@@ -272,6 +283,9 @@ export class CacheManager<T> {
* @returns CacheManager instance
*/
export function createDashboardCache(workflowDir: string, ttl?: number): CacheManager<any> {
const cacheDir = join(workflowDir, '.ccw-cache');
// Use centralized storage path
const projectPath = extractProjectPath(workflowDir);
const cacheDir = StoragePaths.project(projectPath).cache;
ensureStorageDir(cacheDir);
return new CacheManager('dashboard-data', { cacheDir, ttl });
}

View File

@@ -6,6 +6,7 @@
import Database from 'better-sqlite3';
import { existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
// Types
export interface Entity {
@@ -115,12 +116,12 @@ export class MemoryStore {
private dbPath: string;
constructor(projectPath: string) {
const memoryDir = join(projectPath, '.workflow', '.memory');
if (!existsSync(memoryDir)) {
mkdirSync(memoryDir, { recursive: true });
}
// Use centralized storage path
const paths = StoragePaths.project(projectPath);
const memoryDir = paths.memory;
ensureStorageDir(memoryDir);
this.dbPath = join(memoryDir, 'memory.db');
this.dbPath = paths.memoryDb;
this.db = new Database(this.dbPath);
this.db.pragma('journal_mode = WAL');
this.db.pragma('synchronous = NORMAL');

View File

@@ -12,9 +12,383 @@ import * as McpTemplatesDb from './mcp-templates-db.js';
// Claude config file path
const CLAUDE_CONFIG_PATH = join(homedir(), '.claude.json');
// Codex config file path (TOML format)
const CODEX_CONFIG_PATH = join(homedir(), '.codex', 'config.toml');
// Workspace root path for scanning .mcp.json files
let WORKSPACE_ROOT = process.cwd();
// ========================================
// TOML Parser for Codex Config
// ========================================
/**
* Simple TOML parser for Codex config.toml
* Supports basic types: strings, numbers, booleans, arrays, inline tables
*/
function parseToml(content: string): Record<string, any> {
const result: Record<string, any> = {};
let currentSection: string[] = [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
// Skip empty lines and comments
if (!line || line.startsWith('#')) continue;
// Handle section headers [section] or [section.subsection]
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
currentSection = sectionMatch[1].split('.');
// Ensure nested sections exist
let obj = result;
for (const part of currentSection) {
if (!obj[part]) obj[part] = {};
obj = obj[part];
}
continue;
}
// Handle key = value pairs
const keyValueMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$/);
if (keyValueMatch) {
const key = keyValueMatch[1];
const rawValue = keyValueMatch[2].trim();
const value = parseTomlValue(rawValue);
// Navigate to current section
let obj = result;
for (const part of currentSection) {
if (!obj[part]) obj[part] = {};
obj = obj[part];
}
obj[key] = value;
}
}
return result;
}
/**
* Parse a TOML value
*/
function parseTomlValue(value: string): any {
// String (double-quoted)
if (value.startsWith('"') && value.endsWith('"')) {
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
// String (single-quoted - literal)
if (value.startsWith("'") && value.endsWith("'")) {
return value.slice(1, -1);
}
// Boolean
if (value === 'true') return true;
if (value === 'false') return false;
// Number
if (/^-?\d+(\.\d+)?$/.test(value)) {
return value.includes('.') ? parseFloat(value) : parseInt(value, 10);
}
// Array
if (value.startsWith('[') && value.endsWith(']')) {
const inner = value.slice(1, -1).trim();
if (!inner) return [];
// Simple array parsing (handles basic cases)
const items: any[] = [];
let depth = 0;
let current = '';
let inString = false;
let stringChar = '';
for (const char of inner) {
if (!inString && (char === '"' || char === "'")) {
inString = true;
stringChar = char;
current += char;
} else if (inString && char === stringChar) {
inString = false;
current += char;
} else if (!inString && (char === '[' || char === '{')) {
depth++;
current += char;
} else if (!inString && (char === ']' || char === '}')) {
depth--;
current += char;
} else if (!inString && char === ',' && depth === 0) {
items.push(parseTomlValue(current.trim()));
current = '';
} else {
current += char;
}
}
if (current.trim()) {
items.push(parseTomlValue(current.trim()));
}
return items;
}
// Inline table { key = value, ... }
if (value.startsWith('{') && value.endsWith('}')) {
const inner = value.slice(1, -1).trim();
if (!inner) return {};
const table: Record<string, any> = {};
// Simple inline table parsing
const pairs = inner.split(',');
for (const pair of pairs) {
const match = pair.trim().match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$/);
if (match) {
table[match[1]] = parseTomlValue(match[2].trim());
}
}
return table;
}
// Return as string if nothing else matches
return value;
}
/**
* Serialize object to TOML format for Codex config
*/
function serializeToml(obj: Record<string, any>, prefix: string = ''): string {
let result = '';
const sections: string[] = [];
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) continue;
if (typeof value === 'object' && !Array.isArray(value)) {
// Handle nested sections (like mcp_servers.server_name)
const sectionKey = prefix ? `${prefix}.${key}` : key;
sections.push(sectionKey);
// Check if this is a section with sub-sections or direct values
const hasSubSections = Object.values(value).some(v => typeof v === 'object' && !Array.isArray(v));
if (hasSubSections) {
// This section has sub-sections, recurse without header
result += serializeToml(value, sectionKey);
} else {
// This section has direct values, add header and values
result += `\n[${sectionKey}]\n`;
for (const [subKey, subValue] of Object.entries(value)) {
if (subValue !== null && subValue !== undefined) {
result += `${subKey} = ${serializeTomlValue(subValue)}\n`;
}
}
}
} else if (!prefix) {
// Top-level simple values
result += `${key} = ${serializeTomlValue(value)}\n`;
}
}
return result;
}
/**
* Serialize a value to TOML format
*/
function serializeTomlValue(value: any): string {
if (typeof value === 'string') {
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
if (typeof value === 'number') {
return String(value);
}
if (Array.isArray(value)) {
return `[${value.map(v => serializeTomlValue(v)).join(', ')}]`;
}
if (typeof value === 'object' && value !== null) {
const pairs = Object.entries(value)
.filter(([_, v]) => v !== null && v !== undefined)
.map(([k, v]) => `${k} = ${serializeTomlValue(v)}`);
return `{ ${pairs.join(', ')} }`;
}
return String(value);
}
// ========================================
// Codex MCP Functions
// ========================================
/**
* Read Codex config.toml and extract MCP servers
*/
function getCodexMcpConfig(): { servers: Record<string, any>; configPath: string; exists: boolean } {
try {
if (!existsSync(CODEX_CONFIG_PATH)) {
return { servers: {}, configPath: CODEX_CONFIG_PATH, exists: false };
}
const content = readFileSync(CODEX_CONFIG_PATH, 'utf8');
const config = parseToml(content);
// MCP servers are under [mcp_servers] section
const mcpServers = config.mcp_servers || {};
return {
servers: mcpServers,
configPath: CODEX_CONFIG_PATH,
exists: true
};
} catch (error: unknown) {
console.error('Error reading Codex config:', error);
return { servers: {}, configPath: CODEX_CONFIG_PATH, exists: false };
}
}
/**
* Add or update MCP server in Codex config.toml
*/
function addCodexMcpServer(serverName: string, serverConfig: Record<string, any>): { success?: boolean; error?: string } {
try {
const codexDir = join(homedir(), '.codex');
// Ensure .codex directory exists
if (!existsSync(codexDir)) {
mkdirSync(codexDir, { recursive: true });
}
let config: Record<string, any> = {};
// Read existing config if it exists
if (existsSync(CODEX_CONFIG_PATH)) {
const content = readFileSync(CODEX_CONFIG_PATH, 'utf8');
config = parseToml(content);
}
// Ensure mcp_servers section exists
if (!config.mcp_servers) {
config.mcp_servers = {};
}
// Convert serverConfig from Claude format to Codex format
const codexServerConfig: Record<string, any> = {};
// Handle STDIO servers (command-based)
if (serverConfig.command) {
codexServerConfig.command = serverConfig.command;
if (serverConfig.args && serverConfig.args.length > 0) {
codexServerConfig.args = serverConfig.args;
}
if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
codexServerConfig.env = serverConfig.env;
}
if (serverConfig.cwd) {
codexServerConfig.cwd = serverConfig.cwd;
}
}
// Handle HTTP servers (url-based)
if (serverConfig.url) {
codexServerConfig.url = serverConfig.url;
if (serverConfig.bearer_token_env_var) {
codexServerConfig.bearer_token_env_var = serverConfig.bearer_token_env_var;
}
if (serverConfig.http_headers) {
codexServerConfig.http_headers = serverConfig.http_headers;
}
}
// Copy optional fields
if (serverConfig.startup_timeout_sec !== undefined) {
codexServerConfig.startup_timeout_sec = serverConfig.startup_timeout_sec;
}
if (serverConfig.tool_timeout_sec !== undefined) {
codexServerConfig.tool_timeout_sec = serverConfig.tool_timeout_sec;
}
if (serverConfig.enabled !== undefined) {
codexServerConfig.enabled = serverConfig.enabled;
}
if (serverConfig.enabled_tools) {
codexServerConfig.enabled_tools = serverConfig.enabled_tools;
}
if (serverConfig.disabled_tools) {
codexServerConfig.disabled_tools = serverConfig.disabled_tools;
}
// Add the server
config.mcp_servers[serverName] = codexServerConfig;
// Serialize and write back
const tomlContent = serializeToml(config);
writeFileSync(CODEX_CONFIG_PATH, tomlContent, 'utf8');
return { success: true };
} catch (error: unknown) {
console.error('Error adding Codex MCP server:', error);
return { error: (error as Error).message };
}
}
/**
* Remove MCP server from Codex config.toml
*/
function removeCodexMcpServer(serverName: string): { success?: boolean; error?: string } {
try {
if (!existsSync(CODEX_CONFIG_PATH)) {
return { error: 'Codex config.toml not found' };
}
const content = readFileSync(CODEX_CONFIG_PATH, 'utf8');
const config = parseToml(content);
if (!config.mcp_servers || !config.mcp_servers[serverName]) {
return { error: `Server not found: ${serverName}` };
}
// Remove the server
delete config.mcp_servers[serverName];
// Serialize and write back
const tomlContent = serializeToml(config);
writeFileSync(CODEX_CONFIG_PATH, tomlContent, 'utf8');
return { success: true };
} catch (error: unknown) {
console.error('Error removing Codex MCP server:', error);
return { error: (error as Error).message };
}
}
/**
* Toggle Codex MCP server enabled state
*/
function toggleCodexMcpServer(serverName: string, enabled: boolean): { success?: boolean; error?: string } {
try {
if (!existsSync(CODEX_CONFIG_PATH)) {
return { error: 'Codex config.toml not found' };
}
const content = readFileSync(CODEX_CONFIG_PATH, 'utf8');
const config = parseToml(content);
if (!config.mcp_servers || !config.mcp_servers[serverName]) {
return { error: `Server not found: ${serverName}` };
}
// Set enabled state
config.mcp_servers[serverName].enabled = enabled;
// Serialize and write back
const tomlContent = serializeToml(config);
writeFileSync(CODEX_CONFIG_PATH, tomlContent, 'utf8');
return { success: true };
} catch (error: unknown) {
console.error('Error toggling Codex MCP server:', error);
return { error: (error as Error).message };
}
}
export interface RouteContext {
pathname: string;
url: URL;
@@ -598,11 +972,64 @@ function getProjectSettingsPath(projectPath) {
export async function handleMcpRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
// API: Get MCP configuration
// API: Get MCP configuration (includes both Claude and Codex)
if (pathname === '/api/mcp-config') {
const mcpData = getMcpConfig();
const codexData = getCodexMcpConfig();
const combinedData = {
...mcpData,
codex: codexData
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(mcpData));
res.end(JSON.stringify(combinedData));
return true;
}
// ========================================
// Codex MCP API Endpoints
// ========================================
// API: Get Codex MCP configuration
if (pathname === '/api/codex-mcp-config') {
const codexData = getCodexMcpConfig();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(codexData));
return true;
}
// API: Add Codex MCP server
if (pathname === '/api/codex-mcp-add' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { serverName, serverConfig } = body;
if (!serverName || !serverConfig) {
return { error: 'serverName and serverConfig are required', status: 400 };
}
return addCodexMcpServer(serverName, serverConfig);
});
return true;
}
// API: Remove Codex MCP server
if (pathname === '/api/codex-mcp-remove' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { serverName } = body;
if (!serverName) {
return { error: 'serverName is required', status: 400 };
}
return removeCodexMcpServer(serverName);
});
return true;
}
// API: Toggle Codex MCP server enabled state
if (pathname === '/api/codex-mcp-toggle' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { serverName, enabled } = body;
if (!serverName || enabled === undefined) {
return { error: 'serverName and enabled are required', status: 400 };
}
return toggleCodexMcpServer(serverName, enabled);
});
return true;
}

View File

@@ -7,15 +7,14 @@ import Database from 'better-sqlite3';
import { existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { StoragePaths, ensureStorageDir } from '../../config/storage-paths.js';
// Database path
const DB_DIR = join(homedir(), '.ccw');
const DB_PATH = join(DB_DIR, 'mcp-templates.db');
// Database path - uses centralized storage
const DB_DIR = StoragePaths.global.databases();
const DB_PATH = StoragePaths.global.mcpTemplates();
// Ensure database directory exists
if (!existsSync(DB_DIR)) {
mkdirSync(DB_DIR, { recursive: true });
}
ensureStorageDir(DB_DIR);
// Initialize database connection
let db: Database.Database | null = null;

View File

@@ -99,9 +99,10 @@ async function getSessionDetailData(sessionPath, dataType) {
}
}
// Load explorations (exploration-*.json files) - check .process/ first, then session root
// Load explorations (exploration-*.json files) and diagnoses (diagnosis-*.json files) - check .process/ first, then session root
if (dataType === 'context' || dataType === 'explorations' || dataType === 'all') {
result.explorations = { manifest: null, data: {} };
result.diagnoses = { manifest: null, data: {} };
// Try .process/ first (standard workflow sessions), then session root (lite tasks)
const searchDirs = [
@@ -134,15 +135,41 @@ async function getSessionDetailData(sessionPath, dataType) {
} catch (e) {
result.explorations.manifest = null;
}
} else {
// Fallback: scan for exploration-*.json files directly
}
// Look for diagnoses-manifest.json
const diagManifestFile = join(searchDir, 'diagnoses-manifest.json');
if (existsSync(diagManifestFile)) {
try {
const files = readdirSync(searchDir).filter(f => f.startsWith('exploration-') && f.endsWith('.json'));
if (files.length > 0) {
result.diagnoses.manifest = JSON.parse(readFileSync(diagManifestFile, 'utf8'));
// Load each diagnosis file based on manifest
const diagnoses = result.diagnoses.manifest.diagnoses || [];
for (const diag of diagnoses) {
const diagFile = join(searchDir, diag.file);
if (existsSync(diagFile)) {
try {
result.diagnoses.data[diag.angle] = JSON.parse(readFileSync(diagFile, 'utf8'));
} catch (e) {
// Skip unreadable diagnosis files
}
}
}
break; // Found manifest, stop searching
} catch (e) {
result.diagnoses.manifest = null;
}
}
// Fallback: scan for exploration-*.json and diagnosis-*.json files directly
if (!result.explorations.manifest) {
try {
const expFiles = readdirSync(searchDir).filter(f => f.startsWith('exploration-') && f.endsWith('.json') && f !== 'explorations-manifest.json');
if (expFiles.length > 0) {
// Create synthetic manifest
result.explorations.manifest = {
exploration_count: files.length,
explorations: files.map((f, i) => ({
exploration_count: expFiles.length,
explorations: expFiles.map((f, i) => ({
angle: f.replace('exploration-', '').replace('.json', ''),
file: f,
index: i + 1
@@ -150,7 +177,7 @@ async function getSessionDetailData(sessionPath, dataType) {
};
// Load each file
for (const file of files) {
for (const file of expFiles) {
const angle = file.replace('exploration-', '').replace('.json', '');
try {
result.explorations.data[angle] = JSON.parse(readFileSync(join(searchDir, file), 'utf8'));
@@ -158,12 +185,46 @@ async function getSessionDetailData(sessionPath, dataType) {
// Skip unreadable files
}
}
break; // Found explorations, stop searching
}
} catch (e) {
// Directory read failed
}
}
// Fallback: scan for diagnosis-*.json files directly
if (!result.diagnoses.manifest) {
try {
const diagFiles = readdirSync(searchDir).filter(f => f.startsWith('diagnosis-') && f.endsWith('.json') && f !== 'diagnoses-manifest.json');
if (diagFiles.length > 0) {
// Create synthetic manifest
result.diagnoses.manifest = {
diagnosis_count: diagFiles.length,
diagnoses: diagFiles.map((f, i) => ({
angle: f.replace('diagnosis-', '').replace('.json', ''),
file: f,
index: i + 1
}))
};
// Load each file
for (const file of diagFiles) {
const angle = file.replace('diagnosis-', '').replace('.json', '');
try {
result.diagnoses.data[angle] = JSON.parse(readFileSync(join(searchDir, file), 'utf8'));
} catch (e) {
// Skip unreadable files
}
}
}
} catch (e) {
// Directory read failed
}
}
// If we found either explorations or diagnoses, break out of the loop
if (result.explorations.manifest || result.diagnoses.manifest) {
break;
}
}
}

View File

@@ -1022,6 +1022,16 @@
overflow: hidden;
}
.diagnosis-card .collapsible-content {
display: block;
padding: 1rem;
background: hsl(var(--card));
}
.diagnosis-card .collapsible-content.collapsed {
display: none;
}
.diagnosis-header {
background: hsl(var(--muted) / 0.3);
}

View File

@@ -15,6 +15,11 @@ let mcpCurrentProjectServers = {};
let mcpConfigSources = [];
let mcpCreateMode = 'form'; // 'form' or 'json'
// ========== CLI Toggle State (Claude / Codex) ==========
let currentCliMode = 'claude'; // 'claude' or 'codex'
let codexMcpConfig = null;
let codexMcpServers = {};
// ========== Initialization ==========
function initMcpManager() {
// Initialize MCP navigation
@@ -44,6 +49,12 @@ async function loadMcpConfig() {
mcpEnterpriseServers = data.enterpriseServers || {};
mcpConfigSources = data.configSources || [];
// Load Codex MCP config
if (data.codex) {
codexMcpConfig = data.codex;
codexMcpServers = data.codex.servers || {};
}
// Get current project servers
const currentPath = projectPath.replace(/\//g, '\\');
mcpCurrentProjectServers = mcpAllProjects[currentPath]?.mcpServers || {};
@@ -58,6 +69,135 @@ async function loadMcpConfig() {
}
}
// ========== CLI Mode Toggle ==========
function setCliMode(mode) {
currentCliMode = mode;
renderMcpManager();
}
function getCliMode() {
return currentCliMode;
}
// ========== Codex MCP Functions ==========
/**
* Add MCP server to Codex config.toml
*/
async function addCodexMcpServer(serverName, serverConfig) {
try {
const response = await fetch('/api/codex-mcp-add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverName: serverName,
serverConfig: serverConfig
})
});
if (!response.ok) throw new Error('Failed to add Codex MCP server');
const result = await response.json();
if (result.success) {
await loadMcpConfig();
renderMcpManager();
showRefreshToast(t('mcp.codex.serverAdded', { name: serverName }), 'success');
} else {
showRefreshToast(result.error || t('mcp.codex.addFailed'), 'error');
}
return result;
} catch (err) {
console.error('Failed to add Codex MCP server:', err);
showRefreshToast(t('mcp.codex.addFailed') + ': ' + err.message, 'error');
return null;
}
}
/**
* Remove MCP server from Codex config.toml
*/
async function removeCodexMcpServer(serverName) {
try {
const response = await fetch('/api/codex-mcp-remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverName })
});
if (!response.ok) throw new Error('Failed to remove Codex MCP server');
const result = await response.json();
if (result.success) {
await loadMcpConfig();
renderMcpManager();
showRefreshToast(t('mcp.codex.serverRemoved', { name: serverName }), 'success');
} else {
showRefreshToast(result.error || t('mcp.codex.removeFailed'), 'error');
}
return result;
} catch (err) {
console.error('Failed to remove Codex MCP server:', err);
showRefreshToast(t('mcp.codex.removeFailed') + ': ' + err.message, 'error');
return null;
}
}
/**
* Toggle Codex MCP server enabled state
*/
async function toggleCodexMcpServer(serverName, enabled) {
try {
const response = await fetch('/api/codex-mcp-toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverName, enabled })
});
if (!response.ok) throw new Error('Failed to toggle Codex MCP server');
const result = await response.json();
if (result.success) {
await loadMcpConfig();
renderMcpManager();
showRefreshToast(t('mcp.codex.serverToggled', { name: serverName, state: enabled ? 'enabled' : 'disabled' }), 'success');
}
return result;
} catch (err) {
console.error('Failed to toggle Codex MCP server:', err);
showRefreshToast(t('mcp.codex.toggleFailed') + ': ' + err.message, 'error');
return null;
}
}
/**
* Copy Claude MCP server to Codex
*/
async function copyClaudeServerToCodex(serverName, serverConfig) {
return await addCodexMcpServer(serverName, serverConfig);
}
/**
* Copy Codex MCP server to Claude (global)
*/
async function copyCodexServerToClaude(serverName, serverConfig) {
// Convert Codex format to Claude format
const claudeConfig = {
command: serverConfig.command,
args: serverConfig.args || [],
};
if (serverConfig.env) {
claudeConfig.env = serverConfig.env;
}
// If it's an HTTP server
if (serverConfig.url) {
claudeConfig.url = serverConfig.url;
}
return await addGlobalMcpServer(serverName, claudeConfig);
}
async function toggleMcpServer(serverName, enable) {
try {
const response = await fetch('/api/mcp-toggle', {
@@ -255,7 +395,7 @@ async function removeGlobalMcpServer(serverName) {
function updateMcpBadge() {
const badge = document.getElementById('badgeMcpServers');
if (badge) {
const currentPath = projectPath.replace(/\//g, '\\');
const currentPath = projectPath; // Keep original format (forward slash)
const projectData = mcpAllProjects[currentPath];
const servers = projectData?.mcpServers || {};
const disabledServers = projectData?.disabledMcpServers || [];
@@ -702,7 +842,20 @@ async function createMcpServerWithConfig(name, serverConfig, scope = 'project')
// Submit to API
try {
let response;
if (scope === 'global') {
let scopeLabel;
if (scope === 'codex') {
// Create in Codex config.toml
response = await fetch('/api/codex-mcp-add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverName: name,
serverConfig: serverConfig
})
});
scopeLabel = 'Codex';
} else if (scope === 'global') {
response = await fetch('/api/mcp-add-global-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -711,6 +864,7 @@ async function createMcpServerWithConfig(name, serverConfig, scope = 'project')
serverConfig: serverConfig
})
});
scopeLabel = 'global';
} else {
response = await fetch('/api/mcp-copy-server', {
method: 'POST',
@@ -721,6 +875,7 @@ async function createMcpServerWithConfig(name, serverConfig, scope = 'project')
serverConfig: serverConfig
})
});
scopeLabel = 'project';
}
if (!response.ok) throw new Error('Failed to create MCP server');
@@ -730,7 +885,6 @@ async function createMcpServerWithConfig(name, serverConfig, scope = 'project')
closeMcpCreateModal();
await loadMcpConfig();
renderMcpManager();
const scopeLabel = scope === 'global' ? 'global' : 'project';
showRefreshToast(`MCP server "${name}" created in ${scopeLabel} scope`, 'success');
} else {
showRefreshToast(result.error || 'Failed to create MCP server', 'error');
@@ -787,7 +941,7 @@ function buildCcwToolsConfig(selectedTools) {
return config;
}
async function installCcwToolsMcp() {
async function installCcwToolsMcp(scope = 'workspace') {
const selectedTools = getSelectedCcwTools();
if (selectedTools.length === 0) {
@@ -798,27 +952,52 @@ async function installCcwToolsMcp() {
const ccwToolsConfig = buildCcwToolsConfig(selectedTools);
try {
showRefreshToast('Installing CCW Tools MCP...', 'info');
const scopeLabel = scope === 'global' ? 'globally' : 'to workspace';
showRefreshToast(`Installing CCW Tools MCP ${scopeLabel}...`, 'info');
const response = await fetch('/api/mcp-copy-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
serverName: 'ccw-tools',
serverConfig: ccwToolsConfig
})
});
if (scope === 'global') {
// Install to global (~/.claude.json mcpServers)
const response = await fetch('/api/mcp-add-global', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverName: 'ccw-tools',
serverConfig: ccwToolsConfig
})
});
if (!response.ok) throw new Error('Failed to install CCW Tools MCP');
if (!response.ok) throw new Error('Failed to install CCW Tools MCP globally');
const result = await response.json();
if (result.success) {
await loadMcpConfig();
renderMcpManager();
showRefreshToast(`CCW Tools installed (${selectedTools.length} tools)`, 'success');
const result = await response.json();
if (result.success) {
await loadMcpConfig();
renderMcpManager();
showRefreshToast(`CCW Tools installed globally (${selectedTools.length} tools)`, 'success');
} else {
showRefreshToast(result.error || 'Failed to install CCW Tools MCP globally', 'error');
}
} else {
showRefreshToast(result.error || 'Failed to install CCW Tools MCP', 'error');
// Install to workspace (.mcp.json)
const response = await fetch('/api/mcp-copy-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
serverName: 'ccw-tools',
serverConfig: ccwToolsConfig
})
});
if (!response.ok) throw new Error('Failed to install CCW Tools MCP to workspace');
const result = await response.json();
if (result.success) {
await loadMcpConfig();
renderMcpManager();
showRefreshToast(`CCW Tools installed to workspace (${selectedTools.length} tools)`, 'success');
} else {
showRefreshToast(result.error || 'Failed to install CCW Tools MCP to workspace', 'error');
}
}
} catch (err) {
console.error('Failed to install CCW Tools MCP:', err);
@@ -826,7 +1005,7 @@ async function installCcwToolsMcp() {
}
}
async function updateCcwToolsMcp() {
async function updateCcwToolsMcp(scope = 'workspace') {
const selectedTools = getSelectedCcwTools();
if (selectedTools.length === 0) {
@@ -837,27 +1016,52 @@ async function updateCcwToolsMcp() {
const ccwToolsConfig = buildCcwToolsConfig(selectedTools);
try {
showRefreshToast('Updating CCW Tools MCP...', 'info');
const scopeLabel = scope === 'global' ? 'globally' : 'in workspace';
showRefreshToast(`Updating CCW Tools MCP ${scopeLabel}...`, 'info');
const response = await fetch('/api/mcp-copy-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
serverName: 'ccw-tools',
serverConfig: ccwToolsConfig
})
});
if (scope === 'global') {
// Update global (~/.claude.json mcpServers)
const response = await fetch('/api/mcp-add-global', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverName: 'ccw-tools',
serverConfig: ccwToolsConfig
})
});
if (!response.ok) throw new Error('Failed to update CCW Tools MCP');
if (!response.ok) throw new Error('Failed to update CCW Tools MCP globally');
const result = await response.json();
if (result.success) {
await loadMcpConfig();
renderMcpManager();
showRefreshToast(`CCW Tools updated (${selectedTools.length} tools)`, 'success');
const result = await response.json();
if (result.success) {
await loadMcpConfig();
renderMcpManager();
showRefreshToast(`CCW Tools updated globally (${selectedTools.length} tools)`, 'success');
} else {
showRefreshToast(result.error || 'Failed to update CCW Tools MCP globally', 'error');
}
} else {
showRefreshToast(result.error || 'Failed to update CCW Tools MCP', 'error');
// Update workspace (.mcp.json)
const response = await fetch('/api/mcp-copy-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
serverName: 'ccw-tools',
serverConfig: ccwToolsConfig
})
});
if (!response.ok) throw new Error('Failed to update CCW Tools MCP in workspace');
const result = await response.json();
if (result.success) {
await loadMcpConfig();
renderMcpManager();
showRefreshToast(`CCW Tools updated in workspace (${selectedTools.length} tools)`, 'success');
} else {
showRefreshToast(result.error || 'Failed to update CCW Tools MCP in workspace', 'error');
}
}
} catch (err) {
console.error('Failed to update CCW Tools MCP:', err);

View File

@@ -96,7 +96,7 @@ function renderImplPlanContent(implPlan) {
// Lite Context Tab Rendering
// ==========================================
function renderLiteContextContent(context, explorations, session) {
function renderLiteContextContent(context, explorations, session, diagnoses) {
const plan = session.plan || {};
let sections = [];
@@ -105,6 +105,11 @@ function renderLiteContextContent(context, explorations, session) {
sections.push(renderExplorationContext(explorations));
}
// Render diagnoses if available (from diagnosis-*.json files)
if (diagnoses && diagnoses.manifest) {
sections.push(renderDiagnosisContext(diagnoses));
}
// If we have context from context-package.json
if (context) {
sections.push(`
@@ -153,7 +158,7 @@ function renderLiteContextContent(context, explorations, session) {
<div class="tab-empty-state">
<div class="empty-icon"><i data-lucide="package" class="w-12 h-12"></i></div>
<div class="empty-title">No Context Data</div>
<div class="empty-text">No context-package.json or exploration files found for this session.</div>
<div class="empty-text">No context-package.json, exploration files, or diagnosis files found for this session.</div>
</div>
`;
}
@@ -185,15 +190,19 @@ function renderExplorationContext(explorations) {
`);
// Render each exploration angle as collapsible section
const explorationOrder = ['architecture', 'dependencies', 'patterns', 'integration-points'];
const explorationOrder = ['architecture', 'dependencies', 'patterns', 'integration-points', 'testing'];
const explorationTitles = {
'architecture': '<i data-lucide="blocks" class="w-4 h-4 inline mr-1"></i>Architecture',
'dependencies': '<i data-lucide="package" class="w-4 h-4 inline mr-1"></i>Dependencies',
'patterns': '<i data-lucide="git-branch" class="w-4 h-4 inline mr-1"></i>Patterns',
'integration-points': '<i data-lucide="plug" class="w-4 h-4 inline mr-1"></i>Integration Points'
'integration-points': '<i data-lucide="plug" class="w-4 h-4 inline mr-1"></i>Integration Points',
'testing': '<i data-lucide="flask-conical" class="w-4 h-4 inline mr-1"></i>Testing'
};
for (const angle of explorationOrder) {
// Collect all angles from data (in case there are exploration angles not in our predefined list)
const allAngles = [...new Set([...explorationOrder, ...Object.keys(data)])];
for (const angle of allAngles) {
const expData = data[angle];
if (!expData) {
continue;
@@ -205,7 +214,7 @@ function renderExplorationContext(explorations) {
<div class="exploration-section collapsible-section">
<div class="collapsible-header">
<span class="collapse-icon">▶</span>
<span class="section-label">${explorationTitles[angle] || angle}</span>
<span class="section-label">${explorationTitles[angle] || ('<i data-lucide="file-search" class="w-4 h-4 inline mr-1"></i>' + escapeHtml(angle.toUpperCase()))}</span>
</div>
<div class="collapsible-content collapsed">
${angleContent}
@@ -271,3 +280,145 @@ function renderExplorationAngle(angle, data) {
return content.join('') || '<p>No data available</p>';
}
// ==========================================
// Diagnosis Context Rendering
// ==========================================
function renderDiagnosisContext(diagnoses) {
if (!diagnoses || !diagnoses.manifest) {
return '';
}
const manifest = diagnoses.manifest;
const data = diagnoses.data || {};
let sections = [];
// Header with manifest info
sections.push(`
<div class="diagnosis-header">
<h4><i data-lucide="stethoscope" class="w-4 h-4 inline mr-1"></i> ${escapeHtml(manifest.task_description || 'Diagnosis Context')}</h4>
<div class="diagnosis-meta">
<span class="meta-item">Diagnoses: <strong>${manifest.diagnosis_count || 0}</strong></span>
</div>
</div>
`);
// Render each diagnosis angle as collapsible section
const diagnosisOrder = ['root-cause', 'api-contracts', 'dataflow', 'performance', 'security', 'error-handling'];
const diagnosisTitles = {
'root-cause': '<i data-lucide="search" class="w-4 h-4 inline mr-1"></i>Root Cause',
'api-contracts': '<i data-lucide="plug" class="w-4 h-4 inline mr-1"></i>API Contracts',
'dataflow': '<i data-lucide="git-merge" class="w-4 h-4 inline mr-1"></i>Data Flow',
'performance': '<i data-lucide="zap" class="w-4 h-4 inline mr-1"></i>Performance',
'security': '<i data-lucide="shield" class="w-4 h-4 inline mr-1"></i>Security',
'error-handling': '<i data-lucide="alert-circle" class="w-4 h-4 inline mr-1"></i>Error Handling'
};
// Collect all angles from data (in case there are diagnosis angles not in our predefined list)
const allAngles = [...new Set([...diagnosisOrder, ...Object.keys(data)])];
for (const angle of allAngles) {
const diagData = data[angle];
if (!diagData) {
continue;
}
const angleContent = renderDiagnosisAngle(angle, diagData);
sections.push(`
<div class="diagnosis-section collapsible-section">
<div class="collapsible-header">
<span class="collapse-icon">▶</span>
<span class="section-label">${diagnosisTitles[angle] || ('<i data-lucide="file-search" class="w-4 h-4 inline mr-1"></i>' + angle)}</span>
</div>
<div class="collapsible-content collapsed">
${angleContent}
</div>
</div>
`);
}
return `<div class="diagnosis-context">${sections.join('')}</div>`;
}
function renderDiagnosisAngle(angle, data) {
let content = [];
// Summary/Overview
if (data.summary || data.overview) {
content.push(renderExpField('Summary', data.summary || data.overview));
}
// Root cause analysis
if (data.root_cause || data.root_cause_analysis) {
content.push(renderExpField('Root Cause', data.root_cause || data.root_cause_analysis));
}
// Issues/Findings
if (data.issues && Array.isArray(data.issues)) {
content.push(`
<div class="exp-field">
<label>Issues Found (${data.issues.length})</label>
<div class="issues-list">
${data.issues.map(issue => {
if (typeof issue === 'string') {
return `<div class="issue-item">${escapeHtml(issue)}</div>`;
} else {
return `
<div class="issue-item">
<div class="issue-title">${escapeHtml(issue.title || issue.description || 'Unknown')}</div>
${issue.location ? `<div class="issue-location"><code>${escapeHtml(issue.location)}</code></div>` : ''}
${issue.severity ? `<span class="severity-badge ${escapeHtml(issue.severity)}">${escapeHtml(issue.severity)}</span>` : ''}
</div>
`;
}
}).join('')}
</div>
</div>
`);
}
// Affected files
if (data.affected_files && Array.isArray(data.affected_files)) {
content.push(`
<div class="exp-field">
<label>Affected Files (${data.affected_files.length})</label>
<div class="path-tags">
${data.affected_files.map(f => {
const filePath = typeof f === 'string' ? f : (f.path || f.file || '');
return `<span class="path-tag">${escapeHtml(filePath)}</span>`;
}).join('')}
</div>
</div>
`);
}
// Recommendations
if (data.recommendations && Array.isArray(data.recommendations)) {
content.push(`
<div class="exp-field">
<label>Recommendations</label>
<ol class="recommendations-list">
${data.recommendations.map(rec => {
const recText = typeof rec === 'string' ? rec : (rec.description || rec.action || '');
return `<li>${escapeHtml(recText)}</li>`;
}).join('')}
</ol>
</div>
`);
}
// API Contracts (specific to api-contracts diagnosis)
if (data.contracts && Array.isArray(data.contracts)) {
content.push(renderExpField('API Contracts', data.contracts));
}
// Data flow (specific to dataflow diagnosis)
if (data.dataflow || data.data_flow) {
content.push(renderExpField('Data Flow', data.dataflow || data.data_flow));
}
return content.join('') || '<p>No diagnosis data available</p>';
}

View File

@@ -378,9 +378,11 @@ const i18n = {
'mcp.newProjectServer': 'New Project Server',
'mcp.newServer': 'New Server',
'mcp.newGlobalServer': 'New Global Server',
'mcp.copyInstallCmd': 'Copy Install Command',
'mcp.installCmdCopied': 'Install command copied to clipboard',
'mcp.installCmdFailed': 'Failed to copy install command',
'mcp.installToProject': 'Install to Project',
'mcp.installToGlobal': 'Install to Global',
'mcp.installToWorkspace': 'Install to Workspace',
'mcp.updateInWorkspace': 'Update in Workspace',
'mcp.updateInGlobal': 'Update in Global',
'mcp.serversConfigured': 'servers configured',
'mcp.serversAvailable': 'servers available',
'mcp.globalAvailable': '全局可用 MCP',
@@ -413,6 +415,26 @@ const i18n = {
'mcp.availableToAll': 'Available to all projects from ~/.claude.json',
'mcp.managedByOrg': 'Managed by organization (highest priority)',
'mcp.variables': 'variables',
'mcp.cmd': 'Command',
'mcp.url': 'URL',
'mcp.args': 'Arguments',
'mcp.env': 'Environment',
'mcp.usedInCount': 'Used in {count} project{s}',
'mcp.from': 'from',
'mcp.variant': 'variant',
'mcp.sourceEnterprise': 'Enterprise',
'mcp.sourceGlobal': 'Global',
'mcp.sourceProject': 'Project',
'mcp.viewDetails': 'View Details',
'mcp.clickToViewDetails': 'Click to view details',
// MCP Details Modal
'mcp.detailsModal.title': 'MCP Server Details',
'mcp.detailsModal.close': 'Close',
'mcp.detailsModal.serverName': 'Server Name',
'mcp.detailsModal.source': 'Source',
'mcp.detailsModal.configuration': 'Configuration',
'mcp.detailsModal.noEnv': 'No environment variables',
// MCP Create Modal
'mcp.createTitle': 'Create MCP Server',
@@ -456,6 +478,34 @@ const i18n = {
'mcp.toProject': 'To Project',
'mcp.toGlobal': 'To Global',
// MCP CLI Mode
'mcp.cliMode': 'CLI Mode',
'mcp.claudeMode': 'Claude Mode',
'mcp.codexMode': 'Codex Mode',
// Codex MCP
'mcp.codex.globalServers': 'Codex Global MCP Servers',
'mcp.codex.newServer': 'New Server',
'mcp.codex.noServers': 'No Codex MCP servers configured',
'mcp.codex.noServersHint': 'Add servers via "codex mcp add" or create one here',
'mcp.codex.infoTitle': 'About Codex MCP',
'mcp.codex.infoDesc': 'Codex MCP servers are global only (stored in ~/.codex/config.toml). Use TOML format for configuration.',
'mcp.codex.serverAdded': 'Codex MCP server "{name}" added',
'mcp.codex.addFailed': 'Failed to add Codex MCP server',
'mcp.codex.serverRemoved': 'Codex MCP server "{name}" removed',
'mcp.codex.removeFailed': 'Failed to remove Codex MCP server',
'mcp.codex.serverToggled': 'Codex MCP server "{name}" {state}',
'mcp.codex.toggleFailed': 'Failed to toggle Codex MCP server',
'mcp.codex.remove': 'Remove',
'mcp.codex.removeConfirm': 'Remove Codex MCP server "{name}"?',
'mcp.codex.copyToClaude': 'Copy to Claude',
'mcp.codex.copyToCodex': 'Copy to Codex',
'mcp.codex.copyFromClaude': 'Copy Claude Servers to Codex',
'mcp.codex.alreadyAdded': 'Already in Codex',
'mcp.codex.scopeCodex': 'Codex - Global (~/.codex/config.toml)',
'mcp.codex.enabledTools': 'Tools',
'mcp.codex.tools': 'tools enabled',
// Hook Manager
'hook.projectHooks': 'Project Hooks',
'hook.projectFile': '.claude/settings.json',
@@ -1316,9 +1366,11 @@ const i18n = {
// MCP Manager
'mcp.currentAvailable': '当前可用 MCP',
'mcp.copyInstallCmd': '复制安装命令',
'mcp.installCmdCopied': '安装命令已复制到剪贴板',
'mcp.installCmdFailed': '复制安装命令失败',
'mcp.installToProject': '安装到项目',
'mcp.installToGlobal': '安装到全局',
'mcp.installToWorkspace': '安装到工作空间',
'mcp.updateInWorkspace': '在工作空间更新',
'mcp.updateInGlobal': '在全局更新',
'mcp.projectAvailable': '当前可用 MCP',
'mcp.newServer': '新建服务器',
'mcp.newGlobalServer': '新建全局服务器',
@@ -1355,7 +1407,27 @@ const i18n = {
'mcp.availableToAll': '可用于所有项目,来自 ~/.claude.json',
'mcp.managedByOrg': '由组织管理(最高优先级)',
'mcp.variables': '个变量',
'mcp.cmd': '命令',
'mcp.url': '地址',
'mcp.args': '参数',
'mcp.env': '环境变量',
'mcp.usedInCount': '用于 {count} 个项目',
'mcp.from': '来自',
'mcp.variant': '变体',
'mcp.sourceEnterprise': '企业级',
'mcp.sourceGlobal': '全局',
'mcp.sourceProject': '项目级',
'mcp.viewDetails': '查看详情',
'mcp.clickToViewDetails': '点击查看详情',
// MCP Details Modal
'mcp.detailsModal.title': 'MCP 服务器详情',
'mcp.detailsModal.close': '关闭',
'mcp.detailsModal.serverName': '服务器名称',
'mcp.detailsModal.source': '来源',
'mcp.detailsModal.configuration': '配置',
'mcp.detailsModal.noEnv': '无环境变量',
// MCP Create Modal
'mcp.createTitle': '创建 MCP 服务器',
'mcp.form': '表单',
@@ -1375,7 +1447,35 @@ const i18n = {
'mcp.installToMcpJson': '安装到 .mcp.json推荐',
'mcp.claudeJsonDesc': '保存在根目录 .claude.json projects 字段下(共享配置)',
'mcp.mcpJsonDesc': '保存在项目 .mcp.json 文件中(推荐用于版本控制)',
// MCP CLI Mode
'mcp.cliMode': 'CLI 模式',
'mcp.claudeMode': 'Claude 模式',
'mcp.codexMode': 'Codex 模式',
// Codex MCP
'mcp.codex.globalServers': 'Codex 全局 MCP 服务器',
'mcp.codex.newServer': '新建服务器',
'mcp.codex.noServers': '未配置 Codex MCP 服务器',
'mcp.codex.noServersHint': '使用 "codex mcp add" 命令或在此处创建',
'mcp.codex.infoTitle': '关于 Codex MCP',
'mcp.codex.infoDesc': 'Codex MCP 服务器仅支持全局配置(存储在 ~/.codex/config.toml。使用 TOML 格式配置。',
'mcp.codex.serverAdded': 'Codex MCP 服务器 "{name}" 已添加',
'mcp.codex.addFailed': '添加 Codex MCP 服务器失败',
'mcp.codex.serverRemoved': 'Codex MCP 服务器 "{name}" 已移除',
'mcp.codex.removeFailed': '移除 Codex MCP 服务器失败',
'mcp.codex.serverToggled': 'Codex MCP 服务器 "{name}" 已{state}',
'mcp.codex.toggleFailed': '切换 Codex MCP 服务器失败',
'mcp.codex.remove': '移除',
'mcp.codex.removeConfirm': '移除 Codex MCP 服务器 "{name}"',
'mcp.codex.copyToClaude': '复制到 Claude',
'mcp.codex.copyToCodex': '复制到 Codex',
'mcp.codex.copyFromClaude': '从 Claude 复制服务器到 Codex',
'mcp.codex.alreadyAdded': '已在 Codex 中',
'mcp.codex.scopeCodex': 'Codex - 全局 (~/.codex/config.toml)',
'mcp.codex.enabledTools': '工具',
'mcp.codex.tools': '个工具已启用',
// Hook Manager
'hook.projectHooks': '项目钩子',
'hook.projectFile': '.claude/settings.json',

View File

@@ -140,7 +140,7 @@ function toggleSection(header) {
function initCollapsibleSections(container) {
setTimeout(() => {
const headers = container.querySelectorAll('.collapsible-header');
headers.forEach(header => {
headers.forEach((header) => {
if (!header._clickBound) {
header._clickBound = true;
header.addEventListener('click', function(e) {

View File

@@ -160,11 +160,13 @@ function showLiteTaskDetailPage(sessionKey) {
</div>
`;
// Initialize collapsible sections
// Initialize collapsible sections and task click handlers
setTimeout(() => {
document.querySelectorAll('.collapsible-header').forEach(header => {
header.addEventListener('click', () => toggleSection(header));
});
// Bind click events to lite task items on initial load
initLiteTaskClickHandlers();
}, 50);
}
@@ -194,11 +196,13 @@ function switchLiteDetailTab(tabName) {
switch (tabName) {
case 'tasks':
contentArea.innerHTML = renderLiteTasksTab(session, tasks, completed, inProgress, pending);
// Re-initialize collapsible sections
// Re-initialize collapsible sections and task click handlers
setTimeout(() => {
document.querySelectorAll('.collapsible-header').forEach(header => {
header.addEventListener('click', () => toggleSection(header));
});
// Bind click events to lite task items
initLiteTaskClickHandlers();
}, 50);
break;
case 'plan':
@@ -259,12 +263,16 @@ function renderLiteTaskDetailItem(sessionId, task) {
const implCount = rawTask.implementation?.length || 0;
const acceptCount = rawTask.acceptance?.length || 0;
// Escape for data attributes
const safeSessionId = escapeHtml(sessionId);
const safeTaskId = escapeHtml(task.id);
return `
<div class="detail-task-item-full lite-task-item" onclick="openTaskDrawerForLite('${sessionId}', '${escapeHtml(task.id)}')" style="cursor: pointer;" title="Click to view details">
<div class="detail-task-item-full lite-task-item" data-session-id="${safeSessionId}" data-task-id="${safeTaskId}" style="cursor: pointer;" title="Click to view details">
<div class="task-item-header-lite">
<span class="task-id-badge">${escapeHtml(task.id)}</span>
<span class="task-title">${escapeHtml(task.title || 'Untitled')}</span>
<button class="btn-view-json" onclick="event.stopPropagation(); showJsonModal('${taskJsonId}', '${escapeHtml(task.id)}')">{ } JSON</button>
<button class="btn-view-json" data-task-json-id="${taskJsonId}" data-task-display-id="${safeTaskId}">{ } JSON</button>
</div>
<div class="task-item-meta-lite">
${action ? `<span class="meta-badge action">${escapeHtml(action)}</span>` : ''}
@@ -285,6 +293,39 @@ function getMetaPreviewForLite(task, rawTask) {
return parts.join(' | ') || 'No meta';
}
/**
* Initialize click handlers for lite task items
*/
function initLiteTaskClickHandlers() {
// Task item click handlers
document.querySelectorAll('.lite-task-item').forEach(item => {
if (!item._clickBound) {
item._clickBound = true;
item.addEventListener('click', function(e) {
// Don't trigger if clicking on JSON button
if (e.target.closest('.btn-view-json')) return;
const sessionId = this.dataset.sessionId;
const taskId = this.dataset.taskId;
openTaskDrawerForLite(sessionId, taskId);
});
}
});
// JSON button click handlers
document.querySelectorAll('.btn-view-json').forEach(btn => {
if (!btn._clickBound) {
btn._clickBound = true;
btn.addEventListener('click', function(e) {
e.stopPropagation();
const taskJsonId = this.dataset.taskJsonId;
const displayId = this.dataset.taskDisplayId;
showJsonModal(taskJsonId, displayId);
});
}
});
}
function openTaskDrawerForLite(sessionId, taskId) {
const session = liteTaskDataStore[currentSessionDetailKey];
if (!session) return;
@@ -454,15 +495,15 @@ async function loadAndRenderLiteContextTab(session, contentArea) {
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=context`);
if (response.ok) {
const data = await response.json();
contentArea.innerHTML = renderLiteContextContent(data.context, data.explorations, session);
// Re-initialize collapsible sections for explorations (scoped to contentArea)
contentArea.innerHTML = renderLiteContextContent(data.context, data.explorations, session, data.diagnoses);
// Re-initialize collapsible sections for explorations and diagnoses (scoped to contentArea)
initCollapsibleSections(contentArea);
return;
}
}
// Fallback: show plan context if available
contentArea.innerHTML = renderLiteContextContent(null, null, session);
contentArea.innerHTML = renderLiteContextContent(null, null, session, null);
initCollapsibleSections(contentArea);
} catch (err) {
contentArea.innerHTML = `<div class="tab-error">Failed to load context: ${err.message}</div>`;
@@ -530,7 +571,9 @@ function renderDiagnosesTab(session) {
// Individual diagnosis items
if (diagnoses.items && diagnoses.items.length > 0) {
const diagnosisCards = diagnoses.items.map(diag => renderDiagnosisCard(diag)).join('');
const diagnosisCards = diagnoses.items.map((diag) => {
return renderDiagnosisCard(diag);
}).join('');
sections.push(`
<div class="diagnoses-items-section">
<h4 class="diagnoses-section-title"><i data-lucide="search" class="w-4 h-4 inline mr-1"></i> Diagnosis Details (${diagnoses.items.length})</h4>
@@ -565,7 +608,21 @@ function renderDiagnosisCard(diag) {
function renderDiagnosisContent(diag) {
let content = [];
// Summary/Overview
// Symptom (for detailed diagnosis structure)
if (diag.symptom) {
const symptom = diag.symptom;
content.push(`
<div class="diag-section">
<strong>Symptom:</strong>
${symptom.description ? `<p>${escapeHtml(symptom.description)}</p>` : ''}
${symptom.user_impact ? `<div class="symptom-impact"><strong>User Impact:</strong> ${escapeHtml(symptom.user_impact)}</div>` : ''}
${symptom.frequency ? `<div class="symptom-freq"><strong>Frequency:</strong> <span class="badge">${escapeHtml(symptom.frequency)}</span></div>` : ''}
${symptom.error_message ? `<div class="symptom-error"><strong>Error:</strong> <code>${escapeHtml(symptom.error_message)}</code></div>` : ''}
</div>
`);
}
// Summary/Overview (for simple diagnosis structure)
if (diag.summary || diag.overview) {
content.push(`
<div class="diag-section">
@@ -576,11 +633,34 @@ function renderDiagnosisContent(diag) {
}
// Root Cause Analysis
if (diag.root_cause || diag.root_cause_analysis) {
if (diag.root_cause) {
const rootCause = diag.root_cause;
// Handle both object and string formats
if (typeof rootCause === 'object') {
content.push(`
<div class="diag-section">
<strong>Root Cause:</strong>
${rootCause.file ? `<div class="rc-file"><strong>File:</strong> <code>${escapeHtml(rootCause.file)}</code></div>` : ''}
${rootCause.line_range ? `<div class="rc-line"><strong>Lines:</strong> ${escapeHtml(rootCause.line_range)}</div>` : ''}
${rootCause.function ? `<div class="rc-func"><strong>Function:</strong> <code>${escapeHtml(rootCause.function)}</code></div>` : ''}
${rootCause.issue ? `<p>${escapeHtml(rootCause.issue)}</p>` : ''}
${rootCause.confidence ? `<div class="rc-confidence"><strong>Confidence:</strong> ${(rootCause.confidence * 100).toFixed(0)}%</div>` : ''}
${rootCause.category ? `<div class="rc-category"><strong>Category:</strong> <span class="badge">${escapeHtml(rootCause.category)}</span></div>` : ''}
</div>
`);
} else if (typeof rootCause === 'string') {
content.push(`
<div class="diag-section">
<strong>Root Cause:</strong>
<p>${escapeHtml(rootCause)}</p>
</div>
`);
}
} else if (diag.root_cause_analysis) {
content.push(`
<div class="diag-section">
<strong>Root Cause:</strong>
<p>${escapeHtml(diag.root_cause || diag.root_cause_analysis)}</p>
<p>${escapeHtml(diag.root_cause_analysis)}</p>
</div>
`);
}
@@ -660,6 +740,37 @@ function renderDiagnosisContent(diag) {
`);
}
// Reproduction Steps
if (diag.reproduction_steps && Array.isArray(diag.reproduction_steps)) {
content.push(`
<div class="diag-section">
<strong>Reproduction Steps:</strong>
<ol class="repro-steps-list">
${diag.reproduction_steps.map(step => `<li>${escapeHtml(step)}</li>`).join('')}
</ol>
</div>
`);
}
// Fix Hints
if (diag.fix_hints && Array.isArray(diag.fix_hints)) {
content.push(`
<div class="diag-section">
<strong>Fix Hints (${diag.fix_hints.length}):</strong>
<div class="fix-hints-list">
${diag.fix_hints.map((hint, idx) => `
<div class="fix-hint-item">
<div class="hint-header"><strong>Hint ${idx + 1}:</strong> ${escapeHtml(hint.description || 'No description')}</div>
${hint.approach ? `<div class="hint-approach"><strong>Approach:</strong> ${escapeHtml(hint.approach)}</div>` : ''}
${hint.risk ? `<div class="hint-risk"><strong>Risk:</strong> <span class="badge risk-${hint.risk}">${escapeHtml(hint.risk)}</span></div>` : ''}
${hint.code_example ? `<div class="hint-code"><strong>Code Example:</strong><pre><code>${escapeHtml(hint.code_example)}</code></pre></div>` : ''}
</div>
`).join('')}
</div>
</div>
`);
}
// Recommendations
if (diag.recommendations && Array.isArray(diag.recommendations)) {
content.push(`
@@ -672,10 +783,75 @@ function renderDiagnosisContent(diag) {
`);
}
// If no specific content was rendered, show raw JSON preview
if (content.length === 0) {
// Dependencies
if (diag.dependencies && typeof diag.dependencies === 'string') {
content.push(`
<div class="diag-section">
<strong>Dependencies:</strong>
<p>${escapeHtml(diag.dependencies)}</p>
</div>
`);
}
// Constraints
if (diag.constraints && typeof diag.constraints === 'string') {
content.push(`
<div class="diag-section">
<strong>Constraints:</strong>
<p>${escapeHtml(diag.constraints)}</p>
</div>
`);
}
// Clarification Needs
if (diag.clarification_needs && Array.isArray(diag.clarification_needs)) {
content.push(`
<div class="diag-section">
<strong>Clarification Needs:</strong>
<div class="clarification-list">
${diag.clarification_needs.map(clar => `
<div class="clarification-item">
<div class="clar-question"><strong>Q:</strong> ${escapeHtml(clar.question)}</div>
${clar.context ? `<div class="clar-context"><strong>Context:</strong> ${escapeHtml(clar.context)}</div>` : ''}
${clar.options && Array.isArray(clar.options) ? `
<div class="clar-options">
<strong>Options:</strong>
<ul>
${clar.options.map(opt => `<li>${escapeHtml(opt)}</li>`).join('')}
</ul>
</div>
` : ''}
</div>
`).join('')}
</div>
</div>
`);
}
// Related Issues
if (diag.related_issues && Array.isArray(diag.related_issues)) {
content.push(`
<div class="diag-section">
<strong>Related Issues:</strong>
<ul class="related-issues-list">
${diag.related_issues.map(issue => `
<li>
${issue.type ? `<span class="issue-type-badge">${escapeHtml(issue.type)}</span>` : ''}
${issue.reference ? `<strong>${escapeHtml(issue.reference)}</strong>: ` : ''}
${issue.description ? escapeHtml(issue.description) : ''}
</li>
`).join('')}
</ul>
</div>
`);
}
// If no specific content was rendered, show raw JSON preview
if (content.length === 0) {
console.warn('[DEBUG] No content rendered for diagnosis:', diag);
content.push(`
<div class="diag-section">
<strong>Debug: Raw JSON</strong>
<pre class="json-content">${escapeHtml(JSON.stringify(diag, null, 2))}</pre>
</div>
`);

View File

@@ -17,7 +17,7 @@ const CCW_MCP_TOOLS = [
// Get currently enabled tools from installed config
function getCcwEnabledTools() {
const currentPath = projectPath.replace(/\//g, '\\');
const currentPath = projectPath; // Keep original format (forward slash)
const projectData = mcpAllProjects[currentPath] || {};
const ccwConfig = projectData.mcpServers?.['ccw-tools'];
if (ccwConfig?.env?.CCW_ENABLED_TOOLS) {
@@ -46,7 +46,7 @@ async function renderMcpManager() {
// Load MCP templates
await loadMcpTemplates();
const currentPath = projectPath.replace(/\//g, '\\');
const currentPath = projectPath; // Keep original format (forward slash)
const projectData = mcpAllProjects[currentPath] || {};
const projectServers = projectData.mcpServers || {};
const disabledServers = projectData.disabledMcpServers || [];
@@ -121,8 +121,136 @@ async function renderMcpManager() {
const isCcwToolsInstalled = currentProjectServerNames.includes("ccw-tools");
const enabledTools = getCcwEnabledTools();
// Prepare Codex servers data
const codexServerEntries = Object.entries(codexMcpServers || {});
const codexConfigExists = codexMcpConfig?.exists || false;
const codexConfigPath = codexMcpConfig?.configPath || '~/.codex/config.toml';
container.innerHTML = `
<div class="mcp-manager">
<!-- CLI Mode Toggle -->
<div class="mcp-cli-toggle mb-6">
<div class="flex items-center justify-between bg-card border border-border rounded-lg p-4">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-foreground">${t('mcp.cliMode')}</span>
<div class="flex items-center bg-muted rounded-lg p-1">
<button class="cli-mode-btn px-4 py-2 text-sm font-medium rounded-md transition-all ${currentCliMode === 'claude' ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
onclick="setCliMode('claude')">
<i data-lucide="bot" class="w-4 h-4 inline mr-1.5"></i>
Claude
</button>
<button class="cli-mode-btn px-4 py-2 text-sm font-medium rounded-md transition-all ${currentCliMode === 'codex' ? 'bg-orange-500 text-white shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
onclick="setCliMode('codex')">
<i data-lucide="code-2" class="w-4 h-4 inline mr-1.5"></i>
Codex
</button>
</div>
</div>
<div class="text-xs text-muted-foreground">
${currentCliMode === 'claude'
? `<span class="flex items-center gap-1"><i data-lucide="file-json" class="w-3 h-3"></i> ~/.claude.json</span>`
: `<span class="flex items-center gap-1"><i data-lucide="file-code" class="w-3 h-3"></i> ${codexConfigPath}</span>`
}
</div>
</div>
</div>
${currentCliMode === 'codex' ? `
<!-- Codex MCP Servers Section -->
<div class="mcp-section mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<i data-lucide="code-2" class="w-5 h-5 text-orange-500"></i>
<h3 class="text-lg font-semibold text-foreground">${t('mcp.codex.globalServers')}</h3>
</div>
<button class="px-3 py-1.5 text-sm bg-orange-500 text-white rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
onclick="openCodexMcpCreateModal()">
<span>+</span> ${t('mcp.codex.newServer')}
</button>
${codexConfigExists ? `
<span class="inline-flex items-center gap-1.5 px-2 py-1 text-xs bg-success/10 text-success rounded-md border border-success/20">
<i data-lucide="file-check" class="w-3.5 h-3.5"></i>
config.toml
</span>
` : `
<span class="inline-flex items-center gap-1.5 px-2 py-1 text-xs bg-muted text-muted-foreground rounded-md border border-border" title="Will create ~/.codex/config.toml">
<i data-lucide="file-plus" class="w-3.5 h-3.5"></i>
Will create config.toml
</span>
`}
</div>
<span class="text-sm text-muted-foreground">${codexServerEntries.length} ${t('mcp.serversAvailable')}</span>
</div>
<!-- Info about Codex MCP -->
<div class="bg-orange-50 dark:bg-orange-950/30 border border-orange-200 dark:border-orange-800 rounded-lg p-4 mb-4">
<div class="flex items-start gap-3">
<i data-lucide="info" class="w-5 h-5 text-orange-500 shrink-0 mt-0.5"></i>
<div class="text-sm">
<p class="text-orange-800 dark:text-orange-200 font-medium mb-1">${t('mcp.codex.infoTitle')}</p>
<p class="text-orange-700 dark:text-orange-300 text-xs">${t('mcp.codex.infoDesc')}</p>
</div>
</div>
</div>
${codexServerEntries.length === 0 ? `
<div class="mcp-empty-state bg-card border border-border rounded-lg p-6 text-center">
<div class="text-muted-foreground mb-3"><i data-lucide="plug" class="w-10 h-10 mx-auto"></i></div>
<p class="text-muted-foreground">${t('mcp.codex.noServers')}</p>
<p class="text-sm text-muted-foreground mt-1">${t('mcp.codex.noServersHint')}</p>
</div>
` : `
<div class="mcp-server-grid grid gap-3">
${codexServerEntries.map(([serverName, serverConfig]) => {
return renderCodexServerCard(serverName, serverConfig);
}).join('')}
</div>
`}
</div>
<!-- Copy Claude Servers to Codex -->
${Object.keys(mcpUserServers || {}).length > 0 ? `
<div class="mcp-section mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-foreground flex items-center gap-2">
<i data-lucide="copy" class="w-5 h-5"></i>
${t('mcp.codex.copyFromClaude')}
</h3>
<span class="text-sm text-muted-foreground">${Object.keys(mcpUserServers || {}).length} ${t('mcp.serversAvailable')}</span>
</div>
<div class="mcp-server-grid grid gap-3">
${Object.entries(mcpUserServers || {}).map(([serverName, serverConfig]) => {
const alreadyInCodex = codexMcpServers && codexMcpServers[serverName];
return `
<div class="mcp-server-card bg-card border ${alreadyInCodex ? 'border-success/50' : 'border-border'} border-dashed rounded-lg p-4 hover:shadow-md transition-all">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<i data-lucide="bot" class="w-5 h-5 text-primary"></i>
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
${alreadyInCodex ? `<span class="text-xs px-2 py-0.5 bg-success/10 text-success rounded-full">${t('mcp.codex.alreadyAdded')}</span>` : ''}
</div>
${!alreadyInCodex ? `
<button class="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:opacity-90 transition-opacity"
onclick="copyClaudeServerToCodex('${escapeHtml(serverName)}', ${JSON.stringify(serverConfig).replace(/'/g, "&#39;")})"
title="${t('mcp.codex.copyToCodex')}">
<i data-lucide="arrow-right" class="w-3.5 h-3.5 inline"></i> Codex
</button>
` : ''}
</div>
<div class="mcp-server-details text-sm space-y-1">
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${t('mcp.cmd')}</span>
<span class="truncate" title="${escapeHtml(serverConfig.command || 'N/A')}">${escapeHtml(serverConfig.command || 'N/A')}</span>
</div>
</div>
</div>
`;
}).join('')}
</div>
</div>
` : ''}
` : `
<!-- CCW Tools MCP Server Card -->
<div class="mcp-section mb-6">
<div class="ccw-tools-card bg-gradient-to-br from-primary/10 to-primary/5 border-2 ${isCcwToolsInstalled ? 'border-success' : 'border-primary/30'} rounded-lg p-6 hover:shadow-lg transition-all">
@@ -164,17 +292,32 @@ async function renderMcpManager() {
</div>
</div>
</div>
<div class="shrink-0">
<div class="shrink-0 flex gap-2">
${isCcwToolsInstalled ? `
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
onclick="updateCcwToolsMcp()">
Update
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
onclick="updateCcwToolsMcp('workspace')"
title="${t('mcp.updateInWorkspace')}">
<i data-lucide="folder" class="w-4 h-4"></i>
${t('mcp.updateInWorkspace')}
</button>
<button class="px-4 py-2 text-sm bg-success text-success-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
onclick="updateCcwToolsMcp('global')"
title="${t('mcp.updateInGlobal')}">
<i data-lucide="globe" class="w-4 h-4"></i>
${t('mcp.updateInGlobal')}
</button>
` : `
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-2"
onclick="installCcwToolsMcp()">
<i data-lucide="download" class="w-4 h-4"></i>
Install
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
onclick="installCcwToolsMcp('workspace')"
title="${t('mcp.installToWorkspace')}">
<i data-lucide="folder" class="w-4 h-4"></i>
${t('mcp.installToWorkspace')}
</button>
<button class="px-4 py-2 text-sm bg-success text-success-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
onclick="installCcwToolsMcp('global')"
title="${t('mcp.installToGlobal')}">
<i data-lucide="globe" class="w-4 h-4"></i>
${t('mcp.installToGlobal')}
</button>
`}
</div>
@@ -300,12 +443,12 @@ async function renderMcpManager() {
<div class="mcp-server-details text-sm space-y-1 mb-3">
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">cmd</span>
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${t('mcp.cmd')}</span>
<span class="truncate text-xs" title="${escapeHtml(template.serverConfig.command)}">${escapeHtml(template.serverConfig.command)}</span>
</div>
${template.serverConfig.args && template.serverConfig.args.length > 0 ? `
<div class="flex items-start gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">args</span>
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">${t('mcp.args')}</span>
<span class="text-xs font-mono truncate" title="${escapeHtml(template.serverConfig.args.join(' '))}">${escapeHtml(template.serverConfig.args.slice(0, 2).join(' '))}${template.serverConfig.args.length > 2 ? '...' : ''}</span>
</div>
` : ''}
@@ -343,7 +486,8 @@ async function renderMcpManager() {
</div>
` : ''}
<!-- All Projects MCP Overview Table -->
<!-- All Projects MCP Overview Table (Claude mode only) -->
${currentCliMode === 'claude' ? `
<div class="mcp-section mt-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-foreground">${t('mcp.allProjects')}</h3>
@@ -411,6 +555,25 @@ async function renderMcpManager() {
</table>
</div>
</div>
` : ''}
<!-- MCP Server Details Modal -->
<div id="mcpDetailsModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 hidden">
<div class="bg-card border border-border rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-hidden flex flex-col">
<!-- Modal Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 class="text-lg font-semibold text-foreground">${t('mcp.detailsModal.title')}</h2>
<button id="mcpDetailsModalClose" class="text-muted-foreground hover:text-foreground transition-colors">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<!-- Modal Body -->
<div id="mcpDetailsModalBody" class="px-6 py-4 overflow-y-auto flex-1">
<!-- Content will be dynamically filled -->
</div>
</div>
</div>
</div>
`;
@@ -431,15 +594,20 @@ function renderProjectAvailableServerCard(entry) {
// Source badge
let sourceBadge = '';
if (source === 'enterprise') {
sourceBadge = '<span class="text-xs px-2 py-0.5 bg-warning/20 text-warning rounded-full">Enterprise</span>';
sourceBadge = `<span class="text-xs px-2 py-0.5 bg-warning/20 text-warning rounded-full">${t('mcp.sourceEnterprise')}</span>`;
} else if (source === 'global') {
sourceBadge = '<span class="text-xs px-2 py-0.5 bg-success/10 text-success rounded-full">Global</span>';
sourceBadge = `<span class="text-xs px-2 py-0.5 bg-success/10 text-success rounded-full">${t('mcp.sourceGlobal')}</span>`;
} else if (source === 'project') {
sourceBadge = '<span class="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full">Project</span>';
sourceBadge = `<span class="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full">${t('mcp.sourceProject')}</span>`;
}
return `
<div class="mcp-server-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all ${canToggle && !isEnabled ? 'opacity-60' : ''}">
<div class="mcp-server-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all cursor-pointer ${canToggle && !isEnabled ? 'opacity-60' : ''}"
data-server-name="${escapeHtml(name)}"
data-server-config="${escapeHtml(JSON.stringify(config))}"
data-server-source="${source}"
data-action="view-details"
title="${t('mcp.clickToViewDetails')}">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span>${canToggle && isEnabled ? '<i data-lucide="check-circle" class="w-5 h-5 text-success"></i>' : '<i data-lucide="circle" class="w-5 h-5 text-muted-foreground"></i>'}</span>
@@ -447,7 +615,7 @@ function renderProjectAvailableServerCard(entry) {
${sourceBadge}
</div>
${canToggle ? `
<label class="mcp-toggle relative inline-flex items-center cursor-pointer">
<label class="mcp-toggle relative inline-flex items-center cursor-pointer" onclick="event.stopPropagation()">
<input type="checkbox" class="sr-only peer"
${isEnabled ? 'checked' : ''}
data-server-name="${escapeHtml(name)}"
@@ -459,33 +627,25 @@ function renderProjectAvailableServerCard(entry) {
<div class="mcp-server-details text-sm space-y-1">
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">cmd</span>
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${t('mcp.cmd')}</span>
<span class="truncate" title="${escapeHtml(command)}">${escapeHtml(command)}</span>
</div>
${args.length > 0 ? `
<div class="flex items-start gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">args</span>
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">${t('mcp.args')}</span>
<span class="text-xs font-mono truncate" title="${escapeHtml(args.join(' '))}">${escapeHtml(args.slice(0, 3).join(' '))}${args.length > 3 ? '...' : ''}</span>
</div>
` : ''}
${hasEnv ? `
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">env</span>
<span class="text-xs">${Object.keys(config.env).length} variables</span>
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${t('mcp.env')}</span>
<span class="text-xs">${Object.keys(config.env).length} ${t('mcp.variables')}</span>
</div>
` : ''}
</div>
<div class="mt-3 pt-3 border-t border-border flex items-center justify-between gap-2">
<div class="mt-3 pt-3 border-t border-border flex items-center justify-between gap-2" onclick="event.stopPropagation()">
<div class="flex items-center gap-2">
<button class="text-xs text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
data-server-name="${escapeHtml(name)}"
data-server-config="${escapeHtml(JSON.stringify(config))}"
data-scope="${source === 'global' ? 'global' : 'project'}"
data-action="copy-install-cmd">
<i data-lucide="copy" class="w-3 h-3"></i>
${t('mcp.copyInstallCmd')}
</button>
<button class="text-xs text-success hover:text-success/80 transition-colors flex items-center gap-1"
data-server-name="${escapeHtml(name)}"
data-server-config="${escapeHtml(JSON.stringify(config))}"
@@ -525,19 +685,19 @@ function renderGlobalManagementCard(serverName, serverConfig) {
<div class="mcp-server-details text-sm space-y-1">
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${serverType === 'stdio' ? 'cmd' : 'url'}</span>
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${serverType === 'stdio' ? t('mcp.cmd') : t('mcp.url')}</span>
<span class="truncate" title="${escapeHtml(command)}">${escapeHtml(command)}</span>
</div>
${args.length > 0 ? `
<div class="flex items-start gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">args</span>
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">${t('mcp.args')}</span>
<span class="text-xs font-mono truncate" title="${escapeHtml(args.join(' '))}">${escapeHtml(args.slice(0, 3).join(' '))}${args.length > 3 ? '...' : ''}</span>
</div>
` : ''}
${hasEnv ? `
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">env</span>
<span class="text-xs">${Object.keys(serverConfig.env).length} variables</span>
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${t('mcp.env')}</span>
<span class="text-xs">${Object.keys(serverConfig.env).length} ${t('mcp.variables')}</span>
</div>
` : ''}
<div class="flex items-center gap-2 text-muted-foreground mt-1">
@@ -545,15 +705,7 @@ function renderGlobalManagementCard(serverName, serverConfig) {
</div>
</div>
<div class="mt-3 pt-3 border-t border-border flex items-center justify-between">
<button class="text-xs text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
data-server-name="${escapeHtml(serverName)}"
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
data-scope="global"
data-action="copy-install-cmd">
<i data-lucide="copy" class="w-3 h-3"></i>
${t('mcp.copyInstallCmd')}
</button>
<div class="mt-3 pt-3 border-t border-border flex items-center justify-end">
<button class="text-xs text-destructive hover:text-destructive/80 transition-colors"
data-server-name="${escapeHtml(serverName)}"
data-action="remove-global">
@@ -617,35 +769,162 @@ function renderAvailableServerCard(serverName, serverInfo) {
<div class="mcp-server-details text-sm space-y-1">
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">cmd</span>
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${t('mcp.cmd')}</span>
<span class="truncate" title="${escapeHtml(command)}">${escapeHtml(command)}</span>
</div>
${argsPreview ? `
<div class="flex items-start gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">args</span>
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">${t('mcp.args')}</span>
<span class="text-xs font-mono truncate" title="${escapeHtml(args.join(' '))}">${escapeHtml(argsPreview)}</span>
</div>
` : ''}
<div class="flex items-center gap-2 text-muted-foreground">
<span class="text-xs">Used in ${usedIn.length} project${usedIn.length !== 1 ? 's' : ''}</span>
${sourceProjectName ? `<span class="text-xs text-muted-foreground/70">• from ${escapeHtml(sourceProjectName)}</span>` : ''}
<span class="text-xs">${t('mcp.usedInCount').replace('{count}', usedIn.length).replace('{s}', usedIn.length !== 1 ? 's' : '')}</span>
${sourceProjectName ? `<span class="text-xs text-muted-foreground/70">• ${t('mcp.from')} ${escapeHtml(sourceProjectName)}</span>` : ''}
</div>
</div>
<div class="mt-3 pt-3 border-t border-border">
<div class="mt-3 pt-3 border-t border-border flex items-center gap-2">
<button class="text-xs text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
data-server-name="${escapeHtml(originalName)}"
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
data-scope="project"
data-action="copy-install-cmd">
<i data-lucide="copy" class="w-3 h-3"></i>
${t('mcp.copyInstallCmd')}
data-action="install-to-project"
title="${t('mcp.installToProject')}">
<i data-lucide="download" class="w-3 h-3"></i>
${t('mcp.installToProject')}
</button>
<button class="text-xs text-success hover:text-success/80 transition-colors flex items-center gap-1"
data-server-name="${escapeHtml(originalName)}"
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
data-action="install-to-global"
title="${t('mcp.installToGlobal')}">
<i data-lucide="globe" class="w-3 h-3"></i>
${t('mcp.installToGlobal')}
</button>
</div>
</div>
`;
}
// ========================================
// Codex MCP Server Card Renderer
// ========================================
function renderCodexServerCard(serverName, serverConfig) {
const isStdio = !!serverConfig.command;
const isHttp = !!serverConfig.url;
const isEnabled = serverConfig.enabled !== false; // Default to enabled
const command = serverConfig.command || serverConfig.url || 'N/A';
const args = serverConfig.args || [];
const hasEnv = serverConfig.env && Object.keys(serverConfig.env).length > 0;
// Server type badge
const typeBadge = isHttp
? `<span class="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">HTTP</span>`
: `<span class="text-xs px-2 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 rounded-full">STDIO</span>`;
return `
<div class="mcp-server-card bg-card border border-orange-200 dark:border-orange-800 rounded-lg p-4 hover:shadow-md transition-all ${!isEnabled ? 'opacity-60' : ''}"
data-server-name="${escapeHtml(serverName)}"
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
data-cli-type="codex">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span>${isEnabled ? '<i data-lucide="check-circle" class="w-5 h-5 text-orange-500"></i>' : '<i data-lucide="circle" class="w-5 h-5 text-muted-foreground"></i>'}</span>
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
${typeBadge}
</div>
<label class="mcp-toggle relative inline-flex items-center cursor-pointer" onclick="event.stopPropagation()">
<input type="checkbox" class="sr-only peer"
${isEnabled ? 'checked' : ''}
data-server-name="${escapeHtml(serverName)}"
data-action="toggle-codex">
<div class="w-9 h-5 bg-hover peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-orange-500"></div>
</label>
</div>
<div class="mcp-server-details text-sm space-y-1">
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${isHttp ? t('mcp.url') : t('mcp.cmd')}</span>
<span class="truncate" title="${escapeHtml(command)}">${escapeHtml(command)}</span>
</div>
${args.length > 0 ? `
<div class="flex items-start gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">${t('mcp.args')}</span>
<span class="text-xs font-mono truncate" title="${escapeHtml(args.join(' '))}">${escapeHtml(args.slice(0, 3).join(' '))}${args.length > 3 ? '...' : ''}</span>
</div>
` : ''}
${hasEnv ? `
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${t('mcp.env')}</span>
<span class="text-xs">${Object.keys(serverConfig.env).length} ${t('mcp.variables')}</span>
</div>
` : ''}
${serverConfig.enabled_tools ? `
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${t('mcp.codex.enabledTools')}</span>
<span class="text-xs">${serverConfig.enabled_tools.length} ${t('mcp.codex.tools')}</span>
</div>
` : ''}
</div>
<div class="mt-3 pt-3 border-t border-border flex items-center justify-between gap-2" onclick="event.stopPropagation()">
<div class="flex items-center gap-2">
<button class="text-xs text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
onclick="copyCodexServerToClaude('${escapeHtml(serverName)}', ${JSON.stringify(serverConfig).replace(/'/g, "&#39;")})"
title="${t('mcp.codex.copyToClaude')}">
<i data-lucide="copy" class="w-3 h-3"></i>
${t('mcp.codex.copyToClaude')}
</button>
</div>
<button class="text-xs text-destructive hover:text-destructive/80 transition-colors"
data-server-name="${escapeHtml(serverName)}"
data-action="remove-codex">
${t('mcp.codex.remove')}
</button>
</div>
</div>
`;
}
// ========================================
// Codex MCP Create Modal
// ========================================
function openCodexMcpCreateModal() {
// Reuse the existing modal with different settings
const modal = document.getElementById('mcpCreateModal');
if (modal) {
modal.classList.remove('hidden');
// Reset to form mode
mcpCreateMode = 'form';
switchMcpCreateTab('form');
// Clear form
document.getElementById('mcpServerName').value = '';
document.getElementById('mcpServerCommand').value = '';
document.getElementById('mcpServerArgs').value = '';
document.getElementById('mcpServerEnv').value = '';
// Clear JSON input
document.getElementById('mcpServerJson').value = '';
document.getElementById('mcpJsonPreview').classList.add('hidden');
// Set scope to codex
const scopeSelect = document.getElementById('mcpServerScope');
if (scopeSelect) {
// Add codex option if not exists
if (!scopeSelect.querySelector('option[value="codex"]')) {
const codexOption = document.createElement('option');
codexOption.value = 'codex';
codexOption.textContent = t('mcp.codex.scopeCodex');
scopeSelect.appendChild(codexOption);
}
scopeSelect.value = 'codex';
}
// Focus on name input
document.getElementById('mcpServerName').focus();
// Setup JSON input listener
setupMcpJsonListener();
}
}
function attachMcpEventListeners() {
// Toggle switches
@@ -692,13 +971,21 @@ function attachMcpEventListeners() {
});
});
// Copy install command buttons
document.querySelectorAll('.mcp-server-card button[data-action="copy-install-cmd"]').forEach(btn => {
// Install to project buttons
document.querySelectorAll('.mcp-server-card button[data-action="install-to-project"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const serverName = btn.dataset.serverName;
const serverConfig = JSON.parse(btn.dataset.serverConfig);
const scope = btn.dataset.scope || 'project';
await copyMcpInstallCommand(serverName, serverConfig, scope);
await installMcpToProject(serverName, serverConfig);
});
});
// Install to global buttons
document.querySelectorAll('.mcp-server-card button[data-action="install-to-global"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const serverName = btn.dataset.serverName;
const serverConfig = JSON.parse(btn.dataset.serverConfig);
await addGlobalMcpServer(serverName, serverConfig);
});
});
@@ -729,6 +1016,142 @@ function attachMcpEventListeners() {
}
});
});
// ========================================
// Codex MCP Event Listeners
// ========================================
// Toggle Codex MCP servers
document.querySelectorAll('.mcp-server-card input[data-action="toggle-codex"]').forEach(input => {
input.addEventListener('change', async (e) => {
const serverName = e.target.dataset.serverName;
const enable = e.target.checked;
await toggleCodexMcpServer(serverName, enable);
});
});
// Remove Codex MCP servers
document.querySelectorAll('.mcp-server-card button[data-action="remove-codex"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const serverName = btn.dataset.serverName;
if (confirm(t('mcp.codex.removeConfirm', { name: serverName }))) {
await removeCodexMcpServer(serverName);
}
});
});
// View details - click on server card
document.querySelectorAll('.mcp-server-card[data-action="view-details"]').forEach(card => {
card.addEventListener('click', (e) => {
const serverName = card.dataset.serverName;
const serverConfig = JSON.parse(card.dataset.serverConfig);
const serverSource = card.dataset.serverSource;
showMcpDetails(serverName, serverConfig, serverSource);
});
});
// Modal close button
const closeBtn = document.getElementById('mcpDetailsModalClose');
const modal = document.getElementById('mcpDetailsModal');
if (closeBtn && modal) {
closeBtn.addEventListener('click', () => {
modal.classList.add('hidden');
});
// Close on background click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.classList.add('hidden');
}
});
}
}
// ========================================
// MCP Details Modal
// ========================================
function showMcpDetails(serverName, serverConfig, serverSource) {
const modal = document.getElementById('mcpDetailsModal');
const modalBody = document.getElementById('mcpDetailsModalBody');
if (!modal || !modalBody) return;
// Build source badge
let sourceBadge = '';
if (serverSource === 'enterprise') {
sourceBadge = `<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-warning/20 text-warning">${t('mcp.sourceEnterprise')}</span>`;
} else if (serverSource === 'global') {
sourceBadge = `<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-success/10 text-success">${t('mcp.sourceGlobal')}</span>`;
} else if (serverSource === 'project') {
sourceBadge = `<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-primary/10 text-primary">${t('mcp.sourceProject')}</span>`;
}
// Build environment variables display
let envHtml = '';
if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
envHtml = '<div class="mt-4"><h4 class="font-semibold text-sm text-foreground mb-2">' + t('mcp.env') + '</h4><div class="bg-muted rounded-lg p-3 space-y-1 font-mono text-xs">';
for (const [key, value] of Object.entries(serverConfig.env)) {
envHtml += `<div class="flex items-start gap-2"><span class="text-muted-foreground shrink-0">${escapeHtml(key)}:</span><span class="text-foreground break-all">${escapeHtml(value)}</span></div>`;
}
envHtml += '</div></div>';
} else {
envHtml = '<div class="mt-4"><h4 class="font-semibold text-sm text-foreground mb-2">' + t('mcp.env') + '</h4><p class="text-sm text-muted-foreground">' + t('mcp.detailsModal.noEnv') + '</p></div>';
}
modalBody.innerHTML = `
<div class="space-y-4">
<!-- Server Name and Source -->
<div>
<label class="text-xs font-semibold text-muted-foreground uppercase tracking-wide">${t('mcp.detailsModal.serverName')}</label>
<div class="mt-1 flex items-center gap-2">
<h3 class="text-xl font-bold text-foreground">${escapeHtml(serverName)}</h3>
${sourceBadge}
</div>
</div>
<!-- Configuration -->
<div>
<h4 class="font-semibold text-sm text-foreground mb-2">${t('mcp.detailsModal.configuration')}</h4>
<div class="space-y-2">
<!-- Command -->
<div class="flex items-start gap-3">
<span class="font-mono text-xs bg-muted px-2 py-1 rounded shrink-0">${t('mcp.cmd')}</span>
<code class="text-sm font-mono text-foreground break-all">${escapeHtml(serverConfig.command || serverConfig.url || 'N/A')}</code>
</div>
<!-- Arguments -->
${serverConfig.args && serverConfig.args.length > 0 ? `
<div class="flex items-start gap-3">
<span class="font-mono text-xs bg-muted px-2 py-1 rounded shrink-0">${t('mcp.args')}</span>
<div class="flex-1 space-y-1">
${serverConfig.args.map((arg, index) => `
<div class="text-sm font-mono text-foreground flex items-center gap-2">
<span class="text-muted-foreground">[${index}]</span>
<code class="break-all">${escapeHtml(arg)}</code>
</div>
`).join('')}
</div>
</div>
` : ''}
</div>
</div>
<!-- Environment Variables -->
${envHtml}
<!-- Raw JSON -->
<div>
<h4 class="font-semibold text-sm text-foreground mb-2">Raw JSON</h4>
<pre class="bg-muted rounded-lg p-3 text-xs font-mono overflow-x-auto">${escapeHtml(JSON.stringify(serverConfig, null, 2))}</pre>
</div>
</div>
`;
// Show modal
modal.classList.remove('hidden');
// Re-initialize Lucide icons in modal
if (typeof lucide !== 'undefined') lucide.createIcons();
}
// ========================================
@@ -788,15 +1211,15 @@ async function saveMcpAsTemplate(serverName, serverConfig) {
const data = await response.json();
if (data.success) {
showNotification(t('mcp.templateSaved', { name: templateName }), 'success');
showRefreshToast(t('mcp.templateSaved', { name: templateName }), 'success');
await loadMcpTemplates();
await renderMcpManager(); // Refresh view
} else {
showNotification(t('mcp.templateSaveFailed', { error: data.error }), 'error');
showRefreshToast(t('mcp.templateSaveFailed', { error: data.error }), 'error');
}
} catch (error) {
console.error('[MCP] Save template error:', error);
showNotification(t('mcp.templateSaveFailed', { error: error.message }), 'error');
showRefreshToast(t('mcp.templateSaveFailed', { error: error.message }), 'error');
}
}
@@ -808,7 +1231,7 @@ async function installFromTemplate(templateName, scope = 'project') {
// Find template
const template = mcpTemplates.find(t => t.name === templateName);
if (!template) {
showNotification(t('mcp.templateNotFound', { name: templateName }), 'error');
showRefreshToast(t('mcp.templateNotFound', { name: templateName }), 'error');
return;
}
@@ -823,11 +1246,11 @@ async function installFromTemplate(templateName, scope = 'project') {
await addGlobalMcpServer(serverName, template.serverConfig);
}
showNotification(t('mcp.templateInstalled', { name: serverName }), 'success');
showRefreshToast(t('mcp.templateInstalled', { name: serverName }), 'success');
await renderMcpManager();
} catch (error) {
console.error('[MCP] Install from template error:', error);
showNotification(t('mcp.templateInstallFailed', { error: error.message }), 'error');
showRefreshToast(t('mcp.templateInstallFailed', { error: error.message }), 'error');
}
}
@@ -843,14 +1266,14 @@ async function deleteMcpTemplate(templateName) {
const data = await response.json();
if (data.success) {
showNotification(t('mcp.templateDeleted', { name: templateName }), 'success');
showRefreshToast(t('mcp.templateDeleted', { name: templateName }), 'success');
await loadMcpTemplates();
await renderMcpManager();
} else {
showNotification(t('mcp.templateDeleteFailed', { error: data.error }), 'error');
showRefreshToast(t('mcp.templateDeleteFailed', { error: data.error }), 'error');
}
} catch (error) {
console.error('[MCP] Delete template error:', error);
showNotification(t('mcp.templateDeleteFailed', { error: error.message }), 'error');
showRefreshToast(t('mcp.templateDeleteFailed', { error: error.message }), 'error');
}
}

View File

@@ -1,10 +1,11 @@
/**
* CLI Configuration Manager
* Handles loading, saving, and managing CLI tool configurations
* Stores config in .workflow/cli-config.json
* Stores config in centralized storage (~/.ccw/projects/{id}/config/)
*/
import * as fs from 'fs';
import * as path from 'path';
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
// ========== Types ==========
@@ -50,20 +51,15 @@ export const DEFAULT_CONFIG: CliConfig = {
}
};
const CONFIG_DIR = '.workflow';
const CONFIG_FILE = 'cli-config.json';
// ========== Helper Functions ==========
function getConfigPath(baseDir: string): string {
return path.join(baseDir, CONFIG_DIR, CONFIG_FILE);
return StoragePaths.project(baseDir).cliConfig;
}
function ensureConfigDir(baseDir: string): void {
const configDir = path.join(baseDir, CONFIG_DIR);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
function ensureConfigDirForProject(baseDir: string): void {
const configDir = StoragePaths.project(baseDir).config;
ensureStorageDir(configDir);
}
function isValidToolName(tool: string): tool is CliToolName {
@@ -145,7 +141,7 @@ export function loadCliConfig(baseDir: string): CliConfig {
* Save CLI configuration to .workflow/cli-config.json
*/
export function saveCliConfig(baseDir: string, config: CliConfig): void {
ensureConfigDir(baseDir);
ensureConfigDirForProject(baseDir);
const configPath = getConfigPath(baseDir);
try {

View File

@@ -29,9 +29,6 @@ import {
getPrimaryModel
} from './cli-config-manager.js';
// CLI History storage path
const CLI_HISTORY_DIR = join(process.cwd(), '.workflow', '.cli-history');
// Lazy-loaded SQLite store module
let sqliteStoreModule: typeof import('./cli-history-store.js') | null = null;

View File

@@ -7,6 +7,7 @@ import Database from 'better-sqlite3';
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, rmdirSync } from 'fs';
import { join } from 'path';
import { parseSessionFile, formatConversation, extractConversationPairs, type ParsedSession, type ParsedTurn } from './session-content-parser.js';
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
// Types
export interface ConversationTurn {
@@ -97,12 +98,12 @@ export class CliHistoryStore {
private dbPath: string;
constructor(baseDir: string) {
const historyDir = join(baseDir, '.workflow', '.cli-history');
if (!existsSync(historyDir)) {
mkdirSync(historyDir, { recursive: true });
}
// Use centralized storage path
const paths = StoragePaths.project(baseDir);
const historyDir = paths.cliHistory;
ensureStorageDir(historyDir);
this.dbPath = join(historyDir, 'history.db');
this.dbPath = paths.historyDb;
this.db = new Database(this.dbPath);
this.db.pragma('journal_mode = WAL');
this.db.pragma('synchronous = NORMAL');

View File

@@ -1,6 +1,7 @@
import { resolve, join, relative, isAbsolute } from 'path';
import { existsSync, mkdirSync, realpathSync, statSync, readFileSync, writeFileSync } from 'fs';
import { homedir } from 'os';
import { StoragePaths, ensureStorageDir, LegacyPaths } from '../config/storage-paths.js';
/**
* Validation result for path operations
@@ -212,10 +213,24 @@ export function normalizePathForDisplay(filePath: string): string {
return filePath.replace(/\\/g, '/');
}
// Recent paths storage file
const RECENT_PATHS_FILE = join(homedir(), '.ccw-recent-paths.json');
// Recent paths storage - uses centralized storage with backward compatibility
const MAX_RECENT_PATHS = 10;
/**
* Get the recent paths file location
* Uses new location but falls back to legacy location for backward compatibility
*/
function getRecentPathsFile(): string {
const newPath = StoragePaths.global.recentPaths();
const legacyPath = LegacyPaths.recentPaths();
// Backward compatibility: use legacy if it exists and new doesn't
if (!existsSync(newPath) && existsSync(legacyPath)) {
return legacyPath;
}
return newPath;
}
/**
* Recent paths data structure
*/
@@ -229,8 +244,9 @@ interface RecentPathsData {
*/
export function getRecentPaths(): string[] {
try {
if (existsSync(RECENT_PATHS_FILE)) {
const content = readFileSync(RECENT_PATHS_FILE, 'utf8');
const recentPathsFile = getRecentPathsFile();
if (existsSync(recentPathsFile)) {
const content = readFileSync(recentPathsFile, 'utf8');
const data = JSON.parse(content) as RecentPathsData;
return Array.isArray(data.paths) ? data.paths : [];
}
@@ -258,8 +274,10 @@ export function trackRecentPath(projectPath: string): void {
// Limit to max
paths = paths.slice(0, MAX_RECENT_PATHS);
// Save
writeFileSync(RECENT_PATHS_FILE, JSON.stringify({ paths }, null, 2), 'utf8');
// Save to new centralized location
const recentPathsFile = StoragePaths.global.recentPaths();
ensureStorageDir(StoragePaths.global.config());
writeFileSync(recentPathsFile, JSON.stringify({ paths }, null, 2), 'utf8');
} catch {
// Ignore errors
}
@@ -270,9 +288,9 @@ export function trackRecentPath(projectPath: string): void {
*/
export function clearRecentPaths(): void {
try {
if (existsSync(RECENT_PATHS_FILE)) {
writeFileSync(RECENT_PATHS_FILE, JSON.stringify({ paths: [] }, null, 2), 'utf8');
}
const recentPathsFile = StoragePaths.global.recentPaths();
ensureStorageDir(StoragePaths.global.config());
writeFileSync(recentPathsFile, JSON.stringify({ paths: [] }, null, 2), 'utf8');
} catch {
// Ignore errors
}
@@ -293,8 +311,10 @@ export function removeRecentPath(pathToRemove: string): boolean {
paths = paths.filter(p => normalizePathForDisplay(p) !== normalized);
if (paths.length < originalLength) {
// Save updated list
writeFileSync(RECENT_PATHS_FILE, JSON.stringify({ paths }, null, 2), 'utf8');
// Save updated list to new centralized location
const recentPathsFile = StoragePaths.global.recentPaths();
ensureStorageDir(StoragePaths.global.config());
writeFileSync(recentPathsFile, JSON.stringify({ paths }, null, 2), 'utf8');
return true;
}
return false;