mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
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:
183
ccw/src/config/storage-paths.ts
Normal file
183
ccw/src/config/storage-paths.ts
Normal 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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>';
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
`);
|
||||
|
||||
@@ -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, "'")})"
|
||||
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, "'")})"
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user