feat: Implement CLAUDE.md Manager View with file tree, viewer, and metadata actions

- Added main JavaScript functionality for CLAUDE.md management including file loading, rendering, and editing capabilities.
- Created a test HTML file to validate the functionality of the CLAUDE.md manager.
- Introduced CLI generation examples and documentation for rules creation via CLI.
- Enhanced error handling and notifications for file operations.
This commit is contained in:
catlog22
2025-12-14 23:08:36 +08:00
parent 0529b57694
commit d91477ad80
30 changed files with 7961 additions and 298 deletions

22
.mcp.json Normal file
View File

@@ -0,0 +1,22 @@
{
"mcpServers": {
"test-mcp-server": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"D:/Claude_dms3"
]
},
"ccw-tools": {
"command": "npx",
"args": [
"-y",
"ccw-mcp"
],
"env": {
"CCW_ENABLED_TOOLS": "write_file,edit_file,codex_lens,smart_search"
}
}
}
}

190
IMPLEMENTATION_SUMMARY.md Normal file
View File

@@ -0,0 +1,190 @@
# Implementation Summary: Rules CLI Generation Feature
## Status: ✅ Complete
## Files Modified
### D:\Claude_dms3\ccw\src\core\routes\rules-routes.ts
**Changes:**
1. Added import for `executeCliTool` from cli-executor
2. Implemented `generateRuleViaCLI()` function
3. Modified POST `/api/rules/create` endpoint to support `mode: 'cli-generate'`
## Implementation Details
### 1. New Function: `generateRuleViaCLI()`
**Location:** lines 224-340
**Purpose:** Generate rule content using Gemini CLI based on different generation strategies
**Parameters:**
- `generationType`: 'description' | 'template' | 'extract'
- `description`: Natural language description of the rule
- `templateType`: Template category for structured generation
- `extractScope`: File pattern for code analysis (e.g., 'src/**/*.ts')
- `extractFocus`: Focus areas for extraction (e.g., 'error handling, naming')
- `fileName`: Target filename (must end with .md)
- `location`: 'project' or 'user'
- `subdirectory`: Optional subdirectory path
- `projectPath`: Project root directory
**Process Flow:**
1. Parse parameters and determine generation type
2. Build appropriate CLI prompt template based on type
3. Execute Gemini CLI with:
- Tool: 'gemini'
- Mode: 'write' for description/template, 'analysis' for extract
- Timeout: 10 minutes (600000ms)
- Working directory: projectPath
4. Validate CLI execution result
5. Extract generated content from stdout
6. Call `createRule()` to save the file
7. Return result with execution ID
### 2. Prompt Templates
#### Description Mode (write)
```
PURPOSE: Generate Claude Code memory rule from description to guide Claude's behavior
TASK: • Analyze rule requirements • Generate markdown content with clear instructions
MODE: write
EXPECTED: Complete rule content in markdown format
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt)
```
#### Template Mode (write)
```
PURPOSE: Generate Claude Code rule from template type
TASK: • Create rule based on {templateType} • Generate structured markdown content
MODE: write
EXPECTED: Complete rule content in markdown format following template structure
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt)
```
#### Extract Mode (analysis)
```
PURPOSE: Extract coding rules from existing codebase to document patterns and conventions
TASK: • Analyze code patterns • Extract common conventions • Identify best practices
MODE: analysis
CONTEXT: @{extractScope || '**/*'}
EXPECTED: Rule content based on codebase analysis with examples
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/analysis/02-analyze-code-patterns.txt)
```
### 3. API Endpoint Modification
**Endpoint:** POST `/api/rules/create`
**Enhanced Request Body:**
```json
{
"mode": "cli-generate", // NEW: triggers CLI generation
"generationType": "description", // NEW: 'description' | 'template' | 'extract'
"description": "...", // NEW: for description mode
"templateType": "...", // NEW: for template mode
"extractScope": "src/**/*.ts", // NEW: for extract mode
"extractFocus": "...", // NEW: for extract mode
"fileName": "rule-name.md", // REQUIRED
"location": "project", // REQUIRED: 'project' | 'user'
"subdirectory": "", // OPTIONAL
"projectPath": "..." // OPTIONAL: defaults to initialPath
}
```
**Backward Compatibility:** Existing manual creation still works:
```json
{
"fileName": "rule-name.md",
"content": "# Rule Content\n...",
"location": "project",
"paths": [],
"subdirectory": ""
}
```
**Response Format:**
```json
{
"success": true,
"fileName": "rule-name.md",
"location": "project",
"path": "/absolute/path/to/rule-name.md",
"subdirectory": null,
"generatedContent": "# Generated Content\n...",
"executionId": "1734168000000-gemini"
}
```
## Error Handling
### Validation Errors
- Missing `fileName`: "File name is required"
- Missing `location`: "Location is required (project or user)"
- Missing `generationType` in CLI mode: "generationType is required for CLI generation mode"
- Missing `description` for description mode: "description is required for description-based generation"
- Missing `templateType` for template mode: "templateType is required for template-based generation"
- Unknown `generationType`: "Unknown generation type: {type}"
### CLI Execution Errors
- CLI tool failure: Returns `{ error: "CLI execution failed: ...", stderr: "..." }`
- Empty content: Returns `{ error: "CLI execution returned empty content", stdout: "...", stderr: "..." }`
- Timeout: CLI executor will timeout after 10 minutes
- File exists: "Rule '{fileName}' already exists in {location} location"
## Testing
### Test Document
Created: `D:\Claude_dms3\test-rules-cli-generation.md`
Contains:
- API usage examples for all 3 generation types
- Request/response format examples
- Error handling scenarios
- Integration details
### Compilation Test
✅ TypeScript compilation successful (`npm run build`)
## Integration Points
### Dependencies
- **cli-executor.ts**: Provides `executeCliTool()` for Gemini execution
- **createRule()**: Existing function for file creation
- **handlePostRequest()**: Existing request handler from RouteContext
### CLI Tool
- **Tool**: Gemini (via `executeCliTool()`)
- **Timeout**: 10 minutes (600000ms)
- **Mode**: 'write' for generation, 'analysis' for extraction
- **Working Directory**: Project path for context access
## Next Steps (Not Implemented)
1. **UI Integration**: Add frontend interface in Rules Manager dashboard
2. **Streaming Output**: Display CLI execution progress in real-time
3. **Preview**: Show generated content before saving
4. **Refinement**: Allow iterative refinement of generated rules
5. **Templates Library**: Add predefined template types
6. **History**: Track generation history and allow regeneration
## Verification Checklist
- [x] Import cli-executor functions
- [x] Implement `generateRuleViaCLI()` with 3 generation types
- [x] Build appropriate prompts for each type
- [x] Use correct MODE (analysis vs write)
- [x] Set timeout to at least 10 minutes
- [x] Integrate with `createRule()` for file creation
- [x] Modify POST endpoint to support `mode: 'cli-generate'`
- [x] Validate required parameters
- [x] Return unified result format
- [x] Handle errors appropriately
- [x] Maintain backward compatibility
- [x] Verify TypeScript compilation
- [x] Create test documentation
## Files Created
- `D:\Claude_dms3\test-rules-cli-generation.md`: Test documentation
- `D:\Claude_dms3\IMPLEMENTATION_SUMMARY.md`: This file

202
MCP_OPTIMIZATION_SUMMARY.md Normal file
View File

@@ -0,0 +1,202 @@
# MCP 优化和模板功能实现总结
## 完成的功能
### 1. ✅ .mcp.json 支持优化
#### 后端改进(`mcp-routes.ts`
- **新增函数**
- `addMcpServerToMcpJson()` - 添加服务器到 .mcp.json
- `removeMcpServerFromMcpJson()` - 从 .mcp.json 删除服务器
- **优化的配置优先级**
```
1. Enterprise managed-mcp.json (最高优先级,不可覆盖)
2. .mcp.json (项目级,新默认) ← 优先级提升
3. ~/.claude.json projects[path].mcpServers (遗留支持)
4. ~/.claude.json mcpServers (用户全局)
```
- **智能安装逻辑**
- `addMcpServerToProject()` 默认写入 `.mcp.json`
- 仍然支持 `.claude.json`(向后兼容)
- `removeMcpServerFromProject()` 自动检测并从两处删除
- **元数据跟踪**
```json
{
"mcpJsonPath": "D:\\Claude_dms3\\.mcp.json",
"hasMcpJson": true
}
```
#### 前端 UI 改进(`mcp-manager.js`
- **配置来源指示器**
- 有 .mcp.json显示绿色 `file-check` 图标
- 无 .mcp.json显示提示 "Will use .mcp.json"
- **项目概览表增强**
- 每个项目旁显示 `.mcp.json` 状态图标
- 清晰区分配置来源
### 2. ✅ MCP 模板系统
#### 数据库模块(`mcp-templates-db.ts`
- **数据库位置**`~/.ccw/mcp-templates.db`
- **模板结构**
```typescript
interface McpTemplate {
id?: number;
name: string;
description?: string;
serverConfig: {
command: string;
args?: string[];
env?: Record<string, string>;
};
tags?: string[];
category?: string;
createdAt?: number;
updatedAt?: number;
}
```
- **功能**
- `saveTemplate()` - 保存/更新模板
- `getAllTemplates()` - 获取所有模板
- `getTemplateByName()` - 按名称查找
- `getTemplatesByCategory()` - 按分类查找
- `searchTemplates()` - 关键字搜索
- `deleteTemplate()` - 删除模板
#### API 端点
| 方法 | 路径 | 功能 |
|------|------|------|
| GET | `/api/mcp-templates` | 获取所有模板 |
| POST | `/api/mcp-templates` | 保存模板 |
| GET | `/api/mcp-templates/:name` | 获取单个模板 |
| DELETE | `/api/mcp-templates/:name` | 删除模板 |
| GET | `/api/mcp-templates/search?q=keyword` | 搜索模板 |
| GET | `/api/mcp-templates/categories` | 获取所有分类 |
| GET | `/api/mcp-templates/category/:name` | 按分类获取 |
| POST | `/api/mcp-templates/install` | 安装模板到项目/全局 |
### 3. ✅ Bug 修复
#### 删除服务器逻辑优化
- **问题**:无法正确删除来自 .mcp.json 的服务器
- **解决**
```typescript
// 现在会同时检查两个位置
removeMcpServerFromProject() {
// 尝试从 .mcp.json 删除
// 也尝试从 .claude.json 删除
// 返回详细的删除结果
}
```
## 测试验证
### 1. .mcp.json 识别测试
```bash
$ curl http://localhost:3456/api/mcp-config | jq
```
✅ 成功识别 `D:\Claude_dms3\.mcp.json`
✅ 正确加载服务器配置:
- test-mcp-server
- ccw-tools (含环境变量)
### 2. 创建的测试文件
- `D:\Claude_dms3\.mcp.json` - 测试配置文件
## 待实现功能
### 前端 UI下一步
- [ ] 模板管理界面
- 模板列表视图
- 创建/编辑模板表单
- 模板预览
- 从现有服务器保存为模板
- 从模板快速安装
### CCW Tools 安装增强
- [ ] 全局安装选项
- 添加到 ~/.claude.json
- 所有项目可用
- [ ] 项目安装选项(当前默认)
- 写入 .mcp.json
- 仅当前项目可用
## 使用示例
### 保存当前服务器为模板
```javascript
// POST /api/mcp-templates
{
"name": "filesystem-server",
"description": "MCP Filesystem server for local files",
"serverConfig": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/files"]
},
"category": "官方",
"tags": ["filesystem", "mcp", "official"]
}
```
### 安装模板到项目
```javascript
// POST /api/mcp-templates/install
{
"templateName": "filesystem-server",
"projectPath": "D:/Claude_dms3",
"scope": "project" // 或 "global"
}
```
## 架构优势
### 1. 清晰的配置层次
- **企业级** → 组织统一管理
- **.mcp.json** → 项目团队共享(可提交 git
- **.claude.json** → 用户个人配置(不提交)
### 2. 向后兼容
- 遗留 `.claude.json` 配置仍然有效
- 平滑迁移路径
### 3. 模板复用
- 常用配置保存为模板
- 跨项目快速部署
- 团队共享最佳实践
## 文件修改清单
### 新增文件
1. `ccw/src/core/mcp-templates-db.ts` - 模板数据库模块
### 修改文件
1. `ccw/src/core/routes/mcp-routes.ts`
- 添加 .mcp.json 读写函数
- 优化配置优先级
- 添加模板 API 路由
- 修复删除逻辑
2. `ccw/src/templates/dashboard-js/views/mcp-manager.js`
- 添加 .mcp.json 状态显示
- 项目概览表增强
### 测试文件
1. `D:\Claude_dms3\.mcp.json` - 测试配置
## 下一步计划
1. **完成前端模板管理 UI**
2. **实现 CCW Tools 全局/项目安装切换**
3. **添加预设模板库**(官方 MCP 服务器)
4. **模板导入/导出功能**
---
生成时间2025-12-14
Claude Code Workflow v6.1.4

View File

@@ -0,0 +1,269 @@
// @ts-nocheck
/**
* MCP Templates Database Module
* Stores MCP server configurations as reusable templates
*/
import Database from 'better-sqlite3';
import { existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
// Database path
const DB_DIR = join(homedir(), '.ccw');
const DB_PATH = join(DB_DIR, 'mcp-templates.db');
// Ensure database directory exists
if (!existsSync(DB_DIR)) {
mkdirSync(DB_DIR, { recursive: true });
}
// Initialize database connection
let db: Database.Database | null = null;
/**
* Get or create database connection
*/
function getDb(): Database.Database {
if (!db) {
db = new Database(DB_PATH);
initDatabase();
}
return db;
}
/**
* Initialize database schema
*/
function initDatabase() {
const db = getDb();
// Create templates table
db.exec(`
CREATE TABLE IF NOT EXISTS mcp_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
server_config TEXT NOT NULL,
tags TEXT,
category TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Create index on name for fast lookups
db.exec(`
CREATE INDEX IF NOT EXISTS idx_mcp_templates_name
ON mcp_templates(name)
`);
// Create index on category for filtering
db.exec(`
CREATE INDEX IF NOT EXISTS idx_mcp_templates_category
ON mcp_templates(category)
`);
}
export interface McpTemplate {
id?: number;
name: string;
description?: string;
serverConfig: {
command: string;
args?: string[];
env?: Record<string, string>;
};
tags?: string[];
category?: string;
createdAt?: number;
updatedAt?: number;
}
/**
* Save MCP template to database
*/
export function saveTemplate(template: McpTemplate): { success: boolean; id?: number; error?: string } {
try {
const db = getDb();
const now = Date.now();
const stmt = db.prepare(`
INSERT INTO mcp_templates (name, description, server_config, tags, category, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
description = excluded.description,
server_config = excluded.server_config,
tags = excluded.tags,
category = excluded.category,
updated_at = excluded.updated_at
`);
const result = stmt.run(
template.name,
template.description || null,
JSON.stringify(template.serverConfig),
template.tags ? JSON.stringify(template.tags) : null,
template.category || null,
template.createdAt || now,
now
);
return {
success: true,
id: result.lastInsertRowid as number
};
} catch (error: unknown) {
console.error('Error saving MCP template:', error);
return {
success: false,
error: (error as Error).message
};
}
}
/**
* Get all MCP templates
*/
export function getAllTemplates(): McpTemplate[] {
try {
const db = getDb();
const rows = db.prepare('SELECT * FROM mcp_templates ORDER BY name').all();
return rows.map((row: any) => ({
id: row.id,
name: row.name,
description: row.description,
serverConfig: JSON.parse(row.server_config),
tags: row.tags ? JSON.parse(row.tags) : [],
category: row.category,
createdAt: row.created_at,
updatedAt: row.updated_at
}));
} catch (error: unknown) {
console.error('Error getting MCP templates:', error);
return [];
}
}
/**
* Get template by name
*/
export function getTemplateByName(name: string): McpTemplate | null {
try {
const db = getDb();
const row = db.prepare('SELECT * FROM mcp_templates WHERE name = ?').get(name);
if (!row) return null;
return {
id: (row as any).id,
name: (row as any).name,
description: (row as any).description,
serverConfig: JSON.parse((row as any).server_config),
tags: (row as any).tags ? JSON.parse((row as any).tags) : [],
category: (row as any).category,
createdAt: (row as any).created_at,
updatedAt: (row as any).updated_at
};
} catch (error: unknown) {
console.error('Error getting MCP template:', error);
return null;
}
}
/**
* Get templates by category
*/
export function getTemplatesByCategory(category: string): McpTemplate[] {
try {
const db = getDb();
const rows = db.prepare('SELECT * FROM mcp_templates WHERE category = ? ORDER BY name').all(category);
return rows.map((row: any) => ({
id: row.id,
name: row.name,
description: row.description,
serverConfig: JSON.parse(row.server_config),
tags: row.tags ? JSON.parse(row.tags) : [],
category: row.category,
createdAt: row.created_at,
updatedAt: row.updated_at
}));
} catch (error: unknown) {
console.error('Error getting MCP templates by category:', error);
return [];
}
}
/**
* Delete template by name
*/
export function deleteTemplate(name: string): { success: boolean; error?: string } {
try {
const db = getDb();
const result = db.prepare('DELETE FROM mcp_templates WHERE name = ?').run(name);
return {
success: result.changes > 0
};
} catch (error: unknown) {
console.error('Error deleting MCP template:', error);
return {
success: false,
error: (error as Error).message
};
}
}
/**
* Search templates by keyword
*/
export function searchTemplates(keyword: string): McpTemplate[] {
try {
const db = getDb();
const searchPattern = `%${keyword}%`;
const rows = db.prepare(`
SELECT * FROM mcp_templates
WHERE name LIKE ? OR description LIKE ? OR tags LIKE ?
ORDER BY name
`).all(searchPattern, searchPattern, searchPattern);
return rows.map((row: any) => ({
id: row.id,
name: row.name,
description: row.description,
serverConfig: JSON.parse(row.server_config),
tags: row.tags ? JSON.parse(row.tags) : [],
category: row.category,
createdAt: row.created_at,
updatedAt: row.updated_at
}));
} catch (error: unknown) {
console.error('Error searching MCP templates:', error);
return [];
}
}
/**
* Get all categories
*/
export function getAllCategories(): string[] {
try {
const db = getDb();
const rows = db.prepare('SELECT DISTINCT category FROM mcp_templates WHERE category IS NOT NULL ORDER BY category').all();
return rows.map((row: any) => row.category);
} catch (error: unknown) {
console.error('Error getting categories:', error);
return [];
}
}
/**
* Close database connection
*/
export function closeDb() {
if (db) {
db.close();
db = null;
}
}

View File

@@ -0,0 +1,804 @@
// @ts-nocheck
/**
* CLAUDE.md Routes Module
* Handles all CLAUDE.md memory rules management endpoints
*/
import type { IncomingMessage, ServerResponse } from 'http';
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
import { join, relative } from 'path';
import { homedir } from 'os';
export interface RouteContext {
pathname: string;
url: URL;
req: IncomingMessage;
res: ServerResponse;
initialPath: string;
handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise<any>) => void;
broadcastToClients: (data: unknown) => void;
}
interface ClaudeFile {
id: string;
level: 'user' | 'project' | 'module';
path: string;
relativePath: string;
name: string;
content?: string;
size: number;
lastModified: string;
frontmatter?: { paths?: string[] };
stats?: { lines: number; words: number; characters: number };
isMainFile: boolean;
parentDirectory?: string;
depth?: number;
}
interface ClaudeFilesHierarchy {
user: { main: ClaudeFile | null };
project: { main: ClaudeFile | null };
modules: ClaudeFile[];
summary: { totalFiles: number; totalSize: number; lastSync?: string };
}
/**
* Parse frontmatter from markdown file
* Reuses logic from rules-routes.ts
*/
function parseClaudeFrontmatter(content: string) {
const result = {
paths: [] as string[],
content: content
};
if (content.startsWith('---')) {
const endIndex = content.indexOf('---', 3);
if (endIndex > 0) {
const frontmatter = content.substring(3, endIndex).trim();
result.content = content.substring(endIndex + 3).trim();
const lines = frontmatter.split('\n');
for (const line of lines) {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.substring(0, colonIndex).trim().toLowerCase();
const value = line.substring(colonIndex + 1).trim();
if (key === 'paths') {
result.paths = value.replace(/^\[|\]$/g, '').split(',').map(t => t.trim()).filter(Boolean);
}
}
}
}
}
return result;
}
/**
* Calculate file statistics
*/
function calculateFileStats(content: string) {
const lines = content.split('\n').length;
const words = content.split(/\s+/).filter(w => w.length > 0).length;
const characters = content.length;
return { lines, words, characters };
}
/**
* Create ClaudeFile object from file path
*/
function createClaudeFile(
filePath: string,
level: 'user' | 'project' | 'module',
basePath: string,
isMainFile: boolean,
depth?: number
): ClaudeFile | null {
try {
if (!existsSync(filePath)) return null;
const stat = statSync(filePath);
const content = readFileSync(filePath, 'utf8');
const parsed = parseClaudeFrontmatter(content);
const relativePath = relative(basePath, filePath).replace(/\\/g, '/');
const fileName = filePath.split(/[\\/]/).pop() || 'CLAUDE.md';
// Parent directory for module-level files
const parentDir = level === 'module'
? filePath.split(/[\\/]/).slice(-2, -1)[0]
: undefined;
return {
id: `${level}-${relativePath}`,
level,
path: filePath,
relativePath,
name: fileName,
content: parsed.content,
size: stat.size,
lastModified: stat.mtime.toISOString(),
frontmatter: { paths: parsed.paths },
stats: calculateFileStats(content),
isMainFile,
parentDirectory: parentDir,
depth
};
} catch (e) {
console.error(`Error creating ClaudeFile for ${filePath}:`, e);
return null;
}
}
/**
* Scan rules directory (recursive)
* Adapted from rules-routes.ts::scanRulesDirectory
*/
function scanClaudeRulesDirectory(dirPath: string, level: 'user' | 'project', basePath: string): ClaudeFile[] {
const files: ClaudeFile[] = [];
try {
const entries = readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dirPath, entry.name);
if (entry.isFile() && entry.name.endsWith('.md')) {
const file = createClaudeFile(fullPath, level, basePath, false);
if (file) files.push(file);
} else if (entry.isDirectory()) {
const subFiles = scanClaudeRulesDirectory(fullPath, level, basePath);
files.push(...subFiles);
}
}
} catch (e) {
// Ignore errors
}
return files;
}
/**
* Scan modules for CLAUDE.md files
* Uses get-modules-by-depth logic
*/
function scanModules(projectPath: string): ClaudeFile[] {
const modules: ClaudeFile[] = [];
const visited = new Set<string>();
// Directories to exclude (from get-modules-by-depth.ts)
const SYSTEM_EXCLUDES = [
'.git', '.svn', '.hg', '__pycache__', 'node_modules', '.npm', '.yarn',
'dist', 'build', 'out', '.cache', '.venv', 'venv', 'env', 'coverage'
];
function scanDirectory(dirPath: string, depth: number) {
try {
const entries = readdirSync(dirPath, { withFileTypes: true });
// Check for CLAUDE.md in current directory
const claudePath = join(dirPath, 'CLAUDE.md');
if (existsSync(claudePath) && !visited.has(claudePath)) {
visited.add(claudePath);
const file = createClaudeFile(claudePath, 'module', projectPath, true, depth);
if (file) modules.push(file);
}
// Recurse into subdirectories
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (SYSTEM_EXCLUDES.includes(entry.name)) continue;
const fullPath = join(dirPath, entry.name);
scanDirectory(fullPath, depth + 1);
}
} catch (e) {
// Ignore permission errors
}
}
scanDirectory(projectPath, 0);
return modules.sort((a, b) => (b.depth || 0) - (a.depth || 0)); // Deepest first
}
/**
* Scan all CLAUDE.md files
*/
function scanAllClaudeFiles(projectPath: string): ClaudeFilesHierarchy {
const result: ClaudeFilesHierarchy = {
user: { main: null },
project: { main: null },
modules: [],
summary: { totalFiles: 0, totalSize: 0 }
};
// User-level files (only main CLAUDE.md, no rules)
const userHome = homedir();
const userClaudeDir = join(userHome, '.claude');
const userClaudePath = join(userClaudeDir, 'CLAUDE.md');
if (existsSync(userClaudePath)) {
result.user.main = createClaudeFile(userClaudePath, 'user', userHome, true);
}
// Project-level files (only main CLAUDE.md, no rules)
const projectClaudeDir = join(projectPath, '.claude');
const projectClaudePath = join(projectClaudeDir, 'CLAUDE.md');
if (existsSync(projectClaudePath)) {
result.project.main = createClaudeFile(projectClaudePath, 'project', projectPath, true);
}
// Module-level files
result.modules = scanModules(projectPath);
// Calculate summary (only main CLAUDE.md files, no rules)
const allFiles = [
result.user.main,
result.project.main,
...result.modules
].filter(f => f !== null) as ClaudeFile[];
result.summary = {
totalFiles: allFiles.length,
totalSize: allFiles.reduce((sum, f) => sum + f.size, 0),
lastSync: new Date().toISOString()
};
return result;
}
/**
* Get single file content
*/
function getClaudeFile(filePath: string): ClaudeFile | null {
try {
if (!existsSync(filePath)) {
return null;
}
const stat = statSync(filePath);
const content = readFileSync(filePath, 'utf8');
const parsed = parseClaudeFrontmatter(content);
// Determine level based on path
let level: 'user' | 'project' | 'module' = 'module';
if (filePath.includes(join(homedir(), '.claude'))) {
level = 'user';
} else if (filePath.includes('.claude')) {
level = 'project';
}
const isMainFile = filePath.endsWith('CLAUDE.md') && !filePath.includes('rules');
return {
id: `${level}-${filePath}`,
level,
path: filePath,
relativePath: filePath,
name: filePath.split(/[\\/]/).pop() || 'CLAUDE.md',
content: parsed.content,
size: stat.size,
lastModified: stat.mtime.toISOString(),
frontmatter: { paths: parsed.paths },
stats: calculateFileStats(content),
isMainFile
};
} catch (error) {
console.error('Error reading CLAUDE.md file:', error);
return null;
}
}
/**
* Save file content
*/
function saveClaudeFile(filePath: string, content: string, createBackup: boolean = false): { success: boolean; error?: string } {
try {
if (!existsSync(filePath)) {
return { success: false, error: 'File not found' };
}
// Create backup if requested
if (createBackup) {
const backupPath = `${filePath}.backup-${Date.now()}`;
const originalContent = readFileSync(filePath, 'utf8');
writeFileSync(backupPath, originalContent, 'utf8');
}
// Write new content
writeFileSync(filePath, content, 'utf8');
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Generate CLI prompt for syncing CLAUDE.md files
*/
function generateSyncPrompt(level: 'user' | 'project' | 'module', modulePath?: string): string {
if (level === 'module' && modulePath) {
// Module-level prompt
return `PURPOSE: Generate module-level CLAUDE.md for ${modulePath}
TASK: • Analyze module's purpose and responsibilities • Document public APIs and interfaces • Identify dependencies and integration points • Note testing patterns and conventions
MODE: analysis
CONTEXT: @${modulePath}/**/* | Memory: Project conventions from .claude/CLAUDE.md
EXPECTED: Module-level CLAUDE.md with: - Module purpose (1-2 sentences) - Key files and their roles - Public API documentation - Integration points - Testing approach
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/planning/02-design-component-spec.txt) | Module-level perspective only | Concrete examples | analysis=READ-ONLY`;
} else {
// User/Project level prompt
const contextPath = level === 'user' ? '~/.claude' : '.claude';
return `PURPOSE: Update CLAUDE.md with current ${level} understanding
TASK: • Analyze ${level} configuration and conventions • Identify common patterns and anti-patterns • Generate concise, actionable rules • Maintain existing structure and formatting
MODE: analysis
CONTEXT: @${contextPath}/**/*
EXPECTED: Updated CLAUDE.md content with: - Preserved existing sections - New insights appended to relevant sections - Timestamp header - Focus on ${level}-level concerns
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/analysis/02-analyze-code-patterns.txt) | Maintain existing CLAUDE.md structure | Focus on actionable rules | analysis=READ-ONLY`;
}
}
/**
* Smart merge CLAUDE.md content (update mode)
*/
function smartMergeContent(existingContent: string, cliOutput: string): string {
// For now, use simple append strategy
// TODO: Implement intelligent section-based merging
const timestamp = new Date().toISOString();
const separator = '\n\n---\n\n';
const header = `## Updated: ${timestamp}\n\n`;
return existingContent + separator + header + cliOutput;
}
/**
* Scan all files in project directory
*/
function scanAllProjectFiles(projectPath: string): any {
const SYSTEM_EXCLUDES = [
'.git', '.svn', '.hg', '__pycache__', 'node_modules', '.npm', '.yarn',
'dist', 'build', 'out', '.cache', '.venv', 'venv', 'env', 'coverage',
'.next', '.nuxt', '.output', '.turbo', '.parcel-cache', 'logs', 'tmp', 'temp'
];
const results: any = {
files: [],
summary: { totalFiles: 0, totalDirectories: 0, totalSize: 0 }
};
function scanDir(dirPath: string, depth: number = 0): any[] {
if (depth > 10) return []; // Max depth limit
const files: any[] = [];
try {
const entries = readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
// Skip system excludes and hidden files (except .claude and .workflow)
if (SYSTEM_EXCLUDES.includes(entry.name)) continue;
if (entry.name.startsWith('.') && entry.name !== '.claude' && entry.name !== '.workflow') continue;
const fullPath = join(dirPath, entry.name);
const relativePath = relative(projectPath, fullPath).replace(/\\/g, '/');
if (entry.isDirectory()) {
results.summary.totalDirectories++;
const dirNode: any = {
path: fullPath,
name: entry.name,
type: 'directory',
depth,
children: scanDir(fullPath, depth + 1)
};
files.push(dirNode);
} else {
const stat = statSync(fullPath);
results.summary.totalFiles++;
results.summary.totalSize += stat.size;
files.push({
path: fullPath,
name: entry.name,
type: 'file',
size: stat.size,
lastModified: stat.mtime.toISOString(),
depth
});
}
}
} catch (e) {
// Ignore permission errors
}
return files;
}
results.files = scanDir(projectPath);
return results;
}
/**
* Read single file content
*/
function readSingleFile(filePath: string): { content: string; size: number; lastModified: string } | null {
try {
if (!existsSync(filePath)) return null;
const stat = statSync(filePath);
const content = readFileSync(filePath, 'utf8');
return {
content,
size: stat.size,
lastModified: stat.mtime.toISOString()
};
} catch (e) {
return null;
}
}
/**
* Delete CLAUDE.md file
*/
function deleteClaudeFile(filePath: string): { success: boolean; error?: string } {
try {
if (!existsSync(filePath)) {
return { success: false, error: 'File not found' };
}
// Create backup before deletion
const backupPath = `${filePath}.deleted-${Date.now()}`;
const content = readFileSync(filePath, 'utf8');
writeFileSync(backupPath, content, 'utf8');
// Delete original file
const fs = require('fs');
fs.unlinkSync(filePath);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Create new CLAUDE.md file with template
*/
function createNewClaudeFile(level: 'user' | 'project' | 'module', template: string, pathParam?: string): { success: boolean; path?: string; error?: string } {
try {
let filePath: string;
let content: string;
// Determine file path
if (level === 'user') {
filePath = join(homedir(), '.claude', 'CLAUDE.md');
} else if (level === 'project' && pathParam) {
filePath = join(pathParam, '.claude', 'CLAUDE.md');
} else if (level === 'module' && pathParam) {
filePath = join(pathParam, 'CLAUDE.md');
} else {
return { success: false, error: 'Invalid parameters' };
}
// Check if file already exists
if (existsSync(filePath)) {
return { success: false, error: 'File already exists' };
}
// Generate content based on template
const timestamp = new Date().toISOString();
if (template === 'minimal') {
content = `# CLAUDE.md (${level.toUpperCase()} Level)\n\n> Created: ${timestamp}\n\n## Purpose\n\n[Describe the purpose of this ${level}-level context]\n\n## Guidelines\n\n- [Add guideline 1]\n- [Add guideline 2]\n`;
} else if (template === 'comprehensive') {
content = `# CLAUDE.md (${level.toUpperCase()} Level)\n\n> Created: ${timestamp}\n\n## Purpose\n\n[Describe the purpose and scope]\n\n## Architecture\n\n[Describe key architectural decisions]\n\n## Coding Conventions\n\n### Naming\n\n- [Convention 1]\n- [Convention 2]\n\n### Patterns\n\n- [Pattern 1]\n- [Pattern 2]\n\n## Testing Guidelines\n\n[Testing approach and conventions]\n\n## Dependencies\n\n[Key dependencies and integration points]\n\n## Common Tasks\n\n### Task 1\n\n[Steps for task 1]\n\n### Task 2\n\n[Steps for task 2]\n`;
} else {
// default template
content = `# CLAUDE.md (${level.toUpperCase()} Level)\n\n> Created: ${timestamp}\n\n## Overview\n\n[Brief description of this ${level}-level context]\n\n## Key Conventions\n\n- [Convention 1]\n- [Convention 2]\n- [Convention 3]\n\n## Guidelines\n\n### Code Style\n\n[Style guidelines]\n\n### Best Practices\n\n[Best practices]\n`;
}
// Ensure directory exists
const dir = filePath.substring(0, filePath.lastIndexOf('/') || filePath.lastIndexOf('\\'));
const fs = require('fs');
if (!existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Write file
writeFileSync(filePath, content, 'utf8');
return { success: true, path: filePath };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Handle CLAUDE.md routes
*/
export async function handleClaudeRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
// API: Scan all CLAUDE.md files
if (pathname === '/api/memory/claude/scan') {
const projectPathParam = url.searchParams.get('path') || initialPath;
const filesData = scanAllClaudeFiles(projectPathParam);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(filesData));
return true;
}
// API: Scan all project files (not just CLAUDE.md)
if (pathname === '/api/memory/claude/scan-all') {
const projectPathParam = url.searchParams.get('path') || initialPath;
const filesData = scanAllProjectFiles(projectPathParam);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(filesData));
return true;
}
// API: Read single file
if (pathname === '/api/memory/claude/read-file' && req.method === 'GET') {
const filePath = url.searchParams.get('path');
if (!filePath) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing path parameter' }));
return true;
}
const fileData = readSingleFile(filePath);
if (!fileData) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'File not found' }));
return true;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(fileData));
return true;
}
// API: CLI Sync (analyze and update CLAUDE.md using CLI tools)
if (pathname === '/api/memory/claude/sync' && req.method === 'POST') {
handlePostRequest(req, res, async (body: any) => {
const { level, path: modulePath, tool = 'gemini', mode = 'update', targets } = body;
if (!level) {
return { error: 'Missing level parameter', status: 400 };
}
try {
// Import CLI executor
const { executeCliTool } = await import('../../tools/cli-executor.js');
// Determine file path based on level
let filePath: string;
let workingDir: string;
if (level === 'user') {
filePath = join(homedir(), '.claude', 'CLAUDE.md');
workingDir = join(homedir(), '.claude');
} else if (level === 'project') {
filePath = join(initialPath, '.claude', 'CLAUDE.md');
workingDir = join(initialPath, '.claude');
} else if (level === 'module' && modulePath) {
filePath = join(modulePath, 'CLAUDE.md');
workingDir = modulePath;
} else {
return { error: 'Invalid level or missing path for module level', status: 400 };
}
// Check if file exists (for update/append modes)
const fileExists = existsSync(filePath);
if (!fileExists && mode !== 'generate') {
return { error: 'File does not exist. Use generate mode to create it.', status: 404 };
}
// Read existing content
const existingContent = fileExists ? readFileSync(filePath, 'utf8') : '';
// Generate CLI prompt
const cliPrompt = generateSyncPrompt(level, modulePath);
// Execute CLI tool
const syncId = `claude-sync-${level}-${Date.now()}`;
const result = await executeCliTool({
tool: tool === 'qwen' ? 'qwen' : 'gemini',
prompt: cliPrompt,
mode: 'analysis',
format: 'plain',
cd: workingDir,
timeout: 600000, // 10 minutes
stream: false,
category: 'internal',
id: syncId
});
if (!result.success || !result.execution?.output) {
return {
error: 'CLI execution failed',
details: result.execution?.error || 'No output received',
status: 500
};
}
// Extract CLI output
const cliOutput = typeof result.execution.output === 'string'
? result.execution.output
: result.execution.output.stdout || '';
if (!cliOutput || cliOutput.trim().length === 0) {
return { error: 'CLI returned empty output', status: 500 };
}
// Process content based on mode
let finalContent: string;
if (mode === 'generate') {
// Full replace
const timestamp = new Date().toISOString();
finalContent = `# CLAUDE.md (${level.toUpperCase()} Level)\n\n> Auto-generated using ${tool.toUpperCase()}\n> Last updated: ${timestamp}\n\n---\n\n${cliOutput}`;
} else if (mode === 'append') {
// Simple append
const timestamp = new Date().toISOString();
finalContent = existingContent + `\n\n---\n\n## Updated: ${timestamp}\n\n${cliOutput}`;
} else {
// Smart merge (update mode)
finalContent = smartMergeContent(existingContent, cliOutput);
}
// Write updated content
writeFileSync(filePath, finalContent, 'utf8');
// Broadcast WebSocket event
broadcastToClients({
type: 'CLAUDE_FILE_SYNCED',
payload: {
path: filePath,
level,
tool,
mode,
executionId: syncId,
timestamp: new Date().toISOString()
}
});
return {
success: true,
path: filePath,
executionId: syncId,
mode,
tool
};
} catch (error) {
console.error('Error syncing CLAUDE.md file:', error);
return {
error: 'Sync failed',
details: (error as Error).message,
status: 500
};
}
});
return true;
}
// API: Get single file
if (pathname === '/api/memory/claude/file' && req.method === 'GET') {
const filePath = url.searchParams.get('path');
if (!filePath) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing path parameter' }));
return true;
}
const file = getClaudeFile(filePath);
if (!file) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'File not found' }));
return true;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(file));
return true;
}
// API: Save file
if (pathname === '/api/memory/claude/file' && req.method === 'POST') {
handlePostRequest(req, res, async (body: any) => {
const { path: filePath, content, createBackup } = body;
if (!filePath || content === undefined) {
return { error: 'Missing path or content parameter', status: 400 };
}
const result = saveClaudeFile(filePath, content, createBackup);
if (result.success) {
// Broadcast update to all clients
ctx.broadcastToClients({
type: 'CLAUDE_FILE_UPDATED',
data: { path: filePath }
});
return { success: true, path: filePath };
} else {
return { error: result.error, status: 500 };
}
});
return true;
}
// API: Delete file
if (pathname === '/api/memory/claude/file' && req.method === 'DELETE') {
const filePath = url.searchParams.get('path');
const confirm = url.searchParams.get('confirm');
if (!filePath) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing path parameter' }));
return true;
}
if (confirm !== 'true') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Confirmation required' }));
return true;
}
const result = deleteClaudeFile(filePath);
if (result.success) {
broadcastToClients({
type: 'CLAUDE_FILE_DELETED',
data: { path: filePath }
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
} else {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: result.error }));
}
return true;
}
// API: Create file
if (pathname === '/api/memory/claude/create' && req.method === 'POST') {
handlePostRequest(req, res, async (body: any) => {
const { level, path, template = 'default' } = body;
if (!level) {
return { error: 'Missing level parameter', status: 400 };
}
let result: any;
if (level === 'project') {
// For project level, use initialPath
const filePath = join(initialPath, '.claude', 'CLAUDE.md');
result = createNewClaudeFile(level, template, initialPath);
} else if (level === 'module') {
if (!path) {
return { error: 'Module path required', status: 400 };
}
result = createNewClaudeFile(level, template, path);
} else {
result = createNewClaudeFile(level, template);
}
if (result.success) {
broadcastToClients({
type: 'CLAUDE_FILE_CREATED',
data: { path: result.path, level }
});
return { success: true, path: result.path };
} else {
return { error: result.error, status: 500 };
}
});
return true;
}
return false;
}

View File

@@ -9,7 +9,8 @@ import {
bootstrapVenv,
executeCodexLens,
checkSemanticStatus,
installSemantic
installSemantic,
uninstallCodexLens
} from '../../tools/codex-lens.js';
export interface RouteContext {
@@ -44,6 +45,11 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
const result = await bootstrapVenv();
if (result.success) {
const status = await checkVenvStatus();
// Broadcast installation event
broadcastToClients({
type: 'CODEXLENS_INSTALLED',
payload: { version: status.version, timestamp: new Date().toISOString() }
});
return { success: true, message: 'CodexLens installed successfully', version: status.version };
} else {
return { success: false, error: result.error, status: 500 };
@@ -55,6 +61,103 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
return true;
}
// API: CodexLens Uninstall
if (pathname === '/api/codexlens/uninstall' && req.method === 'POST') {
handlePostRequest(req, res, async () => {
try {
const result = await uninstallCodexLens();
if (result.success) {
// Broadcast uninstallation event
broadcastToClients({
type: 'CODEXLENS_UNINSTALLED',
payload: { timestamp: new Date().toISOString() }
});
return { success: true, message: 'CodexLens uninstalled successfully' };
} else {
return { success: false, error: result.error, status: 500 };
}
} catch (err) {
return { success: false, error: err.message, status: 500 };
}
});
return true;
}
// API: CodexLens Config - GET (Get current configuration)
if (pathname === '/api/codexlens/config' && req.method === 'GET') {
try {
const result = await executeCodexLens(['config-show', '--json']);
if (result.success) {
try {
const config = JSON.parse(result.output);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(config));
} catch {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ index_dir: '~/.codexlens/indexes', index_count: 0 }));
}
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ index_dir: '~/.codexlens/indexes', index_count: 0 }));
}
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
return true;
}
// API: CodexLens Config - POST (Set configuration)
if (pathname === '/api/codexlens/config' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { index_dir } = body;
if (!index_dir) {
return { success: false, error: 'index_dir is required', status: 400 };
}
try {
const result = await executeCodexLens(['config-set', '--key', 'index_dir', '--value', index_dir, '--json']);
if (result.success) {
return { success: true, message: 'Configuration updated successfully' };
} else {
return { success: false, error: result.error || 'Failed to update configuration', status: 500 };
}
} catch (err) {
return { success: false, error: err.message, status: 500 };
}
});
return true;
}
// API: CodexLens Clean (Clean indexes)
if (pathname === '/api/codexlens/clean' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { all = false, path } = body;
try {
const args = ['clean'];
if (all) {
args.push('--all');
}
if (path) {
args.push('--path', path);
}
args.push('--json');
const result = await executeCodexLens(args);
if (result.success) {
return { success: true, message: 'Indexes cleaned successfully' };
} else {
return { success: false, error: result.error || 'Failed to clean indexes', status: 500 };
}
} catch (err) {
return { success: false, error: err.message, status: 500 };
}
});
return true;
}
// API: CodexLens Init (Initialize workspace index)
if (pathname === '/api/codexlens/init' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {

View File

@@ -4,13 +4,17 @@
* Handles all MCP-related API endpoints
*/
import type { IncomingMessage, ServerResponse } from 'http';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
import * as McpTemplatesDb from './mcp-templates-db.js';
// Claude config file path
const CLAUDE_CONFIG_PATH = join(homedir(), '.claude.json');
// Workspace root path for scanning .mcp.json files
let WORKSPACE_ROOT = process.cwd();
export interface RouteContext {
pathname: string;
url: URL;
@@ -64,6 +68,83 @@ function getMcpServersFromFile(filePath) {
return config.mcpServers || {};
}
/**
* Add or update MCP server in project's .mcp.json file
* @param {string} projectPath - Project directory path
* @param {string} serverName - MCP server name
* @param {Object} serverConfig - MCP server configuration
* @returns {Object} Result with success/error
*/
function addMcpServerToMcpJson(projectPath, serverName, serverConfig) {
try {
const normalizedPath = normalizeProjectPathForConfig(projectPath);
const mcpJsonPath = join(normalizedPath, '.mcp.json');
// Read existing .mcp.json or create new structure
let mcpJson = safeReadJson(mcpJsonPath) || { mcpServers: {} };
// Ensure mcpServers exists
if (!mcpJson.mcpServers) {
mcpJson.mcpServers = {};
}
// Add or update the server
mcpJson.mcpServers[serverName] = serverConfig;
// Write back to .mcp.json
writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2), 'utf8');
return {
success: true,
serverName,
serverConfig,
scope: 'project-mcp-json',
path: mcpJsonPath
};
} catch (error: unknown) {
console.error('Error adding MCP server to .mcp.json:', error);
return { error: (error as Error).message };
}
}
/**
* Remove MCP server from project's .mcp.json file
* @param {string} projectPath - Project directory path
* @param {string} serverName - MCP server name
* @returns {Object} Result with success/error
*/
function removeMcpServerFromMcpJson(projectPath, serverName) {
try {
const normalizedPath = normalizeProjectPathForConfig(projectPath);
const mcpJsonPath = join(normalizedPath, '.mcp.json');
if (!existsSync(mcpJsonPath)) {
return { error: '.mcp.json not found' };
}
const mcpJson = safeReadJson(mcpJsonPath);
if (!mcpJson || !mcpJson.mcpServers || !mcpJson.mcpServers[serverName]) {
return { error: `Server not found: ${serverName}` };
}
// Remove the server
delete mcpJson.mcpServers[serverName];
// Write back to .mcp.json
writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2), 'utf8');
return {
success: true,
serverName,
removed: true,
scope: 'project-mcp-json'
};
} catch (error: unknown) {
console.error('Error removing MCP server from .mcp.json:', error);
return { error: (error as Error).message };
}
}
/**
* Get MCP configuration from multiple sources (per official Claude Code docs):
*
@@ -114,6 +195,7 @@ function getMcpConfig() {
}
// 3. For each known project, check for .mcp.json (project-level config)
// .mcp.json is now the PRIMARY source for project-level MCP servers
const projectPaths = Object.keys(result.projects);
for (const projectPath of projectPaths) {
const mcpJsonPath = join(projectPath, '.mcp.json');
@@ -121,17 +203,22 @@ function getMcpConfig() {
const mcpJsonConfig = safeReadJson(mcpJsonPath);
if (mcpJsonConfig?.mcpServers) {
// Merge .mcp.json servers into project config
// Project's .mcp.json has lower priority than ~/.claude.json projects[path].mcpServers
// .mcp.json has HIGHER priority than ~/.claude.json projects[path].mcpServers
const existingServers = result.projects[projectPath]?.mcpServers || {};
result.projects[projectPath] = {
...result.projects[projectPath],
mcpServers: {
...mcpJsonConfig.mcpServers, // .mcp.json (lower priority)
...existingServers // ~/.claude.json projects[path] (higher priority)
...existingServers, // ~/.claude.json projects[path] (lower priority, legacy)
...mcpJsonConfig.mcpServers // .mcp.json (higher priority, new default)
},
mcpJsonPath: mcpJsonPath // Track source for debugging
mcpJsonPath: mcpJsonPath, // Track source for debugging
hasMcpJson: true
};
result.configSources.push({ type: 'project-mcp-json', path: mcpJsonPath, count: Object.keys(mcpJsonConfig.mcpServers).length });
result.configSources.push({
type: 'project-mcp-json',
path: mcpJsonPath,
count: Object.keys(mcpJsonConfig.mcpServers).length
});
}
}
}
@@ -223,13 +310,21 @@ function toggleMcpServerEnabled(projectPath, serverName, enable) {
/**
* Add MCP server to project
* Now defaults to using .mcp.json instead of .claude.json
* @param {string} projectPath
* @param {string} serverName
* @param {Object} serverConfig
* @param {boolean} useLegacyConfig - If true, use .claude.json instead of .mcp.json
* @returns {Object}
*/
function addMcpServerToProject(projectPath, serverName, serverConfig) {
function addMcpServerToProject(projectPath, serverName, serverConfig, useLegacyConfig = false) {
try {
// Default: Use .mcp.json for project-level MCP servers
if (!useLegacyConfig) {
return addMcpServerToMcpJson(projectPath, serverName, serverConfig);
}
// Legacy: Use .claude.json (kept for backward compatibility)
if (!existsSync(CLAUDE_CONFIG_PATH)) {
return { error: '.claude.json not found' };
}
@@ -274,7 +369,8 @@ function addMcpServerToProject(projectPath, serverName, serverConfig) {
return {
success: true,
serverName,
serverConfig
serverConfig,
scope: 'project-legacy'
};
} catch (error: unknown) {
console.error('Error adding MCP server:', error);
@@ -284,11 +380,80 @@ function addMcpServerToProject(projectPath, serverName, serverConfig) {
/**
* Remove MCP server from project
* Checks both .mcp.json and .claude.json
* @param {string} projectPath
* @param {string} serverName
* @returns {Object}
*/
function removeMcpServerFromProject(projectPath, serverName) {
try {
const normalizedPath = normalizeProjectPathForConfig(projectPath);
const mcpJsonPath = join(normalizedPath, '.mcp.json');
let removedFromMcpJson = false;
let removedFromClaudeJson = false;
// Try to remove from .mcp.json first (new default)
if (existsSync(mcpJsonPath)) {
const mcpJson = safeReadJson(mcpJsonPath);
if (mcpJson?.mcpServers?.[serverName]) {
const result = removeMcpServerFromMcpJson(projectPath, serverName);
if (result.success) {
removedFromMcpJson = true;
}
}
}
// Also try to remove from .claude.json (legacy - may coexist)
if (existsSync(CLAUDE_CONFIG_PATH)) {
const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8');
const config = JSON.parse(content);
if (config.projects && config.projects[normalizedPath]) {
const projectConfig = config.projects[normalizedPath];
if (projectConfig.mcpServers && projectConfig.mcpServers[serverName]) {
// Remove the server
delete projectConfig.mcpServers[serverName];
// Also remove from disabled list if present
if (projectConfig.disabledMcpServers) {
projectConfig.disabledMcpServers = projectConfig.disabledMcpServers.filter(s => s !== serverName);
}
// Write back to file
writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
removedFromClaudeJson = true;
}
}
}
// Return success if removed from either location
if (removedFromMcpJson || removedFromClaudeJson) {
return {
success: true,
serverName,
removed: true,
scope: removedFromMcpJson ? 'project-mcp-json' : 'project-legacy',
removedFrom: removedFromMcpJson && removedFromClaudeJson ? 'both' :
removedFromMcpJson ? '.mcp.json' : '.claude.json'
};
}
return { error: `Server not found: ${serverName}` };
} catch (error: unknown) {
console.error('Error removing MCP server:', error);
return { error: (error as Error).message };
}
}
/**
* Add MCP server to global/user scope (top-level mcpServers in ~/.claude.json)
* @param {string} serverName
* @param {Object} serverConfig
* @returns {Object}
*/
function addGlobalMcpServer(serverName, serverConfig) {
try {
if (!existsSync(CLAUDE_CONFIG_PATH)) {
return { error: '.claude.json not found' };
@@ -297,25 +462,13 @@ function removeMcpServerFromProject(projectPath, serverName) {
const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8');
const config = JSON.parse(content);
const normalizedPath = normalizeProjectPathForConfig(projectPath);
if (!config.projects || !config.projects[normalizedPath]) {
return { error: `Project not found: ${normalizedPath}` };
// Ensure top-level mcpServers exists
if (!config.mcpServers) {
config.mcpServers = {};
}
const projectConfig = config.projects[normalizedPath];
if (!projectConfig.mcpServers || !projectConfig.mcpServers[serverName]) {
return { error: `Server not found: ${serverName}` };
}
// Remove the server
delete projectConfig.mcpServers[serverName];
// Also remove from disabled list if present
if (projectConfig.disabledMcpServers) {
projectConfig.disabledMcpServers = projectConfig.disabledMcpServers.filter(s => s !== serverName);
}
// Add the server to top-level mcpServers
config.mcpServers[serverName] = serverConfig;
// Write back to file
writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
@@ -323,10 +476,47 @@ function removeMcpServerFromProject(projectPath, serverName) {
return {
success: true,
serverName,
removed: true
serverConfig,
scope: 'global'
};
} catch (error: unknown) {
console.error('Error removing MCP server:', error);
console.error('Error adding global MCP server:', error);
return { error: (error as Error).message };
}
}
/**
* Remove MCP server from global/user scope (top-level mcpServers)
* @param {string} serverName
* @returns {Object}
*/
function removeGlobalMcpServer(serverName) {
try {
if (!existsSync(CLAUDE_CONFIG_PATH)) {
return { error: '.claude.json not found' };
}
const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8');
const config = JSON.parse(content);
if (!config.mcpServers || !config.mcpServers[serverName]) {
return { error: `Global server not found: ${serverName}` };
}
// Remove the server from top-level mcpServers
delete config.mcpServers[serverName];
// Write back to file
writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
return {
success: true,
serverName,
removed: true,
scope: 'global'
};
} catch (error: unknown) {
console.error('Error removing global MCP server:', error);
return { error: (error as Error).message };
}
}
@@ -448,5 +638,134 @@ export async function handleMcpRoutes(ctx: RouteContext): Promise<boolean> {
return true;
}
// API: Add MCP server to global scope (top-level mcpServers in ~/.claude.json)
if (pathname === '/api/mcp-add-global-server' && 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 addGlobalMcpServer(serverName, serverConfig);
});
return true;
}
// API: Remove MCP server from global scope
if (pathname === '/api/mcp-remove-global-server' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { serverName } = body;
if (!serverName) {
return { error: 'serverName is required', status: 400 };
}
return removeGlobalMcpServer(serverName);
});
return true;
}
// ========================================
// MCP Templates API
// ========================================
// API: Get all MCP templates
if (pathname === '/api/mcp-templates' && req.method === 'GET') {
const templates = McpTemplatesDb.getAllTemplates();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, templates }));
return true;
}
// API: Save MCP template
if (pathname === '/api/mcp-templates' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { name, description, serverConfig, tags, category } = body;
if (!name || !serverConfig) {
return { error: 'name and serverConfig are required', status: 400 };
}
return McpTemplatesDb.saveTemplate({
name,
description,
serverConfig,
tags,
category
});
});
return true;
}
// API: Get template by name
if (pathname.startsWith('/api/mcp-templates/') && req.method === 'GET') {
const templateName = decodeURIComponent(pathname.split('/api/mcp-templates/')[1]);
const template = McpTemplatesDb.getTemplateByName(templateName);
if (template) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, template }));
} else {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Template not found' }));
}
return true;
}
// API: Delete MCP template
if (pathname.startsWith('/api/mcp-templates/') && req.method === 'DELETE') {
const templateName = decodeURIComponent(pathname.split('/api/mcp-templates/')[1]);
const result = McpTemplatesDb.deleteTemplate(templateName);
res.writeHead(result.success ? 200 : 404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
return true;
}
// API: Search MCP templates
if (pathname === '/api/mcp-templates/search' && req.method === 'GET') {
const keyword = url.searchParams.get('q') || '';
const templates = McpTemplatesDb.searchTemplates(keyword);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, templates }));
return true;
}
// API: Get all categories
if (pathname === '/api/mcp-templates/categories' && req.method === 'GET') {
const categories = McpTemplatesDb.getAllCategories();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, categories }));
return true;
}
// API: Get templates by category
if (pathname.startsWith('/api/mcp-templates/category/') && req.method === 'GET') {
const category = decodeURIComponent(pathname.split('/api/mcp-templates/category/')[1]);
const templates = McpTemplatesDb.getTemplatesByCategory(category);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, templates }));
return true;
}
// API: Install template to project or global
if (pathname === '/api/mcp-templates/install' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { templateName, projectPath, scope } = body;
if (!templateName) {
return { error: 'templateName is required', status: 400 };
}
const template = McpTemplatesDb.getTemplateByName(templateName);
if (!template) {
return { error: 'Template not found', status: 404 };
}
// Install to global or project
if (scope === 'global') {
return addGlobalMcpServer(templateName, template.serverConfig);
} else {
if (!projectPath) {
return { error: 'projectPath is required for project scope', status: 400 };
}
return addMcpServerToProject(projectPath, templateName, template.serverConfig);
}
});
return true;
}
return false;
}

View File

@@ -0,0 +1,550 @@
// @ts-nocheck
/**
* MCP Routes Module
* Handles all MCP-related API endpoints
*/
import type { IncomingMessage, ServerResponse } from 'http';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
// Claude config file path
const CLAUDE_CONFIG_PATH = join(homedir(), '.claude.json');
export interface RouteContext {
pathname: string;
url: URL;
req: IncomingMessage;
res: ServerResponse;
initialPath: string;
handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise<any>) => void;
broadcastToClients: (data: unknown) => void;
}
// ========================================
// Helper Functions
// ========================================
/**
* Get enterprise managed MCP path (platform-specific)
*/
function getEnterpriseMcpPath(): string {
const platform = process.platform;
if (platform === 'darwin') {
return '/Library/Application Support/ClaudeCode/managed-mcp.json';
} else if (platform === 'win32') {
return 'C:\\Program Files\\ClaudeCode\\managed-mcp.json';
} else {
// Linux and WSL
return '/etc/claude-code/managed-mcp.json';
}
}
/**
* Safely read and parse JSON file
*/
function safeReadJson(filePath) {
try {
if (!existsSync(filePath)) return null;
const content = readFileSync(filePath, 'utf8');
return JSON.parse(content);
} catch {
return null;
}
}
/**
* Get MCP servers from a JSON file (expects mcpServers key at top level)
* @param {string} filePath
* @returns {Object} mcpServers object or empty object
*/
function getMcpServersFromFile(filePath) {
const config = safeReadJson(filePath);
if (!config) return {};
return config.mcpServers || {};
}
/**
* Get MCP configuration from multiple sources (per official Claude Code docs):
*
* Priority (highest to lowest):
* 1. Enterprise managed-mcp.json (cannot be overridden)
* 2. Local scope (project-specific private in ~/.claude.json)
* 3. Project scope (.mcp.json in project root)
* 4. User scope (mcpServers in ~/.claude.json)
*
* Note: ~/.claude/settings.json is for MCP PERMISSIONS, NOT definitions!
*
* @returns {Object}
*/
function getMcpConfig() {
try {
const result = {
projects: {},
userServers: {}, // User-level servers from ~/.claude.json mcpServers
enterpriseServers: {}, // Enterprise managed servers (highest priority)
configSources: [] // Track where configs came from for debugging
};
// 1. Read Enterprise managed MCP servers (highest priority)
const enterprisePath = getEnterpriseMcpPath();
if (existsSync(enterprisePath)) {
const enterpriseConfig = safeReadJson(enterprisePath);
if (enterpriseConfig?.mcpServers) {
result.enterpriseServers = enterpriseConfig.mcpServers;
result.configSources.push({ type: 'enterprise', path: enterprisePath, count: Object.keys(enterpriseConfig.mcpServers).length });
}
}
// 2. Read from ~/.claude.json
if (existsSync(CLAUDE_CONFIG_PATH)) {
const claudeConfig = safeReadJson(CLAUDE_CONFIG_PATH);
if (claudeConfig) {
// 2a. User-level mcpServers (top-level mcpServers key)
if (claudeConfig.mcpServers) {
result.userServers = claudeConfig.mcpServers;
result.configSources.push({ type: 'user', path: CLAUDE_CONFIG_PATH, count: Object.keys(claudeConfig.mcpServers).length });
}
// 2b. Project-specific configurations (projects[path].mcpServers)
if (claudeConfig.projects) {
result.projects = claudeConfig.projects;
}
}
}
// 3. For each known project, check for .mcp.json (project-level config)
const projectPaths = Object.keys(result.projects);
for (const projectPath of projectPaths) {
const mcpJsonPath = join(projectPath, '.mcp.json');
if (existsSync(mcpJsonPath)) {
const mcpJsonConfig = safeReadJson(mcpJsonPath);
if (mcpJsonConfig?.mcpServers) {
// Merge .mcp.json servers into project config
// Project's .mcp.json has lower priority than ~/.claude.json projects[path].mcpServers
const existingServers = result.projects[projectPath]?.mcpServers || {};
result.projects[projectPath] = {
...result.projects[projectPath],
mcpServers: {
...mcpJsonConfig.mcpServers, // .mcp.json (lower priority)
...existingServers // ~/.claude.json projects[path] (higher priority)
},
mcpJsonPath: mcpJsonPath // Track source for debugging
};
result.configSources.push({ type: 'project-mcp-json', path: mcpJsonPath, count: Object.keys(mcpJsonConfig.mcpServers).length });
}
}
}
// Build globalServers by merging user and enterprise servers
// Enterprise servers override user servers
result.globalServers = {
...result.userServers,
...result.enterpriseServers
};
return result;
} catch (error: unknown) {
console.error('Error reading MCP config:', error);
return { projects: {}, globalServers: {}, userServers: {}, enterpriseServers: {}, configSources: [], error: (error as Error).message };
}
}
/**
* Normalize project path for .claude.json (Windows backslash format)
* @param {string} path
* @returns {string}
*/
function normalizeProjectPathForConfig(path) {
// Convert forward slashes to backslashes for Windows .claude.json format
let normalized = path.replace(/\//g, '\\');
// Handle /d/path format -> D:\path
if (normalized.match(/^\\[a-zA-Z]\\/)) {
normalized = normalized.charAt(1).toUpperCase() + ':' + normalized.slice(2);
}
return normalized;
}
/**
* Toggle MCP server enabled/disabled
* @param {string} projectPath
* @param {string} serverName
* @param {boolean} enable
* @returns {Object}
*/
function toggleMcpServerEnabled(projectPath, serverName, enable) {
try {
if (!existsSync(CLAUDE_CONFIG_PATH)) {
return { error: '.claude.json not found' };
}
const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8');
const config = JSON.parse(content);
const normalizedPath = normalizeProjectPathForConfig(projectPath);
if (!config.projects || !config.projects[normalizedPath]) {
return { error: `Project not found: ${normalizedPath}` };
}
const projectConfig = config.projects[normalizedPath];
// Ensure disabledMcpServers array exists
if (!projectConfig.disabledMcpServers) {
projectConfig.disabledMcpServers = [];
}
if (enable) {
// Remove from disabled list
projectConfig.disabledMcpServers = projectConfig.disabledMcpServers.filter(s => s !== serverName);
} else {
// Add to disabled list if not already there
if (!projectConfig.disabledMcpServers.includes(serverName)) {
projectConfig.disabledMcpServers.push(serverName);
}
}
// Write back to file
writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
return {
success: true,
serverName,
enabled: enable,
disabledMcpServers: projectConfig.disabledMcpServers
};
} catch (error: unknown) {
console.error('Error toggling MCP server:', error);
return { error: (error as Error).message };
}
}
/**
* Add MCP server to project
* @param {string} projectPath
* @param {string} serverName
* @param {Object} serverConfig
* @returns {Object}
*/
function addMcpServerToProject(projectPath, serverName, serverConfig) {
try {
if (!existsSync(CLAUDE_CONFIG_PATH)) {
return { error: '.claude.json not found' };
}
const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8');
const config = JSON.parse(content);
const normalizedPath = normalizeProjectPathForConfig(projectPath);
// Create project entry if it doesn't exist
if (!config.projects) {
config.projects = {};
}
if (!config.projects[normalizedPath]) {
config.projects[normalizedPath] = {
allowedTools: [],
mcpContextUris: [],
mcpServers: {},
enabledMcpjsonServers: [],
disabledMcpjsonServers: [],
hasTrustDialogAccepted: false,
projectOnboardingSeenCount: 0,
hasClaudeMdExternalIncludesApproved: false,
hasClaudeMdExternalIncludesWarningShown: false
};
}
const projectConfig = config.projects[normalizedPath];
// Ensure mcpServers exists
if (!projectConfig.mcpServers) {
projectConfig.mcpServers = {};
}
// Add the server
projectConfig.mcpServers[serverName] = serverConfig;
// Write back to file
writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
return {
success: true,
serverName,
serverConfig
};
} catch (error: unknown) {
console.error('Error adding MCP server:', error);
return { error: (error as Error).message };
}
}
/**
* Remove MCP server from project
* @param {string} projectPath
* @param {string} serverName
* @returns {Object}
*/
function removeMcpServerFromProject(projectPath, serverName) {
try {
if (!existsSync(CLAUDE_CONFIG_PATH)) {
return { error: '.claude.json not found' };
}
const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8');
const config = JSON.parse(content);
const normalizedPath = normalizeProjectPathForConfig(projectPath);
if (!config.projects || !config.projects[normalizedPath]) {
return { error: `Project not found: ${normalizedPath}` };
}
const projectConfig = config.projects[normalizedPath];
if (!projectConfig.mcpServers || !projectConfig.mcpServers[serverName]) {
return { error: `Server not found: ${serverName}` };
}
// Remove the server
delete projectConfig.mcpServers[serverName];
// Also remove from disabled list if present
if (projectConfig.disabledMcpServers) {
projectConfig.disabledMcpServers = projectConfig.disabledMcpServers.filter(s => s !== serverName);
}
// Write back to file
writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
return {
success: true,
serverName,
removed: true
};
} catch (error: unknown) {
console.error('Error removing MCP server:', error);
return { error: (error as Error).message };
}
}
/**
* Add MCP server to global/user scope (top-level mcpServers in ~/.claude.json)
* @param {string} serverName
* @param {Object} serverConfig
* @returns {Object}
*/
function addGlobalMcpServer(serverName, serverConfig) {
try {
if (!existsSync(CLAUDE_CONFIG_PATH)) {
return { error: '.claude.json not found' };
}
const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8');
const config = JSON.parse(content);
// Ensure top-level mcpServers exists
if (!config.mcpServers) {
config.mcpServers = {};
}
// Add the server to top-level mcpServers
config.mcpServers[serverName] = serverConfig;
// Write back to file
writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
return {
success: true,
serverName,
serverConfig,
scope: 'global'
};
} catch (error: unknown) {
console.error('Error adding global MCP server:', error);
return { error: (error as Error).message };
}
}
/**
* Remove MCP server from global/user scope (top-level mcpServers)
* @param {string} serverName
* @returns {Object}
*/
function removeGlobalMcpServer(serverName) {
try {
if (!existsSync(CLAUDE_CONFIG_PATH)) {
return { error: '.claude.json not found' };
}
const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8');
const config = JSON.parse(content);
if (!config.mcpServers || !config.mcpServers[serverName]) {
return { error: `Global server not found: ${serverName}` };
}
// Remove the server from top-level mcpServers
delete config.mcpServers[serverName];
// Write back to file
writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
return {
success: true,
serverName,
removed: true,
scope: 'global'
};
} catch (error: unknown) {
console.error('Error removing global MCP server:', error);
return { error: (error as Error).message };
}
}
/**
* Read settings file safely
* @param {string} filePath
* @returns {Object}
*/
function readSettingsFile(filePath) {
try {
if (!existsSync(filePath)) {
return {};
}
const content = readFileSync(filePath, 'utf8');
return JSON.parse(content);
} catch (error: unknown) {
console.error(`Error reading settings file ${filePath}:`, error);
return {};
}
}
/**
* Write settings file safely
* @param {string} filePath
* @param {Object} settings
*/
function writeSettingsFile(filePath, settings) {
const dirPath = dirname(filePath);
// Ensure directory exists
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true });
}
writeFileSync(filePath, JSON.stringify(settings, null, 2), 'utf8');
}
/**
* Get project settings path
* @param {string} projectPath
* @returns {string}
*/
function getProjectSettingsPath(projectPath) {
const normalizedPath = projectPath.replace(/\//g, '\\').replace(/^\\([a-zA-Z])\\/, '$1:\\');
return join(normalizedPath, '.claude', 'settings.json');
}
// ========================================
// Route Handlers
// ========================================
/**
* Handle MCP routes
* @returns true if route was handled, false otherwise
*/
export async function handleMcpRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
// API: Get MCP configuration
if (pathname === '/api/mcp-config') {
const mcpData = getMcpConfig();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(mcpData));
return true;
}
// API: Toggle MCP server enabled/disabled
if (pathname === '/api/mcp-toggle' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { projectPath, serverName, enable } = body;
if (!projectPath || !serverName) {
return { error: 'projectPath and serverName are required', status: 400 };
}
return toggleMcpServerEnabled(projectPath, serverName, enable);
});
return true;
}
// API: Copy MCP server to project
if (pathname === '/api/mcp-copy-server' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { projectPath, serverName, serverConfig } = body;
if (!projectPath || !serverName || !serverConfig) {
return { error: 'projectPath, serverName, and serverConfig are required', status: 400 };
}
return addMcpServerToProject(projectPath, serverName, serverConfig);
});
return true;
}
// API: Install CCW MCP server to project
if (pathname === '/api/mcp-install-ccw' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { projectPath } = body;
if (!projectPath) {
return { error: 'projectPath is required', status: 400 };
}
// Generate CCW MCP server config
const ccwMcpConfig = {
command: "ccw-mcp",
args: []
};
// Use existing addMcpServerToProject to install CCW MCP
return addMcpServerToProject(projectPath, 'ccw-mcp', ccwMcpConfig);
});
return true;
}
// API: Remove MCP server from project
if (pathname === '/api/mcp-remove-server' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { projectPath, serverName } = body;
if (!projectPath || !serverName) {
return { error: 'projectPath and serverName are required', status: 400 };
}
return removeMcpServerFromProject(projectPath, serverName);
});
return true;
}
// API: Add MCP server to global scope (top-level mcpServers in ~/.claude.json)
if (pathname === '/api/mcp-add-global-server' && 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 addGlobalMcpServer(serverName, serverConfig);
});
return true;
}
// API: Remove MCP server from global scope
if (pathname === '/api/mcp-remove-global-server' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { serverName } = body;
if (!serverName) {
return { error: 'serverName is required', status: 400 };
}
return removeGlobalMcpServer(serverName);
});
return true;
}
return false;
}

View File

@@ -4,9 +4,10 @@
* Handles all Rules-related API endpoints
*/
import type { IncomingMessage, ServerResponse } from 'http';
import { readFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
import { readFileSync, existsSync, readdirSync, unlinkSync, promises as fsPromises } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { executeCliTool } from '../../tools/cli-executor.js';
export interface RouteContext {
pathname: string;
@@ -220,6 +221,191 @@ function deleteRule(ruleName, location, projectPath) {
}
}
/**
* Generate rule content via CLI tool
* @param {Object} params
* @param {string} params.generationType - 'description' | 'template' | 'extract'
* @param {string} params.description - Rule description (for 'description' mode)
* @param {string} params.templateType - Template type (for 'template' mode)
* @param {string} params.extractScope - Scope pattern (for 'extract' mode)
* @param {string} params.extractFocus - Focus areas (for 'extract' mode)
* @param {string} params.fileName - Target file name
* @param {string} params.location - 'project' or 'user'
* @param {string} params.subdirectory - Optional subdirectory
* @param {string} params.projectPath - Project root path
* @returns {Object}
*/
async function generateRuleViaCLI(params) {
try {
const {
generationType,
description,
templateType,
extractScope,
extractFocus,
fileName,
location,
subdirectory,
projectPath
} = params;
let prompt = '';
let mode = 'analysis';
let workingDir = projectPath;
// Build prompt based on generation type
if (generationType === 'description') {
mode = 'write';
prompt = `PURPOSE: Generate Claude Code memory rule from description to guide Claude's behavior
TASK: • Analyze rule requirements • Generate markdown content with clear instructions
MODE: write
EXPECTED: Complete rule content in markdown format
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt) | Follow Claude Code rule format | Use frontmatter for conditional rules if paths specified | write=CREATE
RULE DESCRIPTION:
${description}
FILE NAME: ${fileName}`;
} else if (generationType === 'template') {
mode = 'write';
prompt = `PURPOSE: Generate Claude Code rule from template type
TASK: • Create rule based on ${templateType} template • Generate structured markdown content
MODE: write
EXPECTED: Complete rule content in markdown format following template structure
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt) | Follow Claude Code rule format | Use ${templateType} template patterns | write=CREATE
TEMPLATE TYPE: ${templateType}
FILE NAME: ${fileName}`;
} else if (generationType === 'extract') {
mode = 'analysis';
prompt = `PURPOSE: Extract coding rules from existing codebase to document patterns and conventions
TASK: • Analyze code patterns in specified scope • Extract common conventions • Identify best practices
MODE: analysis
CONTEXT: @${extractScope || '**/*'}
EXPECTED: Rule content based on codebase analysis with examples
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/analysis/02-analyze-code-patterns.txt) | Focus on actual patterns found | Include code examples | analysis=READ-ONLY
ANALYSIS SCOPE: ${extractScope || '**/*'}
FOCUS AREAS: ${extractFocus || 'naming conventions, error handling, code structure'}`;
} else {
return { error: `Unknown generation type: ${generationType}` };
}
// Execute CLI tool (Gemini) with at least 10 minutes timeout
const result = await executeCliTool({
tool: 'gemini',
prompt,
mode,
cd: workingDir,
timeout: 600000 // 10 minutes
});
if (!result.success) {
return {
error: `CLI execution failed: ${result.stderr || 'Unknown error'}`,
stderr: result.stderr
};
}
// Extract generated content from stdout
const generatedContent = result.stdout.trim();
if (!generatedContent) {
return {
error: 'CLI execution returned empty content',
stdout: result.stdout,
stderr: result.stderr
};
}
// Create the rule using the generated content
const createResult = await createRule({
fileName,
content: generatedContent,
paths: [],
location,
subdirectory,
projectPath
});
return {
success: createResult.success || false,
...createResult,
generatedContent,
executionId: result.conversation?.id
};
} catch (error) {
return { error: (error as Error).message };
}
}
/**
* Create a new rule
* @param {Object} params
* @param {string} params.fileName - Rule file name (must end with .md)
* @param {string} params.content - Rule content (markdown)
* @param {string[]} params.paths - Optional paths for conditional rule
* @param {string} params.location - 'project' or 'user'
* @param {string} params.subdirectory - Optional subdirectory path
* @param {string} params.projectPath - Project root path
* @returns {Object}
*/
async function createRule(params) {
try {
const { fileName, content, paths, location, subdirectory, projectPath } = params;
// Validate file name
if (!fileName || !fileName.endsWith('.md')) {
return { error: 'File name must end with .md' };
}
// Build base directory
const baseDir = location === 'project'
? join(projectPath, '.claude', 'rules')
: join(homedir(), '.claude', 'rules');
// Build target directory (with optional subdirectory)
const targetDir = subdirectory
? join(baseDir, subdirectory)
: baseDir;
// Ensure target directory exists
await fsPromises.mkdir(targetDir, { recursive: true });
// Build complete file path
const filePath = join(targetDir, fileName);
// Check if file already exists
if (existsSync(filePath)) {
return { error: `Rule '${fileName}' already exists in ${location} location` };
}
// Build complete content with frontmatter if paths provided
let completeContent = content;
if (paths && paths.length > 0) {
const frontmatter = `---
paths: [${paths.join(', ')}]
---
`;
completeContent = frontmatter + content;
}
// Write rule file
await fsPromises.writeFile(filePath, completeContent, 'utf8');
return {
success: true,
fileName,
location,
path: filePath,
subdirectory: subdirectory || null
};
} catch (error) {
return { error: (error as Error).message };
}
}
/**
* Handle Rules routes
* @returns true if route was handled, false otherwise
@@ -262,5 +448,79 @@ export async function handleRulesRoutes(ctx: RouteContext): Promise<boolean> {
return true;
}
// API: Create rule
if (pathname === '/api/rules/create' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const {
mode,
fileName,
content,
paths,
location,
subdirectory,
projectPath: projectPathParam,
// CLI generation parameters
generationType,
description,
templateType,
extractScope,
extractFocus
} = body;
if (!fileName) {
return { error: 'File name is required' };
}
if (!location) {
return { error: 'Location is required (project or user)' };
}
const projectPath = projectPathParam || initialPath;
// CLI generation mode
if (mode === 'cli-generate') {
if (!generationType) {
return { error: 'generationType is required for CLI generation mode' };
}
// Validate based on generation type
if (generationType === 'description' && !description) {
return { error: 'description is required for description-based generation' };
}
if (generationType === 'template' && !templateType) {
return { error: 'templateType is required for template-based generation' };
}
return await generateRuleViaCLI({
generationType,
description,
templateType,
extractScope,
extractFocus,
fileName,
location,
subdirectory: subdirectory || '',
projectPath
});
}
// Manual creation mode
if (!content) {
return { error: 'Content is required for manual creation' };
}
return await createRule({
fileName,
content,
paths: paths || [],
location,
subdirectory: subdirectory || '',
projectPath
});
});
return true;
}
return false;
}

View File

@@ -7,6 +7,7 @@ import type { IncomingMessage, ServerResponse } from 'http';
import { readFileSync, existsSync, readdirSync, statSync, unlinkSync, promises as fsPromises } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { executeCliTool } from '../../tools/cli-executor.js';
export interface RouteContext {
pathname: string;
@@ -252,6 +253,250 @@ function deleteSkill(skillName, location, projectPath) {
}
}
/**
* Validate skill folder structure
* @param {string} folderPath - Path to skill folder
* @returns {Object} Validation result with skill info
*/
function validateSkillFolder(folderPath) {
const errors = [];
// Check if folder exists
if (!existsSync(folderPath)) {
return { valid: false, errors: ['Folder does not exist'], skillInfo: null };
}
// Check if it's a directory
try {
const stat = statSync(folderPath);
if (!stat.isDirectory()) {
return { valid: false, errors: ['Path is not a directory'], skillInfo: null };
}
} catch (e) {
return { valid: false, errors: ['Cannot access folder'], skillInfo: null };
}
// Check SKILL.md exists
const skillMdPath = join(folderPath, 'SKILL.md');
if (!existsSync(skillMdPath)) {
errors.push('SKILL.md file not found');
return { valid: false, errors, skillInfo: null };
}
// Parse and validate frontmatter
try {
const content = readFileSync(skillMdPath, 'utf8');
const parsed = parseSkillFrontmatter(content);
if (!parsed.name) {
errors.push('name field is required in frontmatter');
}
if (!parsed.description) {
errors.push('description field is required in frontmatter');
}
// Get supporting files
const supportingFiles = getSupportingFiles(folderPath);
// If validation passed
if (errors.length === 0) {
return {
valid: true,
errors: [],
skillInfo: {
name: parsed.name,
description: parsed.description,
version: parsed.version,
allowedTools: parsed.allowedTools,
supportingFiles
}
};
} else {
return { valid: false, errors, skillInfo: null };
}
} catch (error) {
return { valid: false, errors: ['Failed to parse SKILL.md: ' + (error as Error).message], skillInfo: null };
}
}
/**
* Recursively copy directory
* @param {string} source - Source directory path
* @param {string} target - Target directory path
*/
async function copyDirectoryRecursive(source, target) {
await fsPromises.mkdir(target, { recursive: true });
const entries = await fsPromises.readdir(source, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = join(source, entry.name);
const targetPath = join(target, entry.name);
if (entry.isDirectory()) {
await copyDirectoryRecursive(sourcePath, targetPath);
} else {
await fsPromises.copyFile(sourcePath, targetPath);
}
}
}
/**
* Import skill from folder
* @param {string} sourcePath - Source skill folder path
* @param {string} location - 'project' or 'user'
* @param {string} projectPath - Project root path
* @param {string} customName - Optional custom name for skill
* @returns {Object}
*/
async function importSkill(sourcePath, location, projectPath, customName) {
try {
// Validate source folder
const validation = validateSkillFolder(sourcePath);
if (!validation.valid) {
return { error: validation.errors.join(', ') };
}
const baseDir = location === 'project'
? join(projectPath, '.claude', 'skills')
: join(homedir(), '.claude', 'skills');
// Ensure base directory exists
if (!existsSync(baseDir)) {
await fsPromises.mkdir(baseDir, { recursive: true });
}
// Determine target folder name
const skillName = customName || validation.skillInfo.name;
const targetPath = join(baseDir, skillName);
// Check if already exists
if (existsSync(targetPath)) {
return { error: `Skill '${skillName}' already exists in ${location} location` };
}
// Copy entire folder recursively
await copyDirectoryRecursive(sourcePath, targetPath);
return {
success: true,
skillName,
location,
path: targetPath
};
} catch (error) {
return { error: (error as Error).message };
}
}
/**
* Generate skill via CLI tool (Gemini)
* @param {Object} params - Generation parameters
* @param {string} params.generationType - 'description' or 'template'
* @param {string} params.description - Skill description from user
* @param {string} params.skillName - Name for the skill
* @param {string} params.location - 'project' or 'user'
* @param {string} params.projectPath - Project root path
* @returns {Object}
*/
async function generateSkillViaCLI({ generationType, description, skillName, location, projectPath }) {
try {
// Validate inputs
if (!skillName) {
return { error: 'Skill name is required' };
}
if (generationType === 'description' && !description) {
return { error: 'Description is required for description-based generation' };
}
// Determine target directory
const baseDir = location === 'project'
? join(projectPath, '.claude', 'skills')
: join(homedir(), '.claude', 'skills');
const targetPath = join(baseDir, skillName);
// Check if already exists
if (existsSync(targetPath)) {
return { error: `Skill '${skillName}' already exists in ${location} location` };
}
// Ensure base directory exists
if (!existsSync(baseDir)) {
await fsPromises.mkdir(baseDir, { recursive: true });
}
// Build CLI prompt
const targetLocationDisplay = location === 'project'
? '.claude/skills/'
: '~/.claude/skills/';
const prompt = `PURPOSE: Generate a complete Claude Code skill from description
TASK: • Parse skill requirements • Create SKILL.md with proper frontmatter (name, description, version, allowed-tools) • Generate supporting files if needed in skill folder
MODE: write
CONTEXT: @**/*
EXPECTED: Complete skill folder structure with SKILL.md and all necessary files
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt) | Follow Claude Code skill format | Include name, description in frontmatter | write=CREATE
SKILL DESCRIPTION:
${description || 'Generate a basic skill template'}
SKILL NAME: ${skillName}
TARGET LOCATION: ${targetLocationDisplay}
TARGET PATH: ${targetPath}
REQUIREMENTS:
1. Create SKILL.md with frontmatter containing:
- name: "${skillName}"
- description: Brief description of the skill
- version: "1.0.0"
- allowed-tools: List of tools this skill can use (e.g., [Read, Write, Edit, Bash])
2. Add skill content below frontmatter explaining what the skill does and how to use it
3. If the skill requires supporting files (e.g., templates, scripts), create them in the skill folder
4. Ensure all files are properly formatted and follow best practices`;
// Execute CLI tool (Gemini) with write mode
const result = await executeCliTool({
tool: 'gemini',
prompt,
mode: 'write',
cd: baseDir,
timeout: 600000, // 10 minutes
category: 'internal'
});
// Check if execution was successful
if (!result.success) {
return {
error: `CLI generation failed: ${result.stderr || 'Unknown error'}`,
stdout: result.stdout,
stderr: result.stderr
};
}
// Validate the generated skill
const validation = validateSkillFolder(targetPath);
if (!validation.valid) {
return {
error: `Generated skill is invalid: ${validation.errors.join(', ')}`,
stdout: result.stdout,
stderr: result.stderr
};
}
return {
success: true,
skillName: validation.skillInfo.name,
location,
path: targetPath,
stdout: result.stdout,
stderr: result.stderr
};
} catch (error) {
return { error: (error as Error).message };
}
}
// ========== Skills API Routes ==========
/**
@@ -296,5 +541,59 @@ export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
return true;
}
// API: Validate skill import
if (pathname === '/api/skills/validate-import' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { sourcePath } = body;
if (!sourcePath) {
return { valid: false, errors: ['Source path is required'], skillInfo: null };
}
return validateSkillFolder(sourcePath);
});
return true;
}
// API: Create/Import skill
if (pathname === '/api/skills/create' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { mode, location, sourcePath, skillName, description, generationType, projectPath: projectPathParam } = body;
if (!mode) {
return { error: 'Mode is required (import or cli-generate)' };
}
if (!location) {
return { error: 'Location is required (project or user)' };
}
const projectPath = projectPathParam || initialPath;
if (mode === 'import') {
// Import mode: copy existing skill folder
if (!sourcePath) {
return { error: 'Source path is required for import mode' };
}
return await importSkill(sourcePath, location, projectPath, skillName);
} else if (mode === 'cli-generate') {
// CLI generate mode: use Gemini to generate skill
if (!skillName) {
return { error: 'Skill name is required for CLI generation mode' };
}
return await generateSkillViaCLI({
generationType: generationType || 'description',
description,
skillName,
location,
projectPath
});
} else {
return { error: 'Invalid mode. Must be "import" or "cli-generate"' };
}
});
return true;
}
return false;
}

View File

@@ -17,6 +17,7 @@ import { handleSkillsRoutes } from './routes/skills-routes.js';
import { handleRulesRoutes } from './routes/rules-routes.js';
import { handleSessionRoutes } from './routes/session-routes.js';
import { handleCcwRoutes } from './routes/ccw-routes.js';
import { handleClaudeRoutes } from './routes/claude-routes.js';
// Import WebSocket handling
import { handleWebSocketUpgrade, broadcastToClients } from './websocket.js';
@@ -59,7 +60,8 @@ const MODULE_CSS_FILES = [
'10-cli.css',
'11-memory.css',
'11-prompt-history.css',
'12-skills-rules.css'
'12-skills-rules.css',
'13-claude-manager.css'
];
// Modular JS files in dependency order
@@ -104,6 +106,7 @@ const MODULE_FILES = [
'views/prompt-history.js',
'views/skills-manager.js',
'views/rules-manager.js',
'views/claude-manager.js',
'main.js'
];
@@ -241,6 +244,11 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleCliRoutes(routeContext)) return;
}
// Claude CLAUDE.md routes (/api/memory/claude/*)
if (pathname.startsWith('/api/memory/claude/')) {
if (await handleClaudeRoutes(routeContext)) return;
}
// Memory routes (/api/memory/*)
if (pathname.startsWith('/api/memory/')) {
if (await handleMemoryRoutes(routeContext)) return;

View File

@@ -1528,6 +1528,11 @@ code.ctx-meta-chip-value {
color: white;
}
.status-toast.warning {
background: hsl(38 92% 50%);
color: white;
}
.status-toast.fade-out {
animation: toastFadeOut 0.3s ease forwards;
}

View File

@@ -214,3 +214,113 @@
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.2);
}
/* ==========================================
CREATE MODAL STYLES (Skills & Rules)
========================================== */
/* Modal Overlay */
.modal-overlay {
animation: fadeIn 0.15s ease-out;
backdrop-filter: blur(2px);
}
/* Modal Dialog */
.modal-dialog {
animation: slideUpModal 0.2s ease-out;
}
@keyframes slideUpModal {
from {
opacity: 0;
transform: translateY(20px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Location Selection Buttons */
.location-btn {
cursor: pointer;
}
.location-btn:active {
transform: scale(0.98);
}
/* Validation Result Animations */
#skillValidationResult > div,
#ruleValidationResult > div {
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Path Input List Animations */
#rulePathsList > div {
animation: slideDown 0.15s ease-out;
}
/* Form Input Focus States */
.modal-dialog input:focus,
.modal-dialog textarea:focus,
.modal-dialog select:focus {
outline: none;
border-color: hsl(var(--primary));
ring: 2px;
ring-color: hsl(var(--primary) / 0.2);
}
/* Checkbox Custom Styling */
.modal-dialog input[type="checkbox"] {
cursor: pointer;
accent-color: hsl(var(--primary));
}
/* Textarea Specific */
.modal-dialog textarea {
resize: vertical;
min-height: 100px;
}
/* Button Hover States */
.modal-dialog button:not(:disabled):hover {
opacity: 0.9;
}
.modal-dialog button:not(:disabled):active {
transform: scale(0.98);
}
/* Loading Spinner in Validation */
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Responsive Modal */
@media (max-width: 640px) {
.modal-dialog {
max-width: 95vw;
max-height: 95vh;
}
}

View File

@@ -0,0 +1,759 @@
/* ========================================
* CLAUDE.md Manager Styles
* Three-column layout: File Tree | Viewer/Editor | Metadata
* ======================================== */
/* ========================================
* Main Layout
* ======================================== */
.claude-manager-view {
height: 100%;
min-height: calc(100vh - 180px);
max-height: calc(100vh - 180px);
overflow: hidden;
display: flex;
flex-direction: column;
}
.claude-manager-view.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* ========================================
* Header
* ======================================== */
.claude-manager-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
margin-bottom: 0.5rem;
border-bottom: 1px solid hsl(var(--border));
flex-shrink: 0;
}
.claude-manager-header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.claude-manager-header-left h2 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0;
}
.file-count-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
border-radius: 0.25rem;
}
.claude-manager-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* ========================================
* Three-Column Grid
* ======================================== */
.claude-manager-columns {
display: grid;
grid-template-columns: 280px 1fr 320px;
gap: 1rem;
flex: 1;
min-height: 0;
overflow: hidden;
}
.claude-manager-column {
display: flex;
flex-direction: column;
min-height: 0;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
}
.claude-manager-column.left {
overflow-y: auto;
height: 100%;
}
.claude-manager-column.center {
overflow: hidden;
height: 100%;
}
.claude-manager-column.right {
overflow-y: auto;
height: 100%;
}
/* ========================================
* File Tree (Left Column)
* ======================================== */
.file-tree {
padding: 0;
}
/* Search Box */
.file-tree-search {
position: relative;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid hsl(var(--border));
}
.file-tree-search input {
width: 100%;
padding: 0.5rem 2.5rem 0.5rem 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
}
.file-tree-search input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.1);
}
.file-tree-search i {
position: absolute;
right: 1.5rem;
top: 50%;
transform: translateY(-50%);
color: hsl(var(--muted-foreground));
pointer-events: none;
}
.file-tree-section {
margin-bottom: 0.5rem;
}
.file-tree-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
cursor: pointer;
font-weight: 600;
color: hsl(var(--foreground));
border-radius: 0.25rem;
transition: background-color 0.15s;
}
.file-tree-header:hover {
background: hsl(var(--muted));
}
.file-tree-header .file-count {
margin-left: auto;
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
border-radius: 0.25rem;
}
.file-tree-subheader {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
cursor: pointer;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
border-radius: 0.25rem;
transition: background-color 0.15s;
}
.file-tree-subheader:hover {
background: hsl(var(--muted) / 0.5);
}
.file-tree-subheader .file-count {
margin-left: auto;
font-size: 0.7rem;
padding: 0.125rem 0.25rem;
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
border-radius: 0.25rem;
}
.file-tree-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
cursor: pointer;
font-size: 0.875rem;
color: hsl(var(--foreground));
border-radius: 0.25rem;
transition: background-color 0.15s;
}
.file-tree-item:hover {
background: hsl(var(--muted) / 0.5);
}
.file-tree-item.selected {
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
font-weight: 500;
}
.file-tree-item.empty {
color: hsl(var(--muted-foreground));
cursor: default;
font-style: italic;
}
.file-tree-item.empty:hover {
background: transparent;
}
.file-tree-item .file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-tree-item .file-path-hint {
font-size: 0.7rem;
color: hsl(var(--muted-foreground));
opacity: 0.7;
}
/* Color coding for level icons */
.text-orange-500 {
color: hsl(25, 95%, 53%);
}
.text-green-500 {
color: hsl(142, 71%, 45%);
}
.text-blue-500 {
color: hsl(217, 91%, 60%);
}
/* ========================================
* File Viewer (Center Column)
* ======================================== */
.file-viewer {
display: flex;
flex-direction: column;
height: 100%;
}
.file-viewer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid hsl(var(--border));
flex-shrink: 0;
}
.file-viewer-header h3 {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0;
}
.file-viewer-actions {
display: flex;
gap: 0.5rem;
}
.file-viewer-content {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.markdown-content {
line-height: 1.6;
color: hsl(var(--foreground));
}
.markdown-content h1 {
font-size: 1.5rem;
font-weight: 700;
margin: 1.5rem 0 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid hsl(var(--border));
}
.markdown-content h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 1.25rem 0 0.75rem;
}
.markdown-content h3 {
font-size: 1.125rem;
font-weight: 600;
margin: 1rem 0 0.5rem;
}
.markdown-content strong {
font-weight: 600;
color: hsl(var(--foreground));
}
.markdown-content em {
font-style: italic;
}
.markdown-content code {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.875rem;
padding: 0.125rem 0.375rem;
background: hsl(var(--muted));
border-radius: 0.25rem;
}
.file-editor {
width: 100%;
height: 100%;
min-height: 400px;
padding: 1rem;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.875rem;
line-height: 1.5;
color: hsl(var(--foreground));
background: hsl(var(--background));
border: none;
outline: none;
resize: none;
}
.file-editor:focus {
background: hsl(var(--background));
}
/* ========================================
* File Metadata (Right Column)
* ======================================== */
.file-metadata {
padding: 1rem;
}
.metadata-section {
margin-bottom: 1.5rem;
}
.metadata-section h4 {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.75rem;
}
.metadata-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
.metadata-item .label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.metadata-item .value {
font-size: 0.875rem;
color: hsl(var(--foreground));
font-weight: 500;
}
.metadata-item .value.path {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.75rem;
word-break: break-all;
background: hsl(var(--muted));
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.metadata-section .btn.full-width {
width: 100%;
margin-bottom: 0.5rem;
justify-content: center;
}
/* ========================================
* CLI Sync Panel
* ======================================== */
.cli-sync-panel {
padding: 1rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--card));
}
.cli-sync-panel .panel-header {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
margin-bottom: 1rem;
color: hsl(var(--primary));
}
.sync-config {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.sync-config label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
margin-bottom: 0.25rem;
}
.sync-config .sync-select,
.sync-config select {
padding: 0.5rem;
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
width: 100%;
}
.sync-config .sync-select:focus,
.sync-config select:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.1);
}
.sync-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem;
background: hsl(var(--primary));
color: white;
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.sync-button:hover:not(:disabled) {
opacity: 0.9;
}
.sync-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sync-progress {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
margin-top: 0.75rem;
background: hsl(var(--accent));
border-radius: 0.375rem;
font-size: 0.875rem;
color: hsl(var(--accent-foreground));
}
.sync-progress i {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ========================================
* Empty State
* ======================================== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: hsl(var(--muted-foreground));
text-align: center;
padding: 2rem;
}
.empty-state i {
margin-bottom: 1rem;
}
.empty-state p {
margin: 0;
font-size: 0.875rem;
}
/* ========================================
* File Type Icons
* ======================================== */
.file-icon {
flex-shrink: 0;
}
.file-icon.directory {
color: hsl(var(--primary));
}
.file-icon.markdown {
color: hsl(25, 95%, 53%);
}
.file-icon.code {
color: hsl(217, 91%, 60%);
}
.file-icon.json {
color: hsl(142, 71%, 45%);
}
.file-icon.css {
color: hsl(262, 83%, 58%);
}
.file-size {
margin-left: auto;
font-size: 0.7rem;
color: hsl(var(--muted-foreground));
opacity: 0.7;
}
/* Search Highlight */
.search-highlight {
background: hsl(45, 100%, 70%);
color: hsl(0, 0%, 10%);
font-weight: 600;
padding: 0.125rem 0.25rem;
border-radius: 0.125rem;
}
/* ========================================
* Create Dialog (Modal)
* ======================================== */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.create-dialog {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
padding: 1.5rem;
width: 90%;
max-width: 500px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
animation: slideUp 0.3s;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.create-dialog h3 {
margin: 0 0 1.5rem 0;
color: hsl(var(--foreground));
font-size: 1.125rem;
font-weight: 600;
}
.dialog-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.dialog-form label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: hsl(var(--muted-foreground));
}
.dialog-form input,
.dialog-form select {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
}
.dialog-form input:focus,
.dialog-form select:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.1);
}
.dialog-buttons {
display: flex;
gap: 0.75rem;
margin-top: 1.5rem;
}
.dialog-buttons button {
flex: 1;
}
/* ========================================
* Enhanced Markdown Content
* ======================================== */
.markdown-content a {
color: hsl(var(--primary));
text-decoration: underline;
}
.markdown-content a:hover {
opacity: 0.8;
}
.markdown-content table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
font-size: 0.875rem;
}
.markdown-content table th,
.markdown-content table td {
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--border));
}
.markdown-content table th {
background: hsl(var(--muted));
font-weight: 600;
}
.markdown-content pre {
background: hsl(var(--muted));
padding: 1rem;
border-radius: 0.375rem;
overflow-x: auto;
margin: 1rem 0;
}
.markdown-content pre code {
background: transparent;
padding: 0;
}
.markdown-content ul,
.markdown-content ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.markdown-content li {
margin: 0.25rem 0;
}
.task-list-item {
list-style: none;
margin-left: -1.5rem;
}
.task-list-item input[type="checkbox"] {
margin-right: 0.5rem;
}
/* ========================================
* Button Styles
* ======================================== */
.btn-danger {
background: hsl(0, 72%, 51%);
color: white;
border-color: hsl(0, 72%, 51%);
}
.btn-danger:hover:not(:disabled) {
background: hsl(0, 72%, 45%);
border-color: hsl(0, 72%, 45%);
}
/* ========================================
* Responsive Design
* ======================================== */
@media (max-width: 1400px) {
.claude-manager-columns {
grid-template-columns: 240px 1fr 280px;
}
}
@media (max-width: 1024px) {
.claude-manager-columns {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
}
.claude-manager-column.left {
max-height: 200px;
}
.claude-manager-column.right {
max-height: 300px;
}
}

View File

@@ -184,6 +184,9 @@ function renderCliStatus() {
</button>`
: `<button class="btn-sm btn-outline flex items-center gap-1" onclick="initCodexLensIndex()">
<i data-lucide="database" class="w-3 h-3"></i> Init Index
</button>
<button class="btn-sm btn-outline flex items-center gap-1" onclick="uninstallCodexLens()">
<i data-lucide="trash-2" class="w-3 h-3"></i> Uninstall
</button>`
}
</div>
@@ -379,8 +382,121 @@ async function refreshAllCliStatus() {
renderCliStatus();
}
async function installCodexLens() {
showRefreshToast('Installing CodexLens...', 'info');
function installCodexLens() {
openCodexLensInstallWizard();
}
function openCodexLensInstallWizard() {
const modal = document.createElement('div');
modal.id = 'codexlensInstallModal';
modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
modal.innerHTML = `
<div class="bg-card rounded-lg shadow-xl w-full max-w-md mx-4 overflow-hidden">
<div class="p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<i data-lucide="database" class="w-5 h-5 text-primary"></i>
</div>
<div>
<h3 class="text-lg font-semibold">Install CodexLens</h3>
<p class="text-sm text-muted-foreground">Python-based code indexing engine</p>
</div>
</div>
<div class="space-y-4">
<div class="bg-muted/50 rounded-lg p-4">
<h4 class="font-medium mb-2">What will be installed:</h4>
<ul class="text-sm space-y-2 text-muted-foreground">
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-success mt-0.5"></i>
<span><strong>Python virtual environment</strong> - Isolated Python environment</span>
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-success mt-0.5"></i>
<span><strong>CodexLens package</strong> - Code indexing and search engine</span>
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-success mt-0.5"></i>
<span><strong>SQLite FTS5</strong> - Full-text search database</span>
</li>
</ul>
</div>
<div class="bg-primary/5 border border-primary/20 rounded-lg p-3">
<div class="flex items-start gap-2">
<i data-lucide="info" class="w-4 h-4 text-primary mt-0.5"></i>
<div class="text-sm text-muted-foreground">
<p class="font-medium text-foreground">Installation Location</p>
<p class="mt-1"><code class="bg-muted px-1 rounded">~/.codexlens/venv</code></p>
<p class="mt-1">First installation may take 2-3 minutes to download and setup Python packages.</p>
</div>
</div>
</div>
<div id="codexlensInstallProgress" class="hidden">
<div class="flex items-center gap-3">
<div class="animate-spin w-5 h-5 border-2 border-primary border-t-transparent rounded-full"></div>
<span class="text-sm" id="codexlensInstallStatus">Starting installation...</span>
</div>
<div class="mt-2 h-2 bg-muted rounded-full overflow-hidden">
<div id="codexlensProgressBar" class="h-full bg-primary transition-all duration-300" style="width: 0%"></div>
</div>
</div>
</div>
</div>
<div class="border-t border-border p-4 flex justify-end gap-3 bg-muted/30">
<button class="btn-outline px-4 py-2" onclick="closeCodexLensInstallWizard()">Cancel</button>
<button id="codexlensInstallBtn" class="btn-primary px-4 py-2" onclick="startCodexLensInstall()">
<i data-lucide="download" class="w-4 h-4 mr-2"></i>
Install Now
</button>
</div>
</div>
`;
document.body.appendChild(modal);
if (window.lucide) {
lucide.createIcons();
}
}
function closeCodexLensInstallWizard() {
const modal = document.getElementById('codexlensInstallModal');
if (modal) {
modal.remove();
}
}
async function startCodexLensInstall() {
const progressDiv = document.getElementById('codexlensInstallProgress');
const installBtn = document.getElementById('codexlensInstallBtn');
const statusText = document.getElementById('codexlensInstallStatus');
const progressBar = document.getElementById('codexlensProgressBar');
// Show progress, disable button
progressDiv.classList.remove('hidden');
installBtn.disabled = true;
installBtn.innerHTML = '<span class="animate-pulse">Installing...</span>';
// Simulate progress stages
const stages = [
{ progress: 10, text: 'Creating virtual environment...' },
{ progress: 30, text: 'Installing pip packages...' },
{ progress: 50, text: 'Installing CodexLens package...' },
{ progress: 70, text: 'Setting up Python dependencies...' },
{ progress: 90, text: 'Finalizing installation...' }
];
let currentStage = 0;
const progressInterval = setInterval(() => {
if (currentStage < stages.length) {
statusText.textContent = stages[currentStage].text;
progressBar.style.width = `${stages[currentStage].progress}%`;
currentStage++;
}
}, 1500);
try {
const response = await fetch('/api/codexlens/bootstrap', {
@@ -389,40 +505,288 @@ async function installCodexLens() {
body: JSON.stringify({})
});
clearInterval(progressInterval);
const result = await response.json();
if (result.success) {
showRefreshToast('CodexLens installed successfully!', 'success');
await loadCodexLensStatus();
renderCliStatus();
progressBar.style.width = '100%';
statusText.textContent = 'Installation complete!';
setTimeout(() => {
closeCodexLensInstallWizard();
showRefreshToast('CodexLens installed successfully!', 'success');
loadCodexLensStatus().then(() => renderCliStatus());
}, 1000);
} else {
showRefreshToast(`Install failed: ${result.error}`, 'error');
statusText.textContent = `Error: ${result.error}`;
progressBar.classList.add('bg-destructive');
installBtn.disabled = false;
installBtn.innerHTML = '<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> Retry';
if (window.lucide) lucide.createIcons();
}
} catch (err) {
showRefreshToast(`Install error: ${err.message}`, 'error');
clearInterval(progressInterval);
statusText.textContent = `Error: ${err.message}`;
progressBar.classList.add('bg-destructive');
installBtn.disabled = false;
installBtn.innerHTML = '<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> Retry';
if (window.lucide) lucide.createIcons();
}
}
function uninstallCodexLens() {
openCodexLensUninstallWizard();
}
function openCodexLensUninstallWizard() {
const modal = document.createElement('div');
modal.id = 'codexlensUninstallModal';
modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
modal.innerHTML = `
<div class="bg-card rounded-lg shadow-xl w-full max-w-md mx-4 overflow-hidden">
<div class="p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
<i data-lucide="trash-2" class="w-5 h-5 text-destructive"></i>
</div>
<div>
<h3 class="text-lg font-semibold">Uninstall CodexLens</h3>
<p class="text-sm text-muted-foreground">Remove CodexLens and all data</p>
</div>
</div>
<div class="space-y-4">
<div class="bg-destructive/5 border border-destructive/20 rounded-lg p-4">
<h4 class="font-medium text-destructive mb-2">What will be removed:</h4>
<ul class="text-sm space-y-2 text-muted-foreground">
<li class="flex items-start gap-2">
<i data-lucide="x" class="w-4 h-4 text-destructive mt-0.5"></i>
<span>Virtual environment at <code class="bg-muted px-1 rounded">~/.codexlens/venv</code></span>
</li>
<li class="flex items-start gap-2">
<i data-lucide="x" class="w-4 h-4 text-destructive mt-0.5"></i>
<span>All CodexLens indexed data and databases</span>
</li>
<li class="flex items-start gap-2">
<i data-lucide="x" class="w-4 h-4 text-destructive mt-0.5"></i>
<span>Configuration and semantic search models</span>
</li>
</ul>
</div>
<div class="bg-warning/10 border border-warning/20 rounded-lg p-3">
<div class="flex items-start gap-2">
<i data-lucide="alert-triangle" class="w-4 h-4 text-warning mt-0.5"></i>
<div class="text-sm">
<p class="font-medium text-warning">Warning</p>
<p class="text-muted-foreground">This action cannot be undone. All indexed code data will be permanently deleted.</p>
</div>
</div>
</div>
<div id="codexlensUninstallProgress" class="hidden">
<div class="flex items-center gap-3">
<div class="animate-spin w-5 h-5 border-2 border-destructive border-t-transparent rounded-full"></div>
<span class="text-sm" id="codexlensUninstallStatus">Removing files...</span>
</div>
<div class="mt-2 h-2 bg-muted rounded-full overflow-hidden">
<div id="codexlensUninstallProgressBar" class="h-full bg-destructive transition-all duration-300" style="width: 0%"></div>
</div>
</div>
</div>
</div>
<div class="border-t border-border p-4 flex justify-end gap-3 bg-muted/30">
<button class="btn-outline px-4 py-2" onclick="closeCodexLensUninstallWizard()">Cancel</button>
<button id="codexlensUninstallBtn" class="btn-destructive px-4 py-2" onclick="startCodexLensUninstall()">
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
Uninstall
</button>
</div>
</div>
`;
document.body.appendChild(modal);
if (window.lucide) {
lucide.createIcons();
}
}
function closeCodexLensUninstallWizard() {
const modal = document.getElementById('codexlensUninstallModal');
if (modal) {
modal.remove();
}
}
async function startCodexLensUninstall() {
const progressDiv = document.getElementById('codexlensUninstallProgress');
const uninstallBtn = document.getElementById('codexlensUninstallBtn');
const statusText = document.getElementById('codexlensUninstallStatus');
const progressBar = document.getElementById('codexlensUninstallProgressBar');
// Show progress, disable button
progressDiv.classList.remove('hidden');
uninstallBtn.disabled = true;
uninstallBtn.innerHTML = '<span class="animate-pulse">Uninstalling...</span>';
// Simulate progress stages
const stages = [
{ progress: 25, text: 'Removing virtual environment...' },
{ progress: 50, text: 'Deleting indexed data...' },
{ progress: 75, text: 'Cleaning up configuration...' },
{ progress: 90, text: 'Finalizing removal...' }
];
let currentStage = 0;
const progressInterval = setInterval(() => {
if (currentStage < stages.length) {
statusText.textContent = stages[currentStage].text;
progressBar.style.width = `${stages[currentStage].progress}%`;
currentStage++;
}
}, 500);
try {
const response = await fetch('/api/codexlens/uninstall', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
clearInterval(progressInterval);
const result = await response.json();
if (result.success) {
progressBar.style.width = '100%';
statusText.textContent = 'Uninstallation complete!';
setTimeout(() => {
closeCodexLensUninstallWizard();
showRefreshToast('CodexLens uninstalled successfully!', 'success');
loadCodexLensStatus().then(() => renderCliStatus());
}, 1000);
} else {
statusText.textContent = `Error: ${result.error}`;
progressBar.classList.remove('bg-destructive');
progressBar.classList.add('bg-destructive');
uninstallBtn.disabled = false;
uninstallBtn.innerHTML = '<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> Retry';
if (window.lucide) lucide.createIcons();
}
} catch (err) {
clearInterval(progressInterval);
statusText.textContent = `Error: ${err.message}`;
progressBar.classList.remove('bg-destructive');
progressBar.classList.add('bg-destructive');
uninstallBtn.disabled = false;
uninstallBtn.innerHTML = '<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> Retry';
if (window.lucide) lucide.createIcons();
}
}
async function initCodexLensIndex() {
// Get current workspace path from multiple sources
let targetPath = null;
// Helper function to check if path is valid
const isValidPath = (path) => {
return path && typeof path === 'string' && path.length > 0 &&
(path.includes('/') || path.includes('\\')) &&
!path.startsWith('{{') && !path.endsWith('}}');
};
console.log('[CodexLens] Attempting to get project path...');
// Try 1: Global projectPath variable
if (isValidPath(projectPath)) {
targetPath = projectPath;
console.log('[CodexLens] ✓ Using global projectPath:', targetPath);
}
// Try 2: Get from workflowData
if (!targetPath && typeof workflowData !== 'undefined' && workflowData && isValidPath(workflowData.projectPath)) {
targetPath = workflowData.projectPath;
console.log('[CodexLens] ✓ Using workflowData.projectPath:', targetPath);
}
// Try 3: Get from current path display element
if (!targetPath) {
const currentPathEl = document.getElementById('currentPath');
if (currentPathEl && currentPathEl.textContent) {
const pathText = currentPathEl.textContent.trim();
if (isValidPath(pathText)) {
targetPath = pathText;
console.log('[CodexLens] ✓ Using currentPath element text:', targetPath);
}
}
}
// Final validation
if (!targetPath) {
showRefreshToast('Error: No workspace loaded. Please open a workspace first.', 'error');
console.error('[CodexLens] No valid project path available');
console.error('[CodexLens] Attempted sources: projectPath:', projectPath, 'workflowData:', workflowData);
return;
}
showRefreshToast('Initializing CodexLens index...', 'info');
console.log('[CodexLens] Initializing index for path:', targetPath);
try {
const response = await fetch('/api/codexlens/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: projectPath })
body: JSON.stringify({ path: targetPath })
});
const result = await response.json();
console.log('[CodexLens] Init result:', result);
if (result.success) {
const data = result.result?.result || result.result || result;
let data = null;
// Try to parse nested JSON in output field
if (result.output && typeof result.output === 'string') {
try {
// Extract JSON from output (it may contain other text before the JSON)
const jsonMatch = result.output.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
data = parsed.result || parsed;
console.log('[CodexLens] Parsed from output:', data);
}
} catch (e) {
console.warn('[CodexLens] Failed to parse output as JSON:', e);
}
}
// Fallback to direct result field
if (!data) {
data = result.result?.result || result.result || result;
}
const files = data.files_indexed || 0;
const dirs = data.dirs_indexed || 0;
const symbols = data.symbols_indexed || 0;
showRefreshToast(`Index created: ${files} files, ${symbols} symbols`, 'success');
console.log('[CodexLens] Parsed data:', { files, dirs, symbols });
if (files === 0 && dirs === 0) {
showRefreshToast(`Warning: No files indexed. Path: ${targetPath}`, 'warning');
console.warn('[CodexLens] No files indexed. Full data:', data);
} else {
showRefreshToast(`Index created: ${files} files, ${dirs} directories`, 'success');
console.log('[CodexLens] Index created successfully');
}
} else {
showRefreshToast(`Init failed: ${result.error}`, 'error');
console.error('[CodexLens] Init error:', result.error);
}
} catch (err) {
showRefreshToast(`Init error: ${err.message}`, 'error');
console.error('[CodexLens] Exception:', err);
}
}
@@ -591,7 +955,7 @@ function openSemanticSettingsModal() {
tool.charAt(0).toUpperCase() + tool.slice(1) + '</option>';
}).join('');
const fallbackOptions = '<option value="">None</option>' + availableTools.map(function(tool) {
const fallbackOptions = '<option value="">' + t('semantic.none') + '</option>' + availableTools.map(function(tool) {
return '<option value="' + tool + '"' + (llmEnhancementSettings.fallbackTool === tool ? ' selected' : '') + '>' +
tool.charAt(0).toUpperCase() + tool.slice(1) + '</option>';
}).join('');
@@ -607,16 +971,16 @@ function openSemanticSettingsModal() {
'<i data-lucide="sparkles" class="w-5 h-5 text-primary"></i>' +
'</div>' +
'<div>' +
'<h3 class="text-lg font-semibold">Semantic Search Settings</h3>' +
'<p class="text-sm text-muted-foreground">Configure LLM enhancement for semantic indexing</p>' +
'<h3 class="text-lg font-semibold">' + t('semantic.settings') + '</h3>' +
'<p class="text-sm text-muted-foreground">' + t('semantic.configDesc') + '</p>' +
'</div>' +
'</div>' +
'<div class="space-y-4">' +
'<div class="flex items-center justify-between p-4 bg-muted/50 rounded-lg">' +
'<div>' +
'<h4 class="font-medium flex items-center gap-2">' +
'<i data-lucide="brain" class="w-4 h-4"></i>LLM Enhancement</h4>' +
'<p class="text-sm text-muted-foreground mt-1">Use LLM to generate code summaries for better semantic search</p>' +
'<i data-lucide="brain" class="w-4 h-4"></i>' + t('semantic.llmEnhancement') + '</h4>' +
'<p class="text-sm text-muted-foreground mt-1">' + t('semantic.llmDesc') + '</p>' +
'</div>' +
'<label class="cli-toggle">' +
'<input type="checkbox" id="llmEnhancementToggle" ' + (llmEnhancementSettings.enabled ? 'checked' : '') +
@@ -628,29 +992,29 @@ function openSemanticSettingsModal() {
'<div class="grid grid-cols-2 gap-4">' +
'<div>' +
'<label class="block text-sm font-medium mb-2">' +
'<i data-lucide="cpu" class="w-3 h-3 inline mr-1"></i>Primary LLM Tool</label>' +
'<i data-lucide="cpu" class="w-3 h-3 inline mr-1"></i>' + t('semantic.primaryTool') + '</label>' +
'<select class="cli-setting-select w-full" id="llmToolSelect" onchange="updateLlmTool(this.value)" ' + disabled + '>' + toolOptions + '</select>' +
'</div>' +
'<div>' +
'<label class="block text-sm font-medium mb-2">' +
'<i data-lucide="refresh-cw" class="w-3 h-3 inline mr-1"></i>Fallback Tool</label>' +
'<i data-lucide="refresh-cw" class="w-3 h-3 inline mr-1"></i>' + t('semantic.fallbackTool') + '</label>' +
'<select class="cli-setting-select w-full" id="llmFallbackSelect" onchange="updateLlmFallback(this.value)" ' + disabled + '>' + fallbackOptions + '</select>' +
'</div>' +
'</div>' +
'<div class="grid grid-cols-2 gap-4">' +
'<div>' +
'<label class="block text-sm font-medium mb-2">' +
'<i data-lucide="layers" class="w-3 h-3 inline mr-1"></i>Batch Size</label>' +
'<i data-lucide="layers" class="w-3 h-3 inline mr-1"></i>' + t('semantic.batchSize') + '</label>' +
'<select class="cli-setting-select w-full" id="llmBatchSelect" onchange="updateLlmBatchSize(this.value)" ' + disabled + '>' +
'<option value="1"' + (llmEnhancementSettings.batchSize === 1 ? ' selected' : '') + '>1 file</option>' +
'<option value="3"' + (llmEnhancementSettings.batchSize === 3 ? ' selected' : '') + '>3 files</option>' +
'<option value="5"' + (llmEnhancementSettings.batchSize === 5 ? ' selected' : '') + '>5 files</option>' +
'<option value="10"' + (llmEnhancementSettings.batchSize === 10 ? ' selected' : '') + '>10 files</option>' +
'<option value="1"' + (llmEnhancementSettings.batchSize === 1 ? ' selected' : '') + '>1 ' + t('semantic.file') + '</option>' +
'<option value="3"' + (llmEnhancementSettings.batchSize === 3 ? ' selected' : '') + '>3 ' + t('semantic.files') + '</option>' +
'<option value="5"' + (llmEnhancementSettings.batchSize === 5 ? ' selected' : '') + '>5 ' + t('semantic.files') + '</option>' +
'<option value="10"' + (llmEnhancementSettings.batchSize === 10 ? ' selected' : '') + '>10 ' + t('semantic.files') + '</option>' +
'</select>' +
'</div>' +
'<div>' +
'<label class="block text-sm font-medium mb-2">' +
'<i data-lucide="clock" class="w-3 h-3 inline mr-1"></i>Timeout</label>' +
'<i data-lucide="clock" class="w-3 h-3 inline mr-1"></i>' + t('semantic.timeout') + '</label>' +
'<select class="cli-setting-select w-full" id="llmTimeoutSelect" onchange="updateLlmTimeout(this.value)" ' + disabled + '>' +
'<option value="60000"' + (llmEnhancementSettings.timeoutMs === 60000 ? ' selected' : '') + '>1 min</option>' +
'<option value="180000"' + (llmEnhancementSettings.timeoutMs === 180000 ? ' selected' : '') + '>3 min</option>' +
@@ -664,26 +1028,110 @@ function openSemanticSettingsModal() {
'<div class="flex items-start gap-2">' +
'<i data-lucide="info" class="w-4 h-4 text-primary mt-0.5"></i>' +
'<div class="text-sm text-muted-foreground">' +
'<p>LLM enhancement generates code summaries and keywords for each file, improving semantic search accuracy.</p>' +
'<p class="mt-1">Run <code class="bg-muted px-1 rounded">codex-lens enhance</code> after enabling to process existing files.</p>' +
'<p>' + t('semantic.enhanceInfo') + '</p>' +
'<p class="mt-1">' + t('semantic.enhanceCommand') + ' <code class="bg-muted px-1 rounded">codex-lens enhance</code> ' + t('semantic.enhanceAfterEnable') + '</p>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="flex gap-2 pt-2">' +
'<button class="btn-sm btn-outline flex items-center gap-1 flex-1" onclick="runEnhanceCommand()" ' + disabled + '>' +
'<i data-lucide="zap" class="w-3 h-3"></i>Run Enhance Now</button>' +
'<i data-lucide="zap" class="w-3 h-3"></i>' + t('semantic.runEnhanceNow') + '</button>' +
'<button class="btn-sm btn-outline flex items-center gap-1 flex-1" onclick="viewEnhanceStatus()">' +
'<i data-lucide="bar-chart-2" class="w-3 h-3"></i>View Status</button>' +
'<i data-lucide="bar-chart-2" class="w-3 h-3"></i>' + t('semantic.viewStatus') + '</button>' +
'</div>' +
'<div class="border-t border-border my-4"></div>' +
'<div>' +
'<h4 class="font-medium mb-3 flex items-center gap-2">' +
'<i data-lucide="search" class="w-4 h-4"></i>' + t('semantic.testSearch') + '</h4>' +
'<div class="space-y-3">' +
'<div>' +
'<input type="text" id="semanticSearchInput" class="tool-config-input w-full" ' +
'placeholder="' + t('semantic.searchPlaceholder') + '" />' +
'</div>' +
'<div>' +
'<button class="btn-sm btn-primary w-full" id="runSemanticSearchBtn">' +
'<i data-lucide="search" class="w-3 h-3"></i> ' + t('semantic.runSearch') +
'</button>' +
'</div>' +
'<div id="semanticSearchResults" class="hidden">' +
'<div class="bg-muted/30 rounded-lg p-3 max-h-64 overflow-y-auto">' +
'<div class="flex items-center justify-between mb-2">' +
'<p class="text-sm font-medium">' + t('codexlens.results') + ':</p>' +
'<span id="semanticResultCount" class="text-xs text-muted-foreground"></span>' +
'</div>' +
'<pre id="semanticResultContent" class="text-xs font-mono whitespace-pre-wrap break-all"></pre>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="border-t border-border p-4 flex justify-end gap-3 bg-muted/30">' +
'<button class="btn-outline px-4 py-2" onclick="closeSemanticSettingsModal()">Close</button>' +
'<button class="btn-outline px-4 py-2" onclick="closeSemanticSettingsModal()">' + t('semantic.close') + '</button>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
// Add semantic search button handler
setTimeout(function() {
var runSemanticSearchBtn = document.getElementById('runSemanticSearchBtn');
if (runSemanticSearchBtn) {
runSemanticSearchBtn.onclick = async function() {
var query = document.getElementById('semanticSearchInput').value.trim();
var resultsDiv = document.getElementById('semanticSearchResults');
var resultCount = document.getElementById('semanticResultCount');
var resultContent = document.getElementById('semanticResultContent');
if (!query) {
showRefreshToast(t('codexlens.enterQuery'), 'warning');
return;
}
runSemanticSearchBtn.disabled = true;
runSemanticSearchBtn.innerHTML = '<span class="animate-pulse">' + t('codexlens.searching') + '</span>';
resultsDiv.classList.add('hidden');
try {
var params = new URLSearchParams({
query: query,
mode: 'semantic',
limit: '10'
});
var response = await fetch('/api/codexlens/search?' + params.toString());
var result = await response.json();
console.log('[Semantic Search Test] Result:', result);
if (result.success) {
var results = result.results || [];
resultCount.textContent = results.length + ' ' + t('codexlens.resultsCount');
resultContent.textContent = JSON.stringify(results, null, 2);
resultsDiv.classList.remove('hidden');
showRefreshToast(t('codexlens.searchCompleted') + ': ' + results.length + ' ' + t('codexlens.resultsCount'), 'success');
} else {
resultContent.textContent = t('common.error') + ': ' + (result.error || t('common.unknownError'));
resultsDiv.classList.remove('hidden');
showRefreshToast(t('codexlens.searchFailed') + ': ' + result.error, 'error');
}
runSemanticSearchBtn.disabled = false;
runSemanticSearchBtn.innerHTML = '<i data-lucide="search" class="w-3 h-3"></i> ' + t('semantic.runSearch');
if (window.lucide) lucide.createIcons();
} catch (err) {
console.error('[Semantic Search Test] Error:', err);
resultContent.textContent = t('common.exception') + ': ' + err.message;
resultsDiv.classList.remove('hidden');
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
runSemanticSearchBtn.disabled = false;
runSemanticSearchBtn.innerHTML = '<i data-lucide="search" class="w-3 h-3"></i> ' + t('semantic.runSearch');
if (window.lucide) lucide.createIcons();
}
};
}
}, 100);
var handleEscape = function(e) {
if (e.key === 'Escape') {
closeSemanticSettingsModal();
@@ -713,37 +1161,37 @@ function toggleLlmEnhancement(enabled) {
}
renderCliStatus();
showRefreshToast('LLM Enhancement ' + (enabled ? 'enabled' : 'disabled'), 'success');
showRefreshToast(t('semantic.llmEnhancement') + ' ' + (enabled ? t('semantic.enabled') : t('semantic.disabled')), 'success');
}
function updateLlmTool(tool) {
llmEnhancementSettings.tool = tool;
localStorage.setItem('ccw-llm-enhancement-tool', tool);
showRefreshToast('Primary LLM tool set to ' + tool, 'success');
showRefreshToast(t('semantic.toolSetTo') + ' ' + tool, 'success');
}
function updateLlmFallback(tool) {
llmEnhancementSettings.fallbackTool = tool;
localStorage.setItem('ccw-llm-enhancement-fallback', tool);
showRefreshToast('Fallback tool set to ' + (tool || 'none'), 'success');
showRefreshToast(t('semantic.fallbackSetTo') + ' ' + (tool || t('semantic.none')), 'success');
}
function updateLlmBatchSize(size) {
llmEnhancementSettings.batchSize = parseInt(size, 10);
localStorage.setItem('ccw-llm-enhancement-batch-size', size);
showRefreshToast('Batch size set to ' + size + ' files', 'success');
showRefreshToast(t('semantic.batchSetTo') + ' ' + size + ' ' + t('semantic.files'), 'success');
}
function updateLlmTimeout(ms) {
llmEnhancementSettings.timeoutMs = parseInt(ms, 10);
localStorage.setItem('ccw-llm-enhancement-timeout', ms);
var mins = parseInt(ms, 10) / 60000;
showRefreshToast('Timeout set to ' + mins + ' minute' + (mins > 1 ? 's' : ''), 'success');
showRefreshToast(t('semantic.timeoutSetTo') + ' ' + mins + ' ' + (mins > 1 ? t('semantic.minutes') : t('semantic.minute')), 'success');
}
async function runEnhanceCommand() {
if (!llmEnhancementSettings.enabled) {
showRefreshToast('Please enable LLM Enhancement first', 'warning');
showRefreshToast(t('semantic.enableFirst'), 'warning');
return;
}

View File

@@ -142,6 +142,59 @@ async function removeMcpServerFromProject(serverName) {
}
}
async function addGlobalMcpServer(serverName, serverConfig) {
try {
const response = await fetch('/api/mcp-add-global-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverName: serverName,
serverConfig: serverConfig
})
});
if (!response.ok) throw new Error('Failed to add global MCP server');
const result = await response.json();
if (result.success) {
await loadMcpConfig();
renderMcpManager();
showRefreshToast(`Global MCP server "${serverName}" added`, 'success');
}
return result;
} catch (err) {
console.error('Failed to add global MCP server:', err);
showRefreshToast(`Failed to add global MCP server: ${err.message}`, 'error');
return null;
}
}
async function removeGlobalMcpServer(serverName) {
try {
const response = await fetch('/api/mcp-remove-global-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverName: serverName
})
});
if (!response.ok) throw new Error('Failed to remove global MCP server');
const result = await response.json();
if (result.success) {
await loadMcpConfig();
renderMcpManager();
showRefreshToast(`Global MCP server "${serverName}" removed`, 'success');
}
return result;
} catch (err) {
console.error('Failed to remove global MCP server:', err);
showRefreshToast(`Failed to remove global MCP server: ${err.message}`, 'error');
return null;
}
}
// ========== Badge Update ==========
function updateMcpBadge() {
const badge = document.getElementById('badgeMcpServers');
@@ -260,8 +313,40 @@ function isServerInCurrentProject(serverName) {
return serverName in servers;
}
// Generate install command for MCP server
function generateMcpInstallCommand(serverName, serverConfig, scope = 'project') {
const command = serverConfig.command || '';
const args = serverConfig.args || [];
// Check if it's an npx-based package
if (command === 'npx' && args.length > 0) {
const packageName = args[0];
// Check if it's a scoped package or standard package
if (packageName.startsWith('@') || packageName.includes('/')) {
const scopeFlag = scope === 'global' ? ' --global' : '';
return `claude mcp add ${packageName}${scopeFlag}`;
}
}
// For custom servers, return JSON configuration
const scopeFlag = scope === 'global' ? ' --global' : '';
return `claude mcp add ${serverName}${scopeFlag}`;
}
// Copy install command to clipboard
async function copyMcpInstallCommand(serverName, serverConfig, scope = 'project') {
try {
const command = generateMcpInstallCommand(serverName, serverConfig, scope);
await navigator.clipboard.writeText(command);
showRefreshToast(t('mcp.installCmdCopied'), 'success');
} catch (error) {
console.error('Failed to copy install command:', error);
showRefreshToast(t('mcp.installCmdFailed'), 'error');
}
}
// ========== MCP Create Modal ==========
function openMcpCreateModal() {
function openMcpCreateModal(scope = 'project') {
const modal = document.getElementById('mcpCreateModal');
if (modal) {
modal.classList.remove('hidden');
@@ -276,6 +361,11 @@ function openMcpCreateModal() {
// Clear JSON input
document.getElementById('mcpServerJson').value = '';
document.getElementById('mcpJsonPreview').classList.add('hidden');
// Set scope (global or project)
const scopeSelect = document.getElementById('mcpServerScope');
if (scopeSelect) {
scopeSelect.value = scope;
}
// Focus on name input
document.getElementById('mcpServerName').focus();
// Setup JSON input listener
@@ -427,6 +517,8 @@ async function submitMcpCreateFromForm() {
const command = document.getElementById('mcpServerCommand').value.trim();
const argsText = document.getElementById('mcpServerArgs').value.trim();
const envText = document.getElementById('mcpServerEnv').value.trim();
const scopeSelect = document.getElementById('mcpServerScope');
const scope = scopeSelect ? scopeSelect.value : 'project';
// Validate required fields
if (!name) {
@@ -471,7 +563,7 @@ async function submitMcpCreateFromForm() {
serverConfig.env = env;
}
await createMcpServerWithConfig(name, serverConfig);
await createMcpServerWithConfig(name, serverConfig, scope);
}
async function submitMcpCreateFromJson() {
@@ -550,18 +642,30 @@ async function submitMcpCreateFromJson() {
}
}
async function createMcpServerWithConfig(name, serverConfig) {
async function createMcpServerWithConfig(name, serverConfig, scope = 'project') {
// Submit to API
try {
const response = await fetch('/api/mcp-copy-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
serverName: name,
serverConfig: serverConfig
})
});
let response;
if (scope === 'global') {
response = await fetch('/api/mcp-add-global-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverName: name,
serverConfig: serverConfig
})
});
} else {
response = await fetch('/api/mcp-copy-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
serverName: name,
serverConfig: serverConfig
})
});
}
if (!response.ok) throw new Error('Failed to create MCP server');
@@ -570,7 +674,8 @@ async function createMcpServerWithConfig(name, serverConfig) {
closeMcpCreateModal();
await loadMcpConfig();
renderMcpManager();
showRefreshToast(`MCP server "${name}" created successfully`, 'success');
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');
}

View File

@@ -112,6 +112,8 @@ function initNavigation() {
renderSkillsManager();
} else if (currentView === 'rules-manager') {
renderRulesManager();
} else if (currentView === 'claude-manager') {
renderClaudeManager();
}
});
});
@@ -144,6 +146,8 @@ function updateContentTitle() {
titleEl.textContent = t('title.skillsManager');
} else if (currentView === 'rules-manager') {
titleEl.textContent = t('title.rulesManager');
} else if (currentView === 'claude-manager') {
titleEl.textContent = t('title.claudeManager');
} else if (currentView === 'liteTasks') {
const names = { 'lite-plan': t('title.litePlanSessions'), 'lite-fix': t('title.liteFixSessions') };
titleEl.textContent = names[currentLiteType] || t('title.liteTasks');

View File

@@ -214,13 +214,13 @@ function handleNotification(data) {
if (typeof handleMemoryUpdated === 'function') {
handleMemoryUpdated(payload);
}
// Force refresh of memory view if active
if (getCurrentView && getCurrentView() === 'memory') {
if (typeof loadMemoryStats === 'function') {
loadMemoryStats().then(function() {
if (typeof renderHotspotsColumn === 'function') renderHotspotsColumn();
});
}
// Force refresh of memory view
if (typeof loadMemoryStats === 'function') {
loadMemoryStats().then(function() {
if (typeof renderHotspotsColumn === 'function') renderHotspotsColumn();
}).catch(function(err) {
console.error('[Memory] Failed to refresh stats:', err);
});
}
break;
@@ -254,15 +254,142 @@ function handleNotification(data) {
'Memory'
);
}
// Refresh Active Memory status if on memory view
if (getCurrentView && getCurrentView() === 'memory') {
if (typeof loadActiveMemoryStatus === 'function') {
loadActiveMemoryStatus();
}
// Refresh Active Memory status
if (typeof loadActiveMemoryStatus === 'function') {
loadActiveMemoryStatus().catch(function(err) {
console.error('[Active Memory] Failed to refresh status:', err);
});
}
console.log('[Active Memory] Sync completed:', payload);
break;
case 'CLAUDE_FILE_SYNCED':
// Handle CLAUDE.md file sync completion
if (typeof addGlobalNotification === 'function') {
const { path, level, tool, mode } = payload;
const fileName = path.split(/[/\\]/).pop();
addGlobalNotification(
'success',
`${fileName} synced`,
{
'Level': level,
'Tool': tool,
'Mode': mode,
'Time': new Date(payload.timestamp).toLocaleTimeString()
},
'CLAUDE.md'
);
}
// Refresh file list
if (typeof loadClaudeFiles === 'function') {
loadClaudeFiles().then(() => {
// Re-render the view to show updated content
if (typeof renderClaudeManager === 'function') {
renderClaudeManager();
}
}).catch(err => console.error('[CLAUDE.md] Failed to refresh files:', err));
}
console.log('[CLAUDE.md] Sync completed:', payload);
break;
case 'CLI_TOOL_INSTALLED':
// Handle CLI tool installation completion
if (typeof addGlobalNotification === 'function') {
const { tool } = payload;
addGlobalNotification(
'success',
`${tool} installed successfully`,
{
'Tool': tool,
'Time': new Date(payload.timestamp).toLocaleTimeString()
},
'CLI Tools'
);
}
// Refresh CLI manager
if (typeof loadCliToolStatus === 'function') {
loadCliToolStatus().then(() => {
if (typeof renderToolsSection === 'function') {
renderToolsSection();
}
}).catch(err => console.error('[CLI Tools] Failed to refresh status:', err));
}
console.log('[CLI Tools] Installation completed:', payload);
break;
case 'CLI_TOOL_UNINSTALLED':
// Handle CLI tool uninstallation completion
if (typeof addGlobalNotification === 'function') {
const { tool } = payload;
addGlobalNotification(
'success',
`${tool} uninstalled successfully`,
{
'Tool': tool,
'Time': new Date(payload.timestamp).toLocaleTimeString()
},
'CLI Tools'
);
}
// Refresh CLI manager
if (typeof loadCliToolStatus === 'function') {
loadCliToolStatus().then(() => {
if (typeof renderToolsSection === 'function') {
renderToolsSection();
}
}).catch(err => console.error('[CLI Tools] Failed to refresh status:', err));
}
console.log('[CLI Tools] Uninstallation completed:', payload);
break;
case 'CODEXLENS_INSTALLED':
// Handle CodexLens installation completion
if (typeof addGlobalNotification === 'function') {
const { version } = payload;
addGlobalNotification(
'success',
`CodexLens installed successfully`,
{
'Version': version || 'latest',
'Time': new Date(payload.timestamp).toLocaleTimeString()
},
'CodexLens'
);
}
// Refresh CLI status if active
if (typeof loadCodexLensStatus === 'function') {
loadCodexLensStatus().then(() => {
if (typeof renderCliStatus === 'function') {
renderCliStatus();
}
});
}
console.log('[CodexLens] Installation completed:', payload);
break;
case 'CODEXLENS_UNINSTALLED':
// Handle CodexLens uninstallation completion
if (typeof addGlobalNotification === 'function') {
addGlobalNotification(
'success',
`CodexLens uninstalled successfully`,
{
'Time': new Date(payload.timestamp).toLocaleTimeString()
},
'CodexLens'
);
}
// Refresh CLI status if active
if (typeof loadCodexLensStatus === 'function') {
loadCodexLensStatus().then(() => {
if (typeof renderCliStatus === 'function') {
renderCliStatus();
}
});
}
console.log('[CodexLens] Uninstallation completed:', payload);
break;
default:
console.log('[WS] Unknown notification type:', type);
}

View File

@@ -107,6 +107,8 @@ const i18n = {
'empty.noLiteSessionsText': 'No sessions found in .workflow/.{type}/',
'empty.noMcpServers': 'No MCP servers configured for this project',
'empty.addMcpServersHint': 'Add servers from the available list below',
'empty.noGlobalMcpServers': 'No global MCP servers configured',
'empty.globalServersHint': 'Global servers are available to all projects from ~/.claude.json',
'empty.noAdditionalMcp': 'No additional MCP servers found in other projects',
'empty.noHooks': 'No hooks configured for this project',
'empty.createHookHint': 'Create a hook to automate actions on tool usage',
@@ -197,6 +199,7 @@ const i18n = {
'cli.setDefault': 'Set Default',
'cli.default': 'Default',
'cli.install': 'Install',
'cli.uninstall': 'Uninstall',
'cli.initIndex': 'Init Index',
'cli.geminiDesc': 'Google AI for code analysis',
'cli.qwenDesc': 'Alibaba AI assistant',
@@ -205,6 +208,80 @@ const i18n = {
'cli.codexLensDescFull': 'Full-text code search engine',
'cli.semanticDesc': 'AI-powered code understanding',
'cli.semanticDescFull': 'Natural language code search',
// CodexLens Configuration
'codexlens.config': 'CodexLens Configuration',
'codexlens.status': 'Status',
'codexlens.installed': 'Installed',
'codexlens.notInstalled': 'Not Installed',
'codexlens.indexes': 'Indexes',
'codexlens.currentWorkspace': 'Current Workspace',
'codexlens.indexStoragePath': 'Index Storage Path',
'codexlens.whereIndexesStored': 'where indexes are stored',
'codexlens.currentPath': 'Current Path',
'codexlens.newStoragePath': 'New Storage Path',
'codexlens.pathPlaceholder': 'e.g., /path/to/indexes or ~/.codexlens/indexes',
'codexlens.pathInfo': 'Supports ~ for home directory. Changes take effect immediately.',
'codexlens.migrationRequired': 'Migration Required',
'codexlens.migrationWarning': 'After changing the path, existing indexes will need to be re-initialized for each workspace.',
'codexlens.actions': 'Actions',
'codexlens.initializeIndex': 'Initialize Index',
'codexlens.cleanAllIndexes': 'Clean All Indexes',
'codexlens.installCodexLens': 'Install CodexLens',
'codexlens.testSearch': 'Test Search',
'codexlens.testFunctionality': 'test CodexLens functionality',
'codexlens.textSearch': 'Text Search',
'codexlens.fileSearch': 'File Search',
'codexlens.symbolSearch': 'Symbol Search',
'codexlens.searchPlaceholder': 'Enter search query (e.g., function name, file path, code snippet)',
'codexlens.runSearch': 'Run Search',
'codexlens.results': 'Results',
'codexlens.resultsCount': 'results',
'codexlens.saveConfig': 'Save Configuration',
'codexlens.searching': 'Searching...',
'codexlens.searchCompleted': 'Search completed',
'codexlens.searchFailed': 'Search failed',
'codexlens.enterQuery': 'Please enter a search query',
'codexlens.configSaved': 'Configuration saved successfully',
'codexlens.pathEmpty': 'Index directory path cannot be empty',
'codexlens.cleanConfirm': 'Are you sure you want to clean all CodexLens indexes? This cannot be undone.',
'codexlens.cleaning': 'Cleaning indexes...',
'codexlens.cleanSuccess': 'All indexes cleaned successfully',
'codexlens.cleanFailed': 'Failed to clean indexes',
'codexlens.loadingConfig': 'Loading configuration...',
// Semantic Search Configuration
'semantic.settings': 'Semantic Search Settings',
'semantic.configDesc': 'Configure LLM enhancement for semantic indexing',
'semantic.llmEnhancement': 'LLM Enhancement',
'semantic.llmDesc': 'Use LLM to generate code summaries for better semantic search',
'semantic.primaryTool': 'Primary LLM Tool',
'semantic.fallbackTool': 'Fallback Tool',
'semantic.batchSize': 'Batch Size',
'semantic.timeout': 'Timeout',
'semantic.file': 'file',
'semantic.files': 'files',
'semantic.enhanceInfo': 'LLM enhancement generates code summaries and keywords for each file, improving semantic search accuracy.',
'semantic.enhanceCommand': 'Run',
'semantic.enhanceAfterEnable': 'after enabling to process existing files.',
'semantic.runEnhanceNow': 'Run Enhance Now',
'semantic.viewStatus': 'View Status',
'semantic.testSearch': 'Test Semantic Search',
'semantic.searchPlaceholder': 'Enter semantic query (e.g., authentication logic, error handling)',
'semantic.runSearch': 'Run Semantic Search',
'semantic.close': 'Close',
'semantic.enabled': 'enabled',
'semantic.disabled': 'disabled',
'semantic.toolSetTo': 'Primary LLM tool set to',
'semantic.fallbackSetTo': 'Fallback tool set to',
'semantic.none': 'none',
'semantic.llmEnhancement': 'LLM Enhancement',
'semantic.batchSetTo': 'Batch size set to',
'semantic.timeoutSetTo': 'Timeout set to',
'semantic.minute': 'minute',
'semantic.minutes': 'minutes',
'semantic.enableFirst': 'Please enable LLM Enhancement first',
'cli.settings': 'CLI Execution Settings',
'cli.promptFormat': 'Prompt Format',
'cli.promptFormatDesc': 'Format for multi-turn conversation concatenation',
@@ -296,9 +373,18 @@ const i18n = {
'updateClaudeMd.cancel': 'Cancel',
// MCP Manager
'mcp.currentProject': 'Current Project MCP Servers',
'mcp.currentAvailable': 'Current Available MCP',
'mcp.projectAvailable': 'Current Available MCP',
'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.serversConfigured': 'servers configured',
'mcp.serversAvailable': 'servers available',
'mcp.globalAvailable': '全局可用 MCP',
'mcp.globalServersFrom': '个服务器来自 ~/.claude.json',
'mcp.enterprise': 'Enterprise MCP Servers',
'mcp.enterpriseManaged': 'Managed',
'mcp.enterpriseReadOnly': 'servers (read-only)',
@@ -315,21 +401,28 @@ const i18n = {
'mcp.noMcpServers': 'No MCP servers',
'mcp.add': 'Add',
'mcp.addToProject': 'Add to Project',
'mcp.installToProject': 'Install to project',
'mcp.installToGlobal': 'Install to global',
'mcp.removeFromProject': 'Remove from project',
'mcp.removeConfirm': 'Remove MCP server "{name}" from this project?',
'mcp.removeGlobal': 'Remove from global scope',
'mcp.removeGlobalConfirm': 'Remove global MCP server "{name}"? This will affect all projects.',
'mcp.readOnly': 'Read-only',
'mcp.usedIn': 'Used in {count} project',
'mcp.usedInPlural': 'Used in {count} projects',
'mcp.availableToAll': 'Available to all projects from ~/.claude.json',
'mcp.managedByOrg': 'Managed by organization (highest priority)',
'mcp.variables': 'variables',
// MCP Create Modal
'mcp.createTitle': 'Create MCP Server',
'mcp.form': 'Form',
'mcp.json': 'JSON',
'mcp.serverName': 'Server Name',
'mcp.serverNamePlaceholder': 'e.g., my-mcp-server',
'mcp.scope': 'Scope',
'mcp.scopeProject': 'Project - Only this project',
'mcp.scopeGlobal': 'Global - All projects (~/.claude.json)',
'mcp.command': 'Command',
'mcp.commandPlaceholder': 'e.g., npx, uvx, node, python',
'mcp.arguments': 'Arguments (one per line)',
@@ -653,6 +746,30 @@ const i18n = {
'skills.deleteError': 'Failed to delete skill',
'skills.editNotImplemented': 'Edit feature coming soon',
'skills.createNotImplemented': 'Create feature coming soon',
'skills.createSkill': 'Create Skill', 'skills.sourceFolder': 'Source Folder', 'skills.sourceFolderPlaceholder': 'Path to skill folder', 'skills.sourceFolderHint': 'Select a folder containing a valid SKILL.md file', 'skills.sourceFolderRequired': 'Source folder path is required', 'skills.customName': 'Custom Name', 'skills.customNamePlaceholder': 'Leave empty to use skill name from SKILL.md', 'skills.customNameHint': 'Optional: Override the skill name', 'skills.validate': 'Validate', 'skills.import': 'Import', 'skills.validating': 'Validating...', 'skills.validSkill': 'Valid Skill', 'skills.invalidSkill': 'Invalid Skill', 'skills.validateFirst': 'Please validate the skill folder first', 'skills.created': 'Skill "{name}" created successfully', 'skills.createError': 'Failed to create skill', 'skills.validationError': 'Validation failed', 'skills.enterFolderPath': 'Enter skill folder path', 'skills.name': 'Name',
'skills.createMode': 'Creation Mode',
'skills.importFolder': 'Import Folder',
'skills.importFolderHint': 'Import existing skill folder',
'skills.cliGenerate': 'CLI Generate',
'skills.cliGenerateHint': 'Generate using AI',
'skills.generationType': 'Generation Type',
'skills.fromDescription': 'From Description',
'skills.fromDescriptionHint': 'Describe what you need',
'skills.fromTemplate': 'From Template',
'skills.comingSoon': 'Coming soon',
'skills.skillName': 'Skill Name',
'skills.skillNamePlaceholder': 'e.g., code-review, testing-helper',
'skills.skillNameHint': 'Choose a descriptive name for the skill',
'skills.skillNameRequired': 'Skill name is required',
'skills.descriptionPlaceholder': 'Describe what this skill should help with...\nExample: Help review code for security vulnerabilities and best practices',
'skills.descriptionRequired': 'Description is required',
'skills.descriptionGenerateHint': 'Be specific about what the skill should do',
'skills.generating': 'Generating skill via CLI... This may take a few minutes.',
'skills.generated': 'Skill "{name}" generated successfully',
'skills.generateError': 'Failed to generate skill',
'skills.generate': 'Generate',
'skills.cliGenerateInfo': 'AI will generate a complete skill based on your description',
'skills.cliGenerateTimeHint': 'Generation may take a few minutes depending on complexity',
// Rules
'nav.rules': 'Rules',
@@ -679,9 +796,116 @@ const i18n = {
'rules.deleteError': 'Failed to delete rule',
'rules.editNotImplemented': 'Edit feature coming soon',
'rules.createNotImplemented': 'Create feature coming soon',
'rules.createRule': 'Create Rule',
'rules.location': 'Location',
'rules.fileName': 'File Name',
'rules.fileNameHint': 'Must end with .md',
'rules.fileNameRequired': 'File name is required',
'rules.fileNameMustEndMd': 'File name must end with .md',
'rules.subdirectory': 'Subdirectory',
'rules.subdirectoryHint': 'Optional: Organize rules into subdirectories',
'rules.conditionalRule': 'Conditional Rule',
'rules.conditionalHint': 'Apply this rule only to specific file paths',
'rules.addPath': 'Add Path',
'rules.contentPlaceholder': 'Enter rule content in Markdown format...',
'rules.contentHint': 'Use Markdown to write rule instructions for Claude',
'rules.contentRequired': 'Content is required',
'rules.created': 'Rule "{name}" created successfully',
'rules.createError': 'Failed to create rule',
'rules.createMode': 'Creation Mode',
'rules.manualInput': 'Manual Input',
'rules.manualInputHint': 'Write content directly',
'rules.cliGenerate': 'CLI Generate',
'rules.cliGenerateHint': 'Auto-generate via AI',
'rules.generationType': 'Generation Type',
'rules.fromDescription': 'From Description',
'rules.fromTemplate': 'From Template',
'rules.fromCodeExtract': 'Extract from Code',
'rules.description': 'Description',
'rules.descriptionPlaceholder': 'Describe the rule purpose and instructions...',
'rules.descriptionHint': 'AI will generate rule content based on your description',
'rules.descriptionRequired': 'Description is required',
'rules.extractScope': 'Analysis Scope',
'rules.extractScopeHint': 'File patterns to analyze (e.g., src/**/*.ts)',
'rules.extractScopeRequired': 'Analysis scope is required',
'rules.extractFocus': 'Focus Areas',
'rules.extractFocusHint': 'Comma-separated aspects to focus on (e.g., naming, error-handling)',
'rules.cliGenerating': 'Generating rule via CLI (this may take a few minutes)...',
// CLAUDE.md Manager
'nav.claudeManager': 'CLAUDE.md',
'title.claudeManager': 'CLAUDE.md Manager',
'claudeManager.title': 'CLAUDE.md Files',
'claudeManager.files': 'files',
'claudeManager.userLevel': 'User Level',
'claudeManager.projectLevel': 'Project Level',
'claudeManager.moduleLevel': 'Module Level',
'claudeManager.noFile': 'No CLAUDE.md file',
'claudeManager.noModules': 'No module CLAUDE.md files',
'claudeManager.selectFile': 'Select a file to view',
'claudeManager.noMetadata': 'Select a file to see metadata',
'claudeManager.fileInfo': 'File Information',
'claudeManager.level': 'Level',
'claudeManager.level_user': 'User (~/.claude/)',
'claudeManager.level_project': 'Project (.claude/)',
'claudeManager.level_module': 'Module',
'claudeManager.path': 'Path',
'claudeManager.size': 'Size',
'claudeManager.modified': 'Modified',
'claudeManager.statistics': 'Statistics',
'claudeManager.lines': 'Lines',
'claudeManager.words': 'Words',
'claudeManager.characters': 'Characters',
'claudeManager.actions': 'Actions',
'claudeManager.loadError': 'Failed to load CLAUDE.md files',
'claudeManager.refreshed': 'Files refreshed successfully',
'claudeManager.unsavedChanges': 'You have unsaved changes. Discard them?',
'claudeManager.saved': 'File saved successfully',
'claudeManager.saveError': 'Failed to save file',
// CLI Sync (used in claude-manager.js)
'claude.cliSync': 'CLI Auto-Sync',
'claude.tool': 'Tool',
'claude.mode': 'Mode',
'claude.syncButton': 'Sync with CLI',
'claude.syncing': 'Analyzing with {tool}...',
'claude.syncSuccess': '{file} synced successfully',
'claude.syncError': 'Sync failed: {error}',
'claude.modeUpdate': 'Update (Smart Merge)',
'claude.modeGenerate': 'Generate (Full Replace)',
'claude.modeAppend': 'Append',
'claude.searchPlaceholder': 'Search files...',
'claude.viewModeClaude': 'CLAUDE.md Only',
'claude.viewModeAll': 'All Files',
'claude.createFile': 'Create File',
'claude.createDialogTitle': 'Create CLAUDE.md File',
'claude.selectLevel': 'Level',
'claude.levelUser': 'User (~/.claude/)',
'claude.levelProject': 'Project (.claude/)',
'claude.levelModule': 'Module (custom path)',
'claude.modulePath': 'Module Path',
'claude.selectTemplate': 'Template',
'claude.templateDefault': 'Default',
'claude.templateMinimal': 'Minimal',
'claude.templateComprehensive': 'Comprehensive',
'claude.deleteFile': 'Delete File',
'claude.deleteConfirm': 'Are you sure you want to delete {file}?',
'claude.deleteWarning': 'This action cannot be undone.',
'claude.copyContent': 'Copy Content',
'claude.contentCopied': 'Content copied to clipboard',
'claude.copyError': 'Failed to copy content',
'claude.modulePathRequired': 'Module path is required',
'claude.fileCreated': 'File created successfully',
'claude.createFileError': 'Failed to create file',
'claude.fileDeleted': 'File deleted successfully',
'claude.deleteFileError': 'Failed to delete file',
'claude.loadAllFilesError': 'Failed to load all files',
'claude.unsupportedFileType': 'Unsupported file type',
'claude.loadFileError': 'Failed to load file',
// Common
'common.cancel': 'Cancel',
'common.optional': '(Optional)',
'common.create': 'Create',
'common.save': 'Save',
'common.delete': 'Delete',
@@ -696,6 +920,10 @@ const i18n = {
'common.remove': 'Remove',
'common.removeFromRecent': 'Remove from recent',
'common.noDescription': 'No description',
'common.saving': 'Saving...',
'common.saveFailed': 'Failed to save',
'common.unknownError': 'Unknown error',
'common.exception': 'Exception',
},
zh: {
@@ -797,6 +1025,8 @@ const i18n = {
'empty.noLiteSessionsText': '在 .workflow/.{type}/ 目录中未找到会话',
'empty.noMcpServers': '该项目未配置 MCP 服务器',
'empty.addMcpServersHint': '从下方可用列表中添加服务器',
'empty.noGlobalMcpServers': '未配置全局 MCP 服务器',
'empty.globalServersHint': '全局服务器对所有项目可用,来自 ~/.claude.json',
'empty.noAdditionalMcp': '其他项目中未找到其他 MCP 服务器',
'empty.noHooks': '该项目未配置钩子',
'empty.createHookHint': '创建钩子以自动化工具使用时的操作',
@@ -887,6 +1117,7 @@ const i18n = {
'cli.setDefault': '设为默认',
'cli.default': '默认',
'cli.install': '安装',
'cli.uninstall': '卸载',
'cli.initIndex': '初始化索引',
'cli.geminiDesc': 'Google AI 代码分析',
'cli.qwenDesc': '阿里通义 AI 助手',
@@ -895,6 +1126,80 @@ const i18n = {
'cli.codexLensDescFull': '全文代码搜索引擎',
'cli.semanticDesc': 'AI 驱动的代码理解',
'cli.semanticDescFull': '自然语言代码搜索',
// CodexLens 配置
'codexlens.config': 'CodexLens 配置',
'codexlens.status': '状态',
'codexlens.installed': '已安装',
'codexlens.notInstalled': '未安装',
'codexlens.indexes': '索引',
'codexlens.currentWorkspace': '当前工作区',
'codexlens.indexStoragePath': '索引存储路径',
'codexlens.whereIndexesStored': '索引存储位置',
'codexlens.currentPath': '当前路径',
'codexlens.newStoragePath': '新存储路径',
'codexlens.pathPlaceholder': '例如:/path/to/indexes 或 ~/.codexlens/indexes',
'codexlens.pathInfo': '支持 ~ 表示用户目录。更改立即生效。',
'codexlens.migrationRequired': '需要迁移',
'codexlens.migrationWarning': '更改路径后,需要为每个工作区重新初始化索引。',
'codexlens.actions': '操作',
'codexlens.initializeIndex': '初始化索引',
'codexlens.cleanAllIndexes': '清理所有索引',
'codexlens.installCodexLens': '安装 CodexLens',
'codexlens.testSearch': '测试搜索',
'codexlens.testFunctionality': '测试 CodexLens 功能',
'codexlens.textSearch': '文本搜索',
'codexlens.fileSearch': '文件搜索',
'codexlens.symbolSearch': '符号搜索',
'codexlens.searchPlaceholder': '输入搜索查询(例如:函数名、文件路径、代码片段)',
'codexlens.runSearch': '运行搜索',
'codexlens.results': '结果',
'codexlens.resultsCount': '个结果',
'codexlens.saveConfig': '保存配置',
'codexlens.searching': '搜索中...',
'codexlens.searchCompleted': '搜索完成',
'codexlens.searchFailed': '搜索失败',
'codexlens.enterQuery': '请输入搜索查询',
'codexlens.configSaved': '配置保存成功',
'codexlens.pathEmpty': '索引目录路径不能为空',
'codexlens.cleanConfirm': '确定要清理所有 CodexLens 索引吗?此操作无法撤销。',
'codexlens.cleaning': '清理索引中...',
'codexlens.cleanSuccess': '所有索引已成功清理',
'codexlens.cleanFailed': '清理索引失败',
'codexlens.loadingConfig': '加载配置中...',
// Semantic Search 配置
'semantic.settings': '语义搜索设置',
'semantic.configDesc': '配置语义索引的 LLM 增强功能',
'semantic.llmEnhancement': 'LLM 增强',
'semantic.llmDesc': '使用 LLM 生成代码摘要以改进语义搜索',
'semantic.primaryTool': '主 LLM 工具',
'semantic.fallbackTool': '备用工具',
'semantic.batchSize': '批处理大小',
'semantic.timeout': '超时时间',
'semantic.file': '个文件',
'semantic.files': '个文件',
'semantic.enhanceInfo': 'LLM 增强为每个文件生成代码摘要和关键词,提高语义搜索准确度。',
'semantic.enhanceCommand': '运行',
'semantic.enhanceAfterEnable': '启用后处理现有文件。',
'semantic.runEnhanceNow': '立即运行增强',
'semantic.viewStatus': '查看状态',
'semantic.testSearch': '测试语义搜索',
'semantic.searchPlaceholder': '输入语义查询(例如:身份验证逻辑、错误处理)',
'semantic.runSearch': '运行语义搜索',
'semantic.close': '关闭',
'semantic.enabled': '已启用',
'semantic.disabled': '已禁用',
'semantic.toolSetTo': '主 LLM 工具已设置为',
'semantic.fallbackSetTo': '备用工具已设置为',
'semantic.none': '无',
'semantic.llmEnhancement': 'LLM 增强',
'semantic.batchSetTo': '批量大小已设置为',
'semantic.timeoutSetTo': '超时已设置为',
'semantic.minute': '分钟',
'semantic.minutes': '分钟',
'semantic.enableFirst': '请先启用 LLM 增强',
'cli.settings': 'CLI 调用设置',
'cli.promptFormat': '提示词格式',
'cli.promptFormatDesc': '多轮对话拼接格式',
@@ -986,9 +1291,18 @@ const i18n = {
'updateClaudeMd.cancel': '取消',
// MCP Manager
'mcp.currentProject': '当前项目 MCP 服务器',
'mcp.currentAvailable': '当前可用 MCP',
'mcp.copyInstallCmd': '复制安装命令',
'mcp.installCmdCopied': '安装命令已复制到剪贴板',
'mcp.installCmdFailed': '复制安装命令失败',
'mcp.projectAvailable': '当前可用 MCP',
'mcp.newServer': '新建服务器',
'mcp.newGlobalServer': '新建全局服务器',
'mcp.newProjectServer': '新建项目服务器',
'mcp.serversConfigured': '个服务器已配置',
'mcp.serversAvailable': 'servers available',
'mcp.globalAvailable': '全局可用 MCP',
'mcp.globalServersFrom': '个服务器来自 ~/.claude.json',
'mcp.enterprise': '企业 MCP 服务器',
'mcp.enterpriseManaged': '托管',
'mcp.enterpriseReadOnly': '个服务器(只读)',
@@ -1005,6 +1319,10 @@ const i18n = {
'mcp.noMcpServers': '无 MCP 服务器',
'mcp.add': '添加',
'mcp.addToProject': '添加到项目',
'mcp.installToProject': '安装到项目',
'mcp.installToGlobal': '安装到全局',
'mcp.installToProject': 'Install to project',
'mcp.installToGlobal': 'Install to global',
'mcp.removeFromProject': '从项目移除',
'mcp.removeConfirm': '从此项目移除 MCP 服务器 "{name}"',
'mcp.readOnly': '只读',
@@ -1343,6 +1661,30 @@ const i18n = {
'skills.deleteError': '删除技能失败',
'skills.editNotImplemented': '编辑功能即将推出',
'skills.createNotImplemented': '创建功能即将推出',
'skills.createSkill': '创建技能', 'skills.sourceFolder': '源文件夹', 'skills.sourceFolderPlaceholder': '技能文件夹路径', 'skills.sourceFolderHint': '选择包含有效 SKILL.md 文件的文件夹', 'skills.sourceFolderRequired': '源文件夹路径是必需的', 'skills.customName': '自定义名称', 'skills.customNamePlaceholder': '留空则使用 SKILL.md 中的技能名称', 'skills.customNameHint': '可选:覆盖技能名称', 'skills.validate': '验证', 'skills.import': '导入', 'skills.validating': '验证中...', 'skills.validSkill': '有效技能', 'skills.invalidSkill': '无效技能', 'skills.validateFirst': '请先验证技能文件夹', 'skills.created': '技能 "{name}" 创建成功', 'skills.createError': '创建技能失败', 'skills.validationError': '验证失败', 'skills.enterFolderPath': '输入技能文件夹路径', 'skills.name': '名称',
'skills.createMode': '创建模式',
'skills.importFolder': '导入文件夹',
'skills.importFolderHint': '导入现有技能文件夹',
'skills.cliGenerate': 'CLI生成',
'skills.cliGenerateHint': '使用AI生成',
'skills.generationType': '生成类型',
'skills.fromDescription': '从描述生成',
'skills.fromDescriptionHint': '描述你需要的功能',
'skills.fromTemplate': '从模板生成',
'skills.comingSoon': '即将推出',
'skills.skillName': '技能名称',
'skills.skillNamePlaceholder': '例如:代码审查、测试助手',
'skills.skillNameHint': '为技能选择一个描述性的名称',
'skills.skillNameRequired': '技能名称是必需的',
'skills.descriptionPlaceholder': '描述这个技能应该帮助什么...\n例如帮助审查代码的安全漏洞和最佳实践',
'skills.descriptionRequired': '描述是必需的',
'skills.descriptionGenerateHint': '具体说明技能应该做什么',
'skills.generating': '正在通过 CLI 生成技能...这可能需要几分钟。',
'skills.generated': '技能 "{name}" 生成成功',
'skills.generateError': '生成技能失败',
'skills.generate': '生成',
'skills.cliGenerateInfo': 'AI 将根据你的描述生成完整的技能',
'skills.cliGenerateTimeHint': '生成时间取决于复杂度,可能需要几分钟',
// Rules
'nav.rules': '规则',
@@ -1369,9 +1711,147 @@ const i18n = {
'rules.deleteError': '删除规则失败',
'rules.editNotImplemented': '编辑功能即将推出',
'rules.createNotImplemented': '创建功能即将推出',
'rules.createRule': '创建规则',
'rules.location': '位置',
'rules.fileName': '文件名',
'rules.fileNameHint': '必须以 .md 结尾',
'rules.fileNameRequired': '文件名是必需的',
'rules.fileNameMustEndMd': '文件名必须以 .md 结尾',
'rules.subdirectory': '子目录',
'rules.subdirectoryHint': '可选:将规则组织到子目录中',
'rules.conditionalRule': '条件规则',
'rules.conditionalHint': '仅对特定文件路径应用此规则',
'rules.addPath': '添加路径',
'rules.contentPlaceholder': '以 Markdown 格式输入规则内容...',
'rules.contentHint': '使用 Markdown 为 Claude 编写规则说明',
'rules.contentRequired': '内容是必需的',
'rules.created': '规则 "{name}" 创建成功',
'rules.createError': '创建规则失败',
'rules.createMode': '创建模式',
'rules.manualInput': '手动输入',
'rules.manualInputHint': '直接编写内容',
'rules.cliGenerate': 'CLI生成',
'rules.cliGenerateHint': '通过AI自动生成',
'rules.generationType': '生成类型',
'rules.fromDescription': '从描述生成',
'rules.fromTemplate': '从模板生成',
'rules.fromCodeExtract': '从代码提取',
'rules.description': '描述',
'rules.descriptionPlaceholder': '描述规则目的和说明...',
'rules.descriptionHint': 'AI将根据您的描述生成规则内容',
'rules.descriptionRequired': '描述是必需的',
'rules.extractScope': '分析范围',
'rules.extractScopeHint': '要分析的文件模式例如src/**/*.ts',
'rules.extractScopeRequired': '分析范围是必需的',
'rules.extractFocus': '关注领域',
'rules.extractFocusHint': '以逗号分隔的关注方面(例如:命名规范, 错误处理)',
'rules.cliGenerating': '正在通过 CLI 生成规则(可能需要几分钟)...',
// CLAUDE.md Manager
'nav.claudeManager': 'CLAUDE.md',
'title.claudeManager': 'CLAUDE.md 管理器',
'claudeManager.title': 'CLAUDE.md 文件',
'claudeManager.files': '个文件',
'claudeManager.userLevel': '用户级',
'claudeManager.projectLevel': '项目级',
'claudeManager.moduleLevel': '模块级',
'claudeManager.noFile': '无 CLAUDE.md 文件',
'claudeManager.noModules': '无模块 CLAUDE.md 文件',
'claudeManager.selectFile': '选择文件以查看',
'claudeManager.noMetadata': '选择文件以查看元数据',
'claudeManager.fileInfo': '文件信息',
'claudeManager.level': '级别',
'claudeManager.level_user': '用户级 (~/.claude/)',
'claudeManager.level_project': '项目级 (.claude/)',
'claudeManager.level_module': '模块级',
'claudeManager.path': '路径',
'claudeManager.size': '大小',
'claudeManager.modified': '修改时间',
'claudeManager.statistics': '统计信息',
'claudeManager.lines': '行数',
'claudeManager.words': '字数',
'claudeManager.characters': '字符数',
'claudeManager.actions': '操作',
'claudeManager.loadError': '加载 CLAUDE.md 文件失败',
'claudeManager.refreshed': '文件刷新成功',
'claudeManager.unsavedChanges': '您有未保存的更改。是否放弃?',
'claudeManager.saved': '文件保存成功',
'claudeManager.saveError': '文件保存失败',
// CLI Sync (used in claude-manager.js)
'claude.cliSync': 'CLI 自动同步',
'claude.tool': '工具',
'claude.mode': '模式',
'claude.syncButton': '使用 CLI 同步',
'claude.syncing': '正在使用 {tool} 分析...',
'claude.syncSuccess': '{file} 同步成功',
'claude.syncError': '同步失败:{error}',
'claude.modeUpdate': '更新(智能合并)',
'claude.modeGenerate': '生成(完全替换)',
'claude.modeAppend': '追加',
'claude.searchPlaceholder': '搜索文件...',
'claude.viewModeClaude': '仅 CLAUDE.md',
'claude.viewModeAll': '所有文件',
'claude.createFile': '创建文件',
'claude.createDialogTitle': '创建 CLAUDE.md 文件',
'claude.selectLevel': '层级',
'claude.levelUser': '用户级 (~/.claude/)',
'claude.levelProject': '项目级 (.claude/)',
'claude.levelModule': '模块级(自定义路径)',
'claude.modulePath': '模块路径',
'claude.selectTemplate': '模板',
'claude.templateDefault': '默认',
'claude.templateMinimal': '最小化',
'claude.templateComprehensive': '完整',
'claude.deleteFile': '删除文件',
'claude.deleteConfirm': '确定要删除 {file} 吗?',
'claude.deleteWarning': '此操作无法撤销。',
'claude.copyContent': '复制内容',
'claude.contentCopied': '内容已复制到剪贴板',
'claude.copyError': '复制内容失败',
'claude.modulePathRequired': '模块路径为必填项',
'claude.fileCreated': '文件创建成功',
'claude.createFileError': '文件创建失败',
'claude.fileDeleted': '文件删除成功',
'claude.deleteFileError': '文件删除失败',
'claude.loadAllFilesError': '加载所有文件失败',
'claude.unsupportedFileType': '不支持的文件类型',
'claude.loadFileError': '加载文件失败',
// Duplicate keys for compatibility
'nav.claudeManager': 'CLAUDE.md',
'title.claudeManager': 'CLAUDE.md Manager',
'claudeManager.title': 'CLAUDE.md Files',
'claudeManager.files': 'files',
'claudeManager.userLevel': 'User Level',
'claudeManager.projectLevel': 'Project Level',
'claudeManager.moduleLevel': 'Module Level',
'claudeManager.noFile': 'No CLAUDE.md file',
'claudeManager.noModules': 'No module CLAUDE.md files',
'claudeManager.selectFile': 'Select a file to view',
'claudeManager.noMetadata': 'Select a file to see metadata',
'claudeManager.fileInfo': 'File Information',
'claudeManager.level': 'Level',
'claudeManager.level_user': 'User (~/.claude/)',
'claudeManager.level_project': 'Project (.claude/)',
'claudeManager.level_module': 'Module',
'claudeManager.path': 'Path',
'claudeManager.size': 'Size',
'claudeManager.modified': 'Modified',
'claudeManager.statistics': 'Statistics',
'claudeManager.lines': 'Lines',
'claudeManager.words': 'Words',
'claudeManager.characters': 'Characters',
'claudeManager.actions': 'Actions',
'claudeManager.loadError': 'Failed to load CLAUDE.md files',
'claudeManager.refreshed': 'Files refreshed successfully',
'claudeManager.unsavedChanges': 'You have unsaved changes. Discard them?',
'claudeManager.saved': 'File saved successfully',
'claudeManager.saveError': 'Failed to save file',
// Common
'common.cancel': '取消',
'common.optional': '(可选)',
'common.create': '创建',
'common.save': '保存',
'common.delete': '删除',
@@ -1386,6 +1866,10 @@ const i18n = {
'common.remove': '移除',
'common.removeFromRecent': '从最近中移除',
'common.noDescription': '无描述',
'common.saving': '保存中...',
'common.saveFailed': '保存失败',
'common.unknownError': '未知错误',
'common.exception': '异常',
}
};

View File

@@ -0,0 +1,764 @@
// CLAUDE.md Manager View
// Three-column layout: File Tree | Viewer/Editor | Metadata & Actions
// ========== State Management ==========
var claudeFilesData = {
user: { main: null },
project: { main: null },
modules: [],
summary: { totalFiles: 0, totalSize: 0 }
};
var selectedFile = null;
var isEditMode = false;
var isDirty = false;
var fileTreeExpanded = {
user: true,
project: true,
modules: {}
};
var searchQuery = '';
// ========== Main Render Function ==========
async function renderClaudeManager() {
var container = document.getElementById('mainContent');
if (!container) return;
// Hide stats grid and search for claude-manager view
var statsGrid = document.getElementById('statsGrid');
var searchInput = document.getElementById('searchInput');
if (statsGrid) statsGrid.style.display = 'none';
if (searchInput) searchInput.parentElement.style.display = 'none';
// Show loading state
container.innerHTML = '<div class="claude-manager-view loading">' +
'<div class="loading-spinner"><i data-lucide="loader-2" class="w-8 h-8 animate-spin"></i></div>' +
'<p>' + t('common.loading') + '</p>' +
'</div>';
// Load data
await loadClaudeFiles();
// Render layout
container.innerHTML = '<div class="claude-manager-view">' +
'<div class="claude-manager-header">' +
'<div class="claude-manager-header-left">' +
'<h2><i data-lucide="file-code" class="w-5 h-5"></i> ' + t('claudeManager.title') + '</h2>' +
'<span class="file-count-badge">' + claudeFilesData.summary.totalFiles + ' ' + t('claudeManager.files') + '</span>' +
'</div>' +
'<div class="claude-manager-header-right">' +
'<button class="btn btn-sm btn-primary" onclick="showCreateFileDialog()">' +
'<i data-lucide="file-plus" class="w-4 h-4"></i> ' + t('claude.createFile') +
'</button>' +
'<button class="btn btn-sm btn-secondary" onclick="refreshClaudeFiles()">' +
'<i data-lucide="refresh-cw" class="w-4 h-4"></i> ' + t('common.refresh') +
'</button>' +
'</div>' +
'</div>' +
'<div class="claude-manager-columns">' +
'<div class="claude-manager-column left" id="claude-file-tree"></div>' +
'<div class="claude-manager-column center" id="claude-file-viewer"></div>' +
'<div class="claude-manager-column right" id="claude-file-metadata"></div>' +
'</div>' +
'</div>';
// Render each column
renderFileTree();
renderFileViewer();
renderFileMetadata();
// Initialize Lucide icons
if (window.lucide) lucide.createIcons();
}
// ========== Data Loading ==========
async function loadClaudeFiles() {
try {
var res = await fetch('/api/memory/claude/scan?path=' + encodeURIComponent(projectPath || ''));
if (!res.ok) throw new Error('Failed to load CLAUDE.md files');
claudeFilesData = await res.json();
updateClaudeBadge(); // Update navigation badge
} catch (error) {
console.error('Error loading CLAUDE.md files:', error);
addGlobalNotification('error', t('claudeManager.loadError'), null, 'CLAUDE.md');
}
}
async function refreshClaudeFiles() {
await loadClaudeFiles();
await renderClaudeManager();
addGlobalNotification('success', t('claudeManager.refreshed'), null, 'CLAUDE.md');
}
// ========== File Tree Rendering ==========
function renderFileTree() {
var container = document.getElementById('claude-file-tree');
if (!container) return;
var html = '<div class="file-tree">' +
// Search Box
'<div class="file-tree-search">' +
'<input type="text" id="fileSearchInput" placeholder="' + t('claude.searchPlaceholder') + '" ' +
'value="' + escapeHtml(searchQuery) + '" oninput="filterFileTree(this.value)">' +
'<i data-lucide="search" class="w-4 h-4"></i>' +
'</div>' +
renderClaudeFilesTree() +
'</div>'; // end file-tree
container.innerHTML = html;
if (window.lucide) lucide.createIcons();
}
function renderClaudeFilesTree() {
var html = '<div class="file-tree-section">' +
'<div class="file-tree-header" onclick="toggleTreeSection(\'user\')">' +
'<i data-lucide="' + (fileTreeExpanded.user ? 'chevron-down' : 'chevron-right') + '" class="w-4 h-4"></i>' +
'<i data-lucide="user" class="w-4 h-4 text-orange-500"></i>' +
'<span>' + t('claudeManager.userLevel') + '</span>' +
'<span class="file-count">' + (claudeFilesData.user.main ? 1 : 0) + '</span>' +
'</div>';
if (fileTreeExpanded.user) {
// User CLAUDE.md (only main file, no rules)
if (claudeFilesData.user.main) {
html += renderFileTreeItem(claudeFilesData.user.main, 1);
} else {
html += '<div class="file-tree-item empty" style="padding-left: 1.5rem;">' +
'<i data-lucide="file-x" class="w-4 h-4"></i>' +
'<span>' + t('claudeManager.noFile') + '</span>' +
'</div>';
}
}
html += '</div>'; // end user section
// Project section
html += '<div class="file-tree-section">' +
'<div class="file-tree-header" onclick="toggleTreeSection(\'project\')">' +
'<i data-lucide="' + (fileTreeExpanded.project ? 'chevron-down' : 'chevron-right') + '" class="w-4 h-4"></i>' +
'<i data-lucide="folder" class="w-4 h-4 text-green-500"></i>' +
'<span>' + t('claudeManager.projectLevel') + '</span>' +
'<span class="file-count">' + (claudeFilesData.project.main ? 1 : 0) + '</span>' +
'</div>';
if (fileTreeExpanded.project) {
// Project CLAUDE.md (only main file, no rules)
if (claudeFilesData.project.main) {
html += renderFileTreeItem(claudeFilesData.project.main, 1);
} else {
html += '<div class="file-tree-item empty" style="padding-left: 1.5rem;">' +
'<i data-lucide="file-x" class="w-4 h-4"></i>' +
'<span>' + t('claudeManager.noFile') + '</span>' +
'</div>';
}
}
html += '</div>'; // end project section
// Modules section
html += '<div class="file-tree-section">' +
'<div class="file-tree-header">' +
'<i data-lucide="package" class="w-4 h-4 text-blue-500"></i>' +
'<span>' + t('claudeManager.moduleLevel') + '</span>' +
'<span class="file-count">' + claudeFilesData.modules.length + '</span>' +
'</div>';
if (claudeFilesData.modules.length > 0) {
claudeFilesData.modules.forEach(function (file) {
html += renderFileTreeItem(file, 1);
});
} else {
html += '<div class="file-tree-item empty" style="padding-left: 1.5rem;">' +
'<i data-lucide="file-x" class="w-4 h-4"></i>' +
'<span>' + t('claudeManager.noModules') + '</span>' +
'</div>';
}
html += '</div>'; // end modules section
return html;
}
function renderFileTreeItem(file, indentLevel) {
var isSelected = selectedFile && selectedFile.id === file.id;
var indentPx = indentLevel * 1.5;
var safeId = file.id.replace(/'/g, "&apos;");
return '<div class="file-tree-item' + (isSelected ? ' selected' : '') + '" ' +
'onclick="selectClaudeFile(\'' + safeId + '\')" ' +
'style="padding-left: ' + indentPx + 'rem;">' +
'<i data-lucide="file-text" class="w-4 h-4"></i>' +
'<span class="file-name">' + escapeHtml(file.name) + '</span>' +
(file.parentDirectory ? '<span class="file-path-hint">' + escapeHtml(file.parentDirectory) + '</span>' : '') +
'</div>';
}
function toggleTreeSection(section) {
fileTreeExpanded[section] = !fileTreeExpanded[section];
renderFileTree();
}
async function selectClaudeFile(fileId) {
// Find file in data (only main CLAUDE.md files, no rules)
var allFiles = [
claudeFilesData.user.main,
claudeFilesData.project.main,
...claudeFilesData.modules
].filter(function (f) { return f !== null; });
selectedFile = allFiles.find(function (f) { return f.id === fileId; }) || null;
if (selectedFile) {
// Load full content if not already loaded
if (!selectedFile.content) {
try {
var res = await fetch('/api/memory/claude/file?path=' + encodeURIComponent(selectedFile.path));
if (res.ok) {
var data = await res.json();
selectedFile.content = data.content;
selectedFile.stats = data.stats;
}
} catch (error) {
console.error('Error loading file content:', error);
}
}
}
renderFileTree();
renderFileViewer();
renderFileMetadata();
}
// ========== File Viewer Rendering ==========
function renderFileViewer() {
var container = document.getElementById('claude-file-viewer');
if (!container) return;
if (!selectedFile) {
container.innerHTML = '<div class="empty-state">' +
'<i data-lucide="file-search" class="w-12 h-12 opacity-20"></i>' +
'<p>' + t('claudeManager.selectFile') + '</p>' +
'</div>';
if (window.lucide) lucide.createIcons();
return;
}
container.innerHTML = '<div class="file-viewer">' +
'<div class="file-viewer-header">' +
'<h3>' + escapeHtml(selectedFile.name) + '</h3>' +
'<div class="file-viewer-actions">' +
'<button class="btn btn-sm btn-secondary" onclick="copyFileContent()" title="' + t('claude.copyContent') + '">' +
'<i data-lucide="copy" class="w-4 h-4"></i>' +
'</button>' +
'<button class="btn btn-sm btn-secondary" onclick="toggleEditMode()" title="' + t('common.edit') + '">' +
'<i data-lucide="' + (isEditMode ? 'eye' : 'edit-2') + '" class="w-4 h-4"></i>' +
'</button>' +
'</div>' +
'</div>' +
'<div class="file-viewer-content">' +
(isEditMode ? renderEditor() : renderMarkdownContent(selectedFile.content || '')) +
'</div>' +
'</div>';
if (window.lucide) lucide.createIcons();
}
function renderMarkdownContent(content) {
// Check if marked.js is available for enhanced rendering
if (typeof marked !== 'undefined') {
try {
marked.setOptions({
gfm: true,
breaks: true,
tables: true,
smartLists: true,
highlight: function(code, lang) {
// Check if highlight.js or Prism is available
if (typeof hljs !== 'undefined' && lang) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (e) {
return escapeHtml(code);
}
} else if (typeof Prism !== 'undefined' && lang && Prism.languages[lang]) {
return Prism.highlight(code, Prism.languages[lang], lang);
}
return escapeHtml(code);
}
});
return '<div class="markdown-content">' + marked.parse(content) + '</div>';
} catch (e) {
console.error('Error rendering markdown with marked.js:', e);
}
}
// Fallback: Enhanced basic rendering
var html = escapeHtml(content);
// Headers
html = html
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^#### (.*$)/gim, '<h4>$1</h4>');
// Inline formatting
html = html
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Task lists
html = html
.replace(/- \[ \] (.+)$/gim, '<li class="task-list-item"><input type="checkbox" disabled> $1</li>')
.replace(/- \[x\] (.+)$/gim, '<li class="task-list-item"><input type="checkbox" disabled checked> $1</li>');
// Lists
html = html.replace(/^- (.+)$/gim, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
// Code blocks
html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, function(match, lang, code) {
return '<pre><code class="language-' + (lang || 'plaintext') + '">' + code + '</code></pre>';
});
// Line breaks
html = html.replace(/\n/g, '<br>');
return '<div class="markdown-content">' + html + '</div>';
}
function renderEditor() {
return '<textarea id="claudeFileEditor" class="file-editor" ' +
'oninput="markDirty()">' +
escapeHtml(selectedFile.content || '') +
'</textarea>';
}
function toggleEditMode() {
if (isEditMode && isDirty) {
if (!confirm(t('claudeManager.unsavedChanges'))) {
return;
}
}
isEditMode = !isEditMode;
isDirty = false;
renderFileViewer();
}
function markDirty() {
isDirty = true;
}
async function saveClaudeFile() {
if (!selectedFile || !isEditMode) return;
var editor = document.getElementById('claudeFileEditor');
if (!editor) return;
var newContent = editor.value;
try {
var res = await fetch('/api/memory/claude/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: selectedFile.path,
content: newContent,
createBackup: true
})
});
if (!res.ok) throw new Error('Failed to save file');
selectedFile.content = newContent;
selectedFile.stats = calculateFileStats(newContent);
isDirty = false;
addGlobalNotification('success', t('claudeManager.saved'), null, 'CLAUDE.md');
renderFileMetadata();
} catch (error) {
console.error('Error saving file:', error);
addGlobalNotification('error', t('claudeManager.saveError'), null, 'CLAUDE.md');
}
}
function calculateFileStats(content) {
var lines = content.split('\n').length;
var words = content.split(/\s+/).filter(function (w) { return w.length > 0; }).length;
var characters = content.length;
return { lines: lines, words: words, characters: characters };
}
// ========== File Metadata Rendering ==========
function renderFileMetadata() {
var container = document.getElementById('claude-file-metadata');
if (!container) return;
if (!selectedFile) {
container.innerHTML = '<div class="empty-state">' +
'<i data-lucide="info" class="w-8 h-8 opacity-20"></i>' +
'<p>' + t('claudeManager.noMetadata') + '</p>' +
'</div>';
if (window.lucide) lucide.createIcons();
return;
}
var html = '<div class="file-metadata">' +
'<div class="metadata-section">' +
'<h4>' + t('claudeManager.fileInfo') + '</h4>' +
'<div class="metadata-item">' +
'<span class="label">' + t('claudeManager.level') + '</span>' +
'<span class="value">' + t('claudeManager.level_' + selectedFile.level) + '</span>' +
'</div>' +
'<div class="metadata-item">' +
'<span class="label">' + t('claudeManager.path') + '</span>' +
'<span class="value path">' + escapeHtml(selectedFile.relativePath) + '</span>' +
'</div>' +
'<div class="metadata-item">' +
'<span class="label">' + t('claudeManager.size') + '</span>' +
'<span class="value">' + formatFileSize(selectedFile.size) + '</span>' +
'</div>' +
'<div class="metadata-item">' +
'<span class="label">' + t('claudeManager.modified') + '</span>' +
'<span class="value">' + formatDate(selectedFile.lastModified) + '</span>' +
'</div>' +
'</div>';
if (selectedFile.stats) {
html += '<div class="metadata-section">' +
'<h4>' + t('claudeManager.statistics') + '</h4>' +
'<div class="metadata-item">' +
'<span class="label">' + t('claudeManager.lines') + '</span>' +
'<span class="value">' + selectedFile.stats.lines + '</span>' +
'</div>' +
'<div class="metadata-item">' +
'<span class="label">' + t('claudeManager.words') + '</span>' +
'<span class="value">' + selectedFile.stats.words + '</span>' +
'</div>' +
'<div class="metadata-item">' +
'<span class="label">' + t('claudeManager.characters') + '</span>' +
'<span class="value">' + selectedFile.stats.characters + '</span>' +
'</div>' +
'</div>';
}
html += '<div class="metadata-section">' +
'<h4>' + t('claudeManager.actions') + '</h4>';
if (isEditMode) {
html += '<button class="btn btn-sm btn-primary full-width" onclick="saveClaudeFile()"' +
(isDirty ? '' : ' disabled') + '>' +
'<i data-lucide="save" class="w-4 h-4"></i> ' + t('common.save') +
'</button>';
html += '<button class="btn btn-sm btn-secondary full-width" onclick="toggleEditMode()">' +
'<i data-lucide="x" class="w-4 h-4"></i> ' + t('common.cancel') +
'</button>';
} else {
html += '<button class="btn btn-sm btn-secondary full-width" onclick="toggleEditMode()">' +
'<i data-lucide="edit-2" class="w-4 h-4"></i> ' + t('common.edit') +
'</button>';
}
// Delete button (only for CLAUDE.md files, not in edit mode)
if (!isEditMode && selectedFile.level !== 'file') {
html += '<button class="btn btn-sm btn-danger full-width" onclick="confirmDeleteFile()">' +
'<i data-lucide="trash-2" class="w-4 h-4"></i> ' + t('claude.deleteFile') +
'</button>';
}
html += '</div>'; // end actions section
// CLI Sync Panel
html += '<div class="metadata-section cli-sync-panel">' +
'<div class="panel-header">' +
'<i data-lucide="sparkles" class="w-4 h-4"></i>' +
'<span>' + (t('claude.cliSync') || 'CLI Auto-Sync') + '</span>' +
'</div>' +
'<div class="sync-config">' +
'<label>' + (t('claude.tool') || 'Tool') + '</label>' +
'<select id="cliToolSelect" class="sync-select">' +
'<option value="gemini">Gemini</option>' +
'<option value="qwen">Qwen</option>' +
'</select>' +
'<label>' + (t('claude.mode') || 'Mode') + '</label>' +
'<select id="cliModeSelect" class="sync-select">' +
'<option value="update">' + (t('claude.modeUpdate') || 'Update (Smart Merge)') + '</option>' +
'<option value="generate">' + (t('claude.modeGenerate') || 'Generate (Full Replace)') + '</option>' +
'<option value="append">' + (t('claude.modeAppend') || 'Append') + '</option>' +
'</select>' +
'</div>' +
'<button class="btn btn-sm btn-primary full-width sync-button" onclick="syncFileWithCLI()" id="cliSyncButton">' +
'<i data-lucide="refresh-cw" class="w-4 h-4"></i> ' +
(t('claude.syncButton') || 'Sync with CLI') +
'</button>' +
'<div id="syncProgress" class="sync-progress" style="display:none;">' +
'<i data-lucide="loader" class="w-4 h-4"></i>' +
'<span id="syncProgressText">' + (t('claude.syncing') || 'Analyzing...') + '</span>' +
'</div>' +
'</div>'; // end cli-sync-panel
html += '</div>'; // end file-metadata
container.innerHTML = html;
if (window.lucide) lucide.createIcons();
}
// ========== CLI Sync Functions ==========
async function syncFileWithCLI() {
if (!selectedFile) return;
var tool = document.getElementById('cliToolSelect').value;
var mode = document.getElementById('cliModeSelect').value;
// Show progress
showSyncProgress(true, tool);
// Disable sync button
var syncButton = document.getElementById('cliSyncButton');
if (syncButton) syncButton.disabled = true;
try {
var response = await fetch('/api/memory/claude/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
level: selectedFile.level,
path: selectedFile.level === 'module' ? selectedFile.path.replace(/CLAUDE\.md$/, '').replace(/\/$/, '') : undefined,
tool: tool,
mode: mode
})
});
var result = await response.json();
if (result.success) {
// Reload file content
var fileData = await loadFileContent(selectedFile.path);
if (fileData) {
selectedFile = fileData;
renderFileViewer();
renderFileMetadata();
}
showClaudeNotification('success', (t('claude.syncSuccess') || 'Synced successfully').replace('{file}', selectedFile.name));
} else {
showClaudeNotification('error', (t('claude.syncError') || 'Sync failed').replace('{error}', result.error || 'Unknown error'));
}
} catch (error) {
console.error('CLI sync error:', error);
showClaudeNotification('error', (t('claude.syncError') || 'Sync failed').replace('{error}', error.message));
} finally {
showSyncProgress(false);
if (syncButton) syncButton.disabled = false;
}
}
function showSyncProgress(show, tool) {
var progressEl = document.getElementById('syncProgress');
var progressText = document.getElementById('syncProgressText');
if (!progressEl) return;
if (show) {
progressEl.style.display = 'flex';
if (progressText) {
var text = (t('claude.syncing') || 'Analyzing with {tool}...').replace('{tool}', tool || 'CLI');
progressText.textContent = text;
}
if (window.lucide) lucide.createIcons();
} else {
progressEl.style.display = 'none';
}
}
async function loadFileContent(filePath) {
try {
var res = await fetch('/api/memory/claude/file?path=' + encodeURIComponent(filePath));
if (!res.ok) return null;
return await res.json();
} catch (error) {
console.error('Error loading file content:', error);
return null;
}
}
function showClaudeNotification(type, message) {
// Use global notification system if available
if (typeof addGlobalNotification === 'function') {
addGlobalNotification(type, message, null, 'CLAUDE.md');
} else {
// Fallback to simple alert
alert(message);
}
}
// ========== Search Functions ==========
function filterFileTree(query) {
searchQuery = query.toLowerCase();
renderFileTree();
// Add keyboard shortcut handler
if (query && !window.claudeSearchKeyboardHandlerAdded) {
document.addEventListener('keydown', handleSearchKeyboard);
window.claudeSearchKeyboardHandlerAdded = true;
}
}
function handleSearchKeyboard(e) {
// Ctrl+F or Cmd+F
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
var searchInput = document.getElementById('fileSearchInput');
if (searchInput) {
searchInput.focus();
searchInput.select();
}
}
}
// ========== File Creation Functions ==========
function showCreateFileDialog() {
var dialog = '<div class="modal-overlay" onclick="closeCreateDialog()">' +
'<div class="create-dialog" onclick="event.stopPropagation()">' +
'<h3>' + t('claude.createDialogTitle') + '</h3>' +
'<div class="dialog-form">' +
'<label>' + t('claude.selectLevel') + '</label>' +
'<select id="createLevel" onchange="toggleModulePathInput(this.value)">' +
'<option value="user">' + t('claude.levelUser') + '</option>' +
'<option value="project">' + t('claude.levelProject') + '</option>' +
'<option value="module">' + t('claude.levelModule') + '</option>' +
'</select>' +
'<label id="modulePathLabel" style="display:none;">' + t('claude.modulePath') + '</label>' +
'<input id="modulePath" type="text" style="display:none;" placeholder="e.g., src/components">' +
'<label>' + t('claude.selectTemplate') + '</label>' +
'<select id="createTemplate">' +
'<option value="default">' + t('claude.templateDefault') + '</option>' +
'<option value="minimal">' + t('claude.templateMinimal') + '</option>' +
'<option value="comprehensive">' + t('claude.templateComprehensive') + '</option>' +
'</select>' +
'</div>' +
'<div class="dialog-buttons">' +
'<button onclick="closeCreateDialog()" class="btn btn-sm btn-secondary">' + t('common.cancel') + '</button>' +
'<button onclick="createNewFile()" class="btn btn-sm btn-primary">' + t('claude.createFile') + '</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.insertAdjacentHTML('beforeend', dialog);
if (window.lucide) lucide.createIcons();
}
function closeCreateDialog() {
var overlay = document.querySelector('.modal-overlay');
if (overlay) overlay.remove();
}
function toggleModulePathInput(level) {
var pathLabel = document.getElementById('modulePathLabel');
var pathInput = document.getElementById('modulePath');
if (level === 'module') {
pathLabel.style.display = 'block';
pathInput.style.display = 'block';
} else {
pathLabel.style.display = 'none';
pathInput.style.display = 'none';
}
}
async function createNewFile() {
var level = document.getElementById('createLevel').value;
var template = document.getElementById('createTemplate').value;
var modulePath = document.getElementById('modulePath').value;
if (level === 'module' && !modulePath) {
addGlobalNotification('error', t('claude.modulePathRequired') || 'Module path is required', null, 'CLAUDE.md');
return;
}
try {
var res = await fetch('/api/memory/claude/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
level: level,
path: modulePath || undefined,
template: template
})
});
if (!res.ok) throw new Error('Failed to create file');
var result = await res.json();
closeCreateDialog();
addGlobalNotification('success', t('claude.fileCreated') || 'File created successfully', null, 'CLAUDE.md');
// Refresh file tree
await refreshClaudeFiles();
} catch (error) {
console.error('Error creating file:', error);
addGlobalNotification('error', t('claude.createFileError') || 'Failed to create file', null, 'CLAUDE.md');
}
}
// ========== File Deletion Functions ==========
async function confirmDeleteFile() {
if (!selectedFile) return;
var confirmed = confirm(
(t('claude.deleteConfirm') || 'Are you sure you want to delete {file}?').replace('{file}', selectedFile.name) + '\n\n' +
'Path: ' + selectedFile.path + '\n\n' +
(t('claude.deleteWarning') || 'This action cannot be undone.')
);
if (!confirmed) return;
try {
var res = await fetch('/api/memory/claude/file?path=' + encodeURIComponent(selectedFile.path) + '&confirm=true', {
method: 'DELETE'
});
if (!res.ok) throw new Error('Failed to delete file');
addGlobalNotification('success', t('claude.fileDeleted') || 'File deleted successfully', null, 'CLAUDE.md');
selectedFile = null;
// Refresh file tree
await refreshClaudeFiles();
} catch (error) {
console.error('Error deleting file:', error);
addGlobalNotification('error', t('claude.deleteFileError') || 'Failed to delete file', null, 'CLAUDE.md');
}
}
// ========== Copy Content Function ==========
function copyFileContent() {
if (!selectedFile || !selectedFile.content) return;
navigator.clipboard.writeText(selectedFile.content).then(function() {
addGlobalNotification('success', t('claude.contentCopied') || 'Content copied to clipboard', null, 'CLAUDE.md');
}).catch(function(error) {
console.error('Error copying content:', error);
addGlobalNotification('error', t('claude.copyError') || 'Failed to copy content', null, 'CLAUDE.md');
});
}
// ========== Utility Functions ==========
// Note: escapeHtml and formatDate are imported from utils.js
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// Update navigation badge with total file count
function updateClaudeBadge() {
var badge = document.getElementById('badgeClaude');
if (badge && claudeFilesData && claudeFilesData.summary) {
var total = claudeFilesData.summary.totalFiles;
badge.textContent = total;
}
}

View File

@@ -207,32 +207,14 @@ function initToolConfigModalEvents(tool, currentConfig, models) {
// Install/Uninstall
var installBtn = document.getElementById('installBtn');
if (installBtn) {
installBtn.onclick = async function() {
installBtn.onclick = function() {
var status = cliToolStatus[tool] || {};
var endpoint = status.available ? '/api/cli/uninstall' : '/api/cli/install';
var action = status.available ? 'uninstalling' : 'installing';
showRefreshToast(tool.charAt(0).toUpperCase() + tool.slice(1) + ' ' + action + '...', 'info');
closeModal();
try {
var response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool: tool })
});
var result = await response.json();
if (result.success) {
showRefreshToast(result.message || (tool + ' ' + (status.available ? 'uninstalled' : 'installed')), 'success');
await loadCliToolStatus();
renderToolsSection();
if (window.lucide) lucide.createIcons();
} else {
showRefreshToast(result.error || 'Operation failed', 'error');
}
} catch (err) {
showRefreshToast('Failed: ' + err.message, 'error');
if (status.available) {
openCliUninstallWizard(tool);
} else {
openCliInstallWizard(tool);
}
};
}
@@ -384,20 +366,22 @@ function renderToolsSection() {
}).join('');
// CodexLens item
var codexLensHtml = '<div class="tool-item ' + (codexLensStatus.ready ? 'available' : 'unavailable') + '">' +
var codexLensHtml = '<div class="tool-item clickable ' + (codexLensStatus.ready ? 'available' : 'unavailable') + '" onclick="showCodexLensConfigModal()">' +
'<div class="tool-item-left">' +
'<span class="tool-status-dot ' + (codexLensStatus.ready ? 'status-available' : 'status-unavailable') + '"></span>' +
'<div class="tool-item-info">' +
'<div class="tool-item-name">CodexLens <span class="tool-type-badge">Index</span></div>' +
'<div class="tool-item-name">CodexLens <span class="tool-type-badge">Index</span>' +
'<i data-lucide="settings" class="w-3 h-3 tool-config-icon"></i></div>' +
'<div class="tool-item-desc">' + (codexLensStatus.ready ? t('cli.codexLensDesc') : t('cli.codexLensDescFull')) + '</div>' +
'</div>' +
'</div>' +
'<div class="tool-item-right">' +
(codexLensStatus.ready
? '<span class="tool-status-text success"><i data-lucide="check-circle" class="w-3.5 h-3.5"></i> v' + (codexLensStatus.version || 'installed') + '</span>' +
'<button class="btn-sm btn-outline" onclick="initCodexLensIndex()"><i data-lucide="database" class="w-3 h-3"></i> ' + t('cli.initIndex') + '</button>'
'<button class="btn-sm btn-outline" onclick="event.stopPropagation(); initCodexLensIndex()"><i data-lucide="database" class="w-3 h-3"></i> ' + t('cli.initIndex') + '</button>' +
'<button class="btn-sm btn-outline btn-danger" onclick="event.stopPropagation(); uninstallCodexLens()"><i data-lucide="trash-2" class="w-3 h-3"></i> ' + t('cli.uninstall') + '</button>'
: '<span class="tool-status-text muted"><i data-lucide="circle-dashed" class="w-3.5 h-3.5"></i> ' + t('cli.notInstalled') + '</span>' +
'<button class="btn-sm btn-primary" onclick="installCodexLens()"><i data-lucide="download" class="w-3 h-3"></i> ' + t('cli.install') + '</button>') +
'<button class="btn-sm btn-primary" onclick="event.stopPropagation(); installCodexLens()"><i data-lucide="download" class="w-3 h-3"></i> ' + t('cli.install') + '</button>') +
'</div>' +
'</div>';
@@ -1203,3 +1187,607 @@ function handleCliExecutionError(payload) {
currentCliExecution = null;
}
// ========== CLI Tool Install/Uninstall Wizards ==========
function openCliInstallWizard(toolName) {
var toolDescriptions = {
gemini: 'Google AI for code analysis and generation',
qwen: 'Alibaba AI assistant for coding',
codex: 'OpenAI code generation and understanding',
claude: 'Anthropic AI assistant'
};
var toolPackages = {
gemini: '@google/gemini-cli',
qwen: '@qwen-code/qwen-code',
codex: '@openai/codex',
claude: '@anthropic-ai/claude-code'
};
var modal = document.createElement('div');
modal.id = 'cliInstallModal';
modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
modal.innerHTML =
'<div class="bg-card rounded-lg shadow-xl w-full max-w-md mx-4 overflow-hidden">' +
'<div class="p-6">' +
'<div class="flex items-center gap-3 mb-4">' +
'<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">' +
'<i data-lucide="download" class="w-5 h-5 text-primary"></i>' +
'</div>' +
'<div>' +
'<h3 class="text-lg font-semibold">Install ' + toolName.charAt(0).toUpperCase() + toolName.slice(1) + '</h3>' +
'<p class="text-sm text-muted-foreground">' + (toolDescriptions[toolName] || 'CLI tool') + '</p>' +
'</div>' +
'</div>' +
'<div class="space-y-4">' +
'<div class="bg-muted/50 rounded-lg p-4">' +
'<h4 class="font-medium mb-2">What will be installed:</h4>' +
'<ul class="text-sm space-y-2 text-muted-foreground">' +
'<li class="flex items-start gap-2">' +
'<i data-lucide="check" class="w-4 h-4 text-success mt-0.5"></i>' +
'<span><strong>NPM Package:</strong> <code class="bg-muted px-1 rounded">' + (toolPackages[toolName] || toolName) + '</code></span>' +
'</li>' +
'<li class="flex items-start gap-2">' +
'<i data-lucide="check" class="w-4 h-4 text-success mt-0.5"></i>' +
'<span><strong>Global installation</strong> - Available system-wide</span>' +
'</li>' +
'<li class="flex items-start gap-2">' +
'<i data-lucide="check" class="w-4 h-4 text-success mt-0.5"></i>' +
'<span><strong>CLI commands</strong> - Accessible from terminal</span>' +
'</li>' +
'</ul>' +
'</div>' +
'<div class="bg-primary/5 border border-primary/20 rounded-lg p-3">' +
'<div class="flex items-start gap-2">' +
'<i data-lucide="info" class="w-4 h-4 text-primary mt-0.5"></i>' +
'<div class="text-sm text-muted-foreground">' +
'<p class="font-medium text-foreground">Installation Method</p>' +
'<p class="mt-1">Uses <code class="bg-muted px-1 rounded">npm install -g</code></p>' +
'<p class="mt-1">First installation may take 1-2 minutes depending on network speed.</p>' +
'</div>' +
'</div>' +
'</div>' +
'<div id="cliInstallProgress" class="hidden">' +
'<div class="flex items-center gap-3">' +
'<div class="animate-spin w-5 h-5 border-2 border-primary border-t-transparent rounded-full"></div>' +
'<span class="text-sm" id="cliInstallStatus">Starting installation...</span>' +
'</div>' +
'<div class="mt-2 h-2 bg-muted rounded-full overflow-hidden">' +
'<div id="cliInstallProgressBar" class="h-full bg-primary transition-all duration-300" style="width: 0%"></div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="border-t border-border p-4 flex justify-end gap-3 bg-muted/30">' +
'<button class="btn-outline px-4 py-2" onclick="closeCliInstallWizard()">Cancel</button>' +
'<button id="cliInstallBtn" class="btn-primary px-4 py-2" onclick="startCliInstall(\'' + toolName + '\')">' +
'<i data-lucide="download" class="w-4 h-4 mr-2"></i>' +
'Install Now' +
'</button>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
if (window.lucide) {
lucide.createIcons();
}
}
function closeCliInstallWizard() {
var modal = document.getElementById('cliInstallModal');
if (modal) {
modal.remove();
}
}
async function startCliInstall(toolName) {
var progressDiv = document.getElementById('cliInstallProgress');
var installBtn = document.getElementById('cliInstallBtn');
var statusText = document.getElementById('cliInstallStatus');
var progressBar = document.getElementById('cliInstallProgressBar');
progressDiv.classList.remove('hidden');
installBtn.disabled = true;
installBtn.innerHTML = '<span class="animate-pulse">Installing...</span>';
var stages = [
{ progress: 20, text: 'Connecting to NPM registry...' },
{ progress: 40, text: 'Downloading package...' },
{ progress: 60, text: 'Installing dependencies...' },
{ progress: 80, text: 'Setting up CLI commands...' },
{ progress: 95, text: 'Finalizing installation...' }
];
var currentStage = 0;
var progressInterval = setInterval(function() {
if (currentStage < stages.length) {
statusText.textContent = stages[currentStage].text;
progressBar.style.width = stages[currentStage].progress + '%';
currentStage++;
}
}, 1000);
try {
var response = await fetch('/api/cli/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool: toolName })
});
clearInterval(progressInterval);
var result = await response.json();
if (result.success) {
progressBar.style.width = '100%';
statusText.textContent = 'Installation complete!';
setTimeout(function() {
closeCliInstallWizard();
showRefreshToast(toolName + ' installed successfully!', 'success');
loadCliToolStatus().then(function() {
renderToolsSection();
if (window.lucide) lucide.createIcons();
});
}, 1000);
} else {
statusText.textContent = 'Error: ' + result.error;
progressBar.classList.add('bg-destructive');
installBtn.disabled = false;
installBtn.innerHTML = '<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> Retry';
if (window.lucide) lucide.createIcons();
}
} catch (err) {
clearInterval(progressInterval);
statusText.textContent = 'Error: ' + err.message;
progressBar.classList.add('bg-destructive');
installBtn.disabled = false;
installBtn.innerHTML = '<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> Retry';
if (window.lucide) lucide.createIcons();
}
}
function openCliUninstallWizard(toolName) {
var modal = document.createElement('div');
modal.id = 'cliUninstallModal';
modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
modal.innerHTML =
'<div class="bg-card rounded-lg shadow-xl w-full max-w-md mx-4 overflow-hidden">' +
'<div class="p-6">' +
'<div class="flex items-center gap-3 mb-4">' +
'<div class="w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">' +
'<i data-lucide="trash-2" class="w-5 h-5 text-destructive"></i>' +
'</div>' +
'<div>' +
'<h3 class="text-lg font-semibold">Uninstall ' + toolName.charAt(0).toUpperCase() + toolName.slice(1) + '</h3>' +
'<p class="text-sm text-muted-foreground">Remove CLI tool from system</p>' +
'</div>' +
'</div>' +
'<div class="space-y-4">' +
'<div class="bg-destructive/5 border border-destructive/20 rounded-lg p-4">' +
'<h4 class="font-medium text-destructive mb-2">What will be removed:</h4>' +
'<ul class="text-sm space-y-2 text-muted-foreground">' +
'<li class="flex items-start gap-2">' +
'<i data-lucide="x" class="w-4 h-4 text-destructive mt-0.5"></i>' +
'<span>Global NPM package</span>' +
'</li>' +
'<li class="flex items-start gap-2">' +
'<i data-lucide="x" class="w-4 h-4 text-destructive mt-0.5"></i>' +
'<span>CLI commands and executables</span>' +
'</li>' +
'<li class="flex items-start gap-2">' +
'<i data-lucide="x" class="w-4 h-4 text-destructive mt-0.5"></i>' +
'<span>Tool configuration (if any)</span>' +
'</li>' +
'</ul>' +
'</div>' +
'<div class="bg-warning/10 border border-warning/20 rounded-lg p-3">' +
'<div class="flex items-start gap-2">' +
'<i data-lucide="alert-triangle" class="w-4 h-4 text-warning mt-0.5"></i>' +
'<div class="text-sm">' +
'<p class="font-medium text-warning">Note</p>' +
'<p class="text-muted-foreground">You can reinstall this tool anytime from the CLI Manager.</p>' +
'</div>' +
'</div>' +
'</div>' +
'<div id="cliUninstallProgress" class="hidden">' +
'<div class="flex items-center gap-3">' +
'<div class="animate-spin w-5 h-5 border-2 border-destructive border-t-transparent rounded-full"></div>' +
'<span class="text-sm" id="cliUninstallStatus">Removing package...</span>' +
'</div>' +
'<div class="mt-2 h-2 bg-muted rounded-full overflow-hidden">' +
'<div id="cliUninstallProgressBar" class="h-full bg-destructive transition-all duration-300" style="width: 0%"></div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="border-t border-border p-4 flex justify-end gap-3 bg-muted/30">' +
'<button class="btn-outline px-4 py-2" onclick="closeCliUninstallWizard()">Cancel</button>' +
'<button id="cliUninstallBtn" class="btn-destructive px-4 py-2" onclick="startCliUninstall(\'' + toolName + '\')">' +
'<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>' +
'Uninstall' +
'</button>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
if (window.lucide) {
lucide.createIcons();
}
}
function closeCliUninstallWizard() {
var modal = document.getElementById('cliUninstallModal');
if (modal) {
modal.remove();
}
}
async function startCliUninstall(toolName) {
var progressDiv = document.getElementById('cliUninstallProgress');
var uninstallBtn = document.getElementById('cliUninstallBtn');
var statusText = document.getElementById('cliUninstallStatus');
var progressBar = document.getElementById('cliUninstallProgressBar');
progressDiv.classList.remove('hidden');
uninstallBtn.disabled = true;
uninstallBtn.innerHTML = '<span class="animate-pulse">Uninstalling...</span>';
var stages = [
{ progress: 33, text: 'Removing package files...' },
{ progress: 66, text: 'Cleaning up dependencies...' },
{ progress: 90, text: 'Finalizing removal...' }
];
var currentStage = 0;
var progressInterval = setInterval(function() {
if (currentStage < stages.length) {
statusText.textContent = stages[currentStage].text;
progressBar.style.width = stages[currentStage].progress + '%';
currentStage++;
}
}, 500);
try {
var response = await fetch('/api/cli/uninstall', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool: toolName })
});
clearInterval(progressInterval);
var result = await response.json();
if (result.success) {
progressBar.style.width = '100%';
statusText.textContent = 'Uninstallation complete!';
setTimeout(function() {
closeCliUninstallWizard();
showRefreshToast(toolName + ' uninstalled successfully!', 'success');
loadCliToolStatus().then(function() {
renderToolsSection();
if (window.lucide) lucide.createIcons();
});
}, 1000);
} else {
statusText.textContent = 'Error: ' + result.error;
progressBar.classList.remove('bg-destructive');
progressBar.classList.add('bg-destructive');
uninstallBtn.disabled = false;
uninstallBtn.innerHTML = '<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> Retry';
if (window.lucide) lucide.createIcons();
}
} catch (err) {
clearInterval(progressInterval);
statusText.textContent = 'Error: ' + err.message;
progressBar.classList.remove('bg-destructive');
progressBar.classList.add('bg-destructive');
uninstallBtn.disabled = false;
uninstallBtn.innerHTML = '<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> Retry';
if (window.lucide) lucide.createIcons();
}
}
// ========== CodexLens Configuration Modal ==========
async function showCodexLensConfigModal() {
var loadingContent = '<div class="text-center py-8">' +
'<div class="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full mx-auto mb-4"></div>' +
'<p class="text-muted-foreground">' + t('codexlens.loadingConfig') + '</p>' +
'</div>';
showModal(t('codexlens.config'), loadingContent, { size: 'md' });
try {
// Fetch current configuration
var response = await fetch('/api/codexlens/config');
var config = await response.json();
var content = buildCodexLensConfigContent(config);
showModal('CodexLens Configuration', content, { size: 'md' });
setTimeout(function() {
initCodexLensConfigEvents(config);
if (window.lucide) lucide.createIcons();
}, 100);
} catch (err) {
var errorContent = '<div class="bg-destructive/10 border border-destructive/20 rounded-lg p-4">' +
'<div class="flex items-start gap-2">' +
'<i data-lucide="alert-circle" class="w-5 h-5 text-destructive mt-0.5"></i>' +
'<div>' +
'<p class="font-medium text-destructive">Failed to load configuration</p>' +
'<p class="text-sm text-muted-foreground mt-1">' + err.message + '</p>' +
'</div>' +
'</div>' +
'</div>';
showModal('CodexLens Configuration', errorContent, { size: 'md' });
}
}
function buildCodexLensConfigContent(config) {
var status = codexLensStatus || {};
var isInstalled = status.ready;
var indexDir = config.index_dir || '~/.codexlens/indexes';
var currentWorkspace = config.current_workspace || 'None';
var indexCount = config.index_count || 0;
return '<div class="tool-config-modal">' +
// Status Section
'<div class="tool-config-section">' +
'<h4>' + t('codexlens.status') + '</h4>' +
'<div class="tool-config-badges">' +
'<span class="badge ' + (isInstalled ? 'badge-success' : 'badge-muted') + '">' +
'<i data-lucide="' + (isInstalled ? 'check-circle' : 'circle-dashed') + '" class="w-3 h-3"></i> ' +
(isInstalled ? t('codexlens.installed') : t('codexlens.notInstalled')) +
'</span>' +
'<span class="badge badge-primary">' +
'<i data-lucide="database" class="w-3 h-3"></i> ' + indexCount + ' ' + t('codexlens.indexes') +
'</span>' +
'</div>' +
(currentWorkspace !== 'None'
? '<div class="mt-3 p-3 bg-muted/30 rounded-lg">' +
'<p class="text-sm text-muted-foreground mb-1">' + t('codexlens.currentWorkspace') + ':</p>' +
'<p class="text-sm font-mono break-all">' + escapeHtml(currentWorkspace) + '</p>' +
'</div>'
: '') +
'</div>' +
// Index Storage Path Section
'<div class="tool-config-section">' +
'<h4>' + t('codexlens.indexStoragePath') + ' <span class="text-muted">(' + t('codexlens.whereIndexesStored') + ')</span></h4>' +
'<div class="space-y-3">' +
'<div class="bg-muted/30 rounded-lg p-3">' +
'<p class="text-sm text-muted-foreground mb-2">' + t('codexlens.currentPath') + ':</p>' +
'<p class="text-sm font-mono break-all bg-background px-2 py-1 rounded border border-border">' +
escapeHtml(indexDir) +
'</p>' +
'</div>' +
'<div>' +
'<label class="text-sm font-medium mb-2 block">' + t('codexlens.newStoragePath') + ':</label>' +
'<input type="text" id="indexDirInput" class="tool-config-input w-full" ' +
'placeholder="' + t('codexlens.pathPlaceholder') + '" ' +
'value="' + escapeHtml(indexDir) + '" />' +
'<p class="text-xs text-muted-foreground mt-2">' +
'<i data-lucide="info" class="w-3 h-3 inline"></i> ' +
t('codexlens.pathInfo') +
'</p>' +
'</div>' +
'<div class="bg-warning/10 border border-warning/20 rounded-lg p-3">' +
'<div class="flex items-start gap-2">' +
'<i data-lucide="alert-triangle" class="w-4 h-4 text-warning mt-0.5"></i>' +
'<div class="text-sm">' +
'<p class="font-medium text-warning">' + t('codexlens.migrationRequired') + '</p>' +
'<p class="text-muted-foreground mt-1">' + t('codexlens.migrationWarning') + '</p>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
// Actions Section
'<div class="tool-config-section">' +
'<h4>' + t('codexlens.actions') + '</h4>' +
'<div class="tool-config-actions">' +
(isInstalled
? '<button class="btn-sm btn-outline" onclick="event.stopPropagation(); initCodexLensIndex()">' +
'<i data-lucide="database" class="w-3 h-3"></i> ' + t('codexlens.initializeIndex') +
'</button>' +
'<button class="btn-sm btn-outline" onclick="event.stopPropagation(); cleanCodexLensIndexes()">' +
'<i data-lucide="trash" class="w-3 h-3"></i> ' + t('codexlens.cleanAllIndexes') +
'</button>' +
'<button class="btn-sm btn-outline btn-danger" onclick="event.stopPropagation(); uninstallCodexLens()">' +
'<i data-lucide="trash-2" class="w-3 h-3"></i> ' + t('cli.uninstall') +
'</button>'
: '<button class="btn-sm btn-primary" onclick="event.stopPropagation(); installCodexLens()">' +
'<i data-lucide="download" class="w-3 h-3"></i> ' + t('codexlens.installCodexLens') +
'</button>') +
'</div>' +
'</div>' +
// Test Search Section
(isInstalled
? '<div class="tool-config-section">' +
'<h4>' + t('codexlens.testSearch') + ' <span class="text-muted">(' + t('codexlens.testFunctionality') + ')</span></h4>' +
'<div class="space-y-3">' +
'<div class="flex gap-2">' +
'<select id="searchTypeSelect" class="tool-config-select flex-1">' +
'<option value="search">' + t('codexlens.textSearch') + '</option>' +
'<option value="search_files">' + t('codexlens.fileSearch') + '</option>' +
'<option value="symbol">' + t('codexlens.symbolSearch') + '</option>' +
'</select>' +
'</div>' +
'<div>' +
'<input type="text" id="searchQueryInput" class="tool-config-input w-full" ' +
'placeholder="' + t('codexlens.searchPlaceholder') + '" />' +
'</div>' +
'<div>' +
'<button class="btn-sm btn-primary w-full" id="runSearchBtn">' +
'<i data-lucide="search" class="w-3 h-3"></i> ' + t('codexlens.runSearch') +
'</button>' +
'</div>' +
'<div id="searchResults" class="hidden">' +
'<div class="bg-muted/30 rounded-lg p-3 max-h-64 overflow-y-auto">' +
'<div class="flex items-center justify-between mb-2">' +
'<p class="text-sm font-medium">' + t('codexlens.results') + ':</p>' +
'<span id="searchResultCount" class="text-xs text-muted-foreground"></span>' +
'</div>' +
'<pre id="searchResultContent" class="text-xs font-mono whitespace-pre-wrap break-all"></pre>' +
'</div>' +
'</div>' +
'</div>' +
'</div>'
: '') +
// Footer
'<div class="tool-config-footer">' +
'<button class="btn btn-outline" onclick="closeModal()">' + t('common.cancel') + '</button>' +
'<button class="btn btn-primary" id="saveCodexLensConfigBtn">' +
'<i data-lucide="save" class="w-3.5 h-3.5"></i> ' + t('codexlens.saveConfig') +
'</button>' +
'</div>' +
'</div>';
}
function initCodexLensConfigEvents(currentConfig) {
var saveBtn = document.getElementById('saveCodexLensConfigBtn');
if (saveBtn) {
saveBtn.onclick = async function() {
var indexDirInput = document.getElementById('indexDirInput');
var newIndexDir = indexDirInput ? indexDirInput.value.trim() : '';
if (!newIndexDir) {
showRefreshToast(t('codexlens.pathEmpty'), 'error');
return;
}
if (newIndexDir === currentConfig.index_dir) {
closeModal();
return;
}
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="animate-pulse">' + t('common.saving') + '</span>';
try {
var response = await fetch('/api/codexlens/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ index_dir: newIndexDir })
});
var result = await response.json();
if (result.success) {
showRefreshToast(t('codexlens.configSaved'), 'success');
closeModal();
// Refresh CodexLens status
if (typeof loadCodexLensStatus === 'function') {
await loadCodexLensStatus();
renderToolsSection();
if (window.lucide) lucide.createIcons();
}
} else {
showRefreshToast(t('common.saveFailed') + ': ' + result.error, 'error');
saveBtn.disabled = false;
saveBtn.innerHTML = '<i data-lucide="save" class="w-3.5 h-3.5"></i> ' + t('codexlens.saveConfig');
if (window.lucide) lucide.createIcons();
}
} catch (err) {
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
saveBtn.disabled = false;
saveBtn.innerHTML = '<i data-lucide="save" class="w-3.5 h-3.5"></i> ' + t('codexlens.saveConfig');
if (window.lucide) lucide.createIcons();
}
};
}
// Test Search Button
var runSearchBtn = document.getElementById('runSearchBtn');
if (runSearchBtn) {
runSearchBtn.onclick = async function() {
var searchType = document.getElementById('searchTypeSelect').value;
var query = document.getElementById('searchQueryInput').value.trim();
var resultsDiv = document.getElementById('searchResults');
var resultCount = document.getElementById('searchResultCount');
var resultContent = document.getElementById('searchResultContent');
if (!query) {
showRefreshToast(t('codexlens.enterQuery'), 'warning');
return;
}
runSearchBtn.disabled = true;
runSearchBtn.innerHTML = '<span class="animate-pulse">' + t('codexlens.searching') + '</span>';
resultsDiv.classList.add('hidden');
try {
var endpoint = '/api/codexlens/' + searchType;
var params = new URLSearchParams({ query: query, limit: '20' });
var response = await fetch(endpoint + '?' + params.toString());
var result = await response.json();
console.log('[CodexLens Test] Search result:', result);
if (result.success) {
var results = result.results || result.files || [];
resultCount.textContent = results.length + ' ' + t('codexlens.resultsCount');
resultContent.textContent = JSON.stringify(results, null, 2);
resultsDiv.classList.remove('hidden');
showRefreshToast(t('codexlens.searchCompleted') + ': ' + results.length + ' ' + t('codexlens.resultsCount'), 'success');
} else {
resultContent.textContent = t('common.error') + ': ' + (result.error || t('common.unknownError'));
resultsDiv.classList.remove('hidden');
showRefreshToast(t('codexlens.searchFailed') + ': ' + result.error, 'error');
}
runSearchBtn.disabled = false;
runSearchBtn.innerHTML = '<i data-lucide="search" class="w-3 h-3"></i> ' + t('codexlens.runSearch');
if (window.lucide) lucide.createIcons();
} catch (err) {
console.error('[CodexLens Test] Error:', err);
resultContent.textContent = t('common.exception') + ': ' + err.message;
resultsDiv.classList.remove('hidden');
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
runSearchBtn.disabled = false;
runSearchBtn.innerHTML = '<i data-lucide="search" class="w-3 h-3"></i> ' + t('codexlens.runSearch');
if (window.lucide) lucide.createIcons();
}
};
}
}
async function cleanCodexLensIndexes() {
if (!confirm(t('codexlens.cleanConfirm'))) {
return;
}
try {
showRefreshToast(t('codexlens.cleaning'), 'info');
var response = await fetch('/api/codexlens/clean', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ all: true })
});
var result = await response.json();
if (result.success) {
showRefreshToast(t('codexlens.cleanSuccess'), 'success');
// Refresh status
if (typeof loadCodexLensStatus === 'function') {
await loadCodexLensStatus();
renderToolsSection();
if (window.lucide) lucide.createIcons();
}
} else {
showRefreshToast(t('codexlens.cleanFailed') + ': ' + result.error, 'error');
}
} catch (err) {
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
}
}

View File

@@ -47,18 +47,71 @@ async function renderMcpManager() {
const projectData = mcpAllProjects[currentPath] || {};
const projectServers = projectData.mcpServers || {};
const disabledServers = projectData.disabledMcpServers || [];
const hasMcpJson = projectData.hasMcpJson || false;
const mcpJsonPath = projectData.mcpJsonPath || null;
// Get all available servers from all projects
const allAvailableServers = getAllAvailableMcpServers();
// Separate current project servers and available servers
const currentProjectServerNames = Object.keys(projectServers);
// Separate servers by category:
// 1. Project Available = Global + Project-specific (servers available to current project)
// 2. Global Management = Global servers that can be managed
// 3. Other Projects = Servers from other projects (can install to project or global)
// Separate enterprise, user, and other project servers
const enterpriseServerEntries = Object.entries(mcpEnterpriseServers || {})
.filter(([name]) => !currentProjectServerNames.includes(name));
const userServerEntries = Object.entries(mcpUserServers || {})
.filter(([name]) => !currentProjectServerNames.includes(name) && !(mcpEnterpriseServers || {})[name]);
const currentProjectServerNames = Object.keys(projectServers);
const globalServerNames = Object.keys(mcpUserServers || {});
const enterpriseServerNames = Object.keys(mcpEnterpriseServers || {});
// Project Available MCP: servers available to current project
// This includes: Enterprise (highest priority) + Global + Project-specific
const projectAvailableEntries = [];
// Add enterprise servers first (highest priority)
for (const [name, config] of Object.entries(mcpEnterpriseServers || {})) {
projectAvailableEntries.push({
name,
config,
source: 'enterprise',
canRemove: false,
canToggle: false
});
}
// Add global servers
for (const [name, config] of Object.entries(mcpUserServers || {})) {
if (!enterpriseServerNames.includes(name)) {
projectAvailableEntries.push({
name,
config,
source: 'global',
canRemove: false, // Can't remove from project view, must go to global management
canToggle: true,
isEnabled: !disabledServers.includes(name)
});
}
}
// Add project-specific servers
for (const [name, config] of Object.entries(projectServers)) {
if (!enterpriseServerNames.includes(name) && !globalServerNames.includes(name)) {
projectAvailableEntries.push({
name,
config,
source: 'project',
canRemove: true,
canToggle: true,
isEnabled: !disabledServers.includes(name)
});
}
}
// Global Management: user global servers (for management)
const globalManagementEntries = Object.entries(mcpUserServers || {});
// Enterprise servers (for display only, read-only)
const enterpriseServerEntries = Object.entries(mcpEnterpriseServers || {});
// Other Projects: servers from other projects (not in current project, not global)
const otherProjectServers = Object.entries(allAvailableServers)
.filter(([name, info]) => !currentProjectServerNames.includes(name) && !info.isGlobal);
// Check if CCW Tools is already installed
@@ -126,20 +179,31 @@ async function renderMcpManager() {
</div>
</div>
<!-- Current Project MCP Servers -->
<!-- Project Available MCP Servers -->
<div class="mcp-section mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-foreground">${t('mcp.currentProject')}</h3>
<h3 class="text-lg font-semibold text-foreground">${t('mcp.projectAvailable')}</h3>
<button class="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
onclick="openMcpCreateModal()">
<span>+</span> ${t('mcp.newServer')}
onclick="openMcpCreateModal('project')">
<span>+</span> ${t('mcp.newProjectServer')}
</button>
${hasMcpJson ? `
<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>
.mcp.json
</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="New servers will create .mcp.json">
<i data-lucide="file-plus" class="w-3.5 h-3.5"></i>
Will use .mcp.json
</span>
`}
</div>
<span class="text-sm text-muted-foreground">${currentProjectServerNames.length} ${t('mcp.serversConfigured')}</span>
<span class="text-sm text-muted-foreground">${projectAvailableEntries.length} ${t('mcp.serversAvailable')}</span>
</div>
${currentProjectServerNames.length === 0 ? `
${projectAvailableEntries.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('empty.noMcpServers')}</p>
@@ -147,53 +211,43 @@ async function renderMcpManager() {
</div>
` : `
<div class="mcp-server-grid grid gap-3">
${currentProjectServerNames.map(serverName => {
const serverConfig = projectServers[serverName];
const isEnabled = !disabledServers.includes(serverName);
return renderMcpServerCard(serverName, serverConfig, isEnabled, true);
${projectAvailableEntries.map(entry => {
return renderProjectAvailableServerCard(entry);
}).join('')}
</div>
`}
</div>
<!-- Enterprise MCP Servers (Managed) -->
${enterpriseServerEntries.length > 0 ? `
<div class="mcp-section mb-6">
<div class="flex items-center justify-between mb-4">
<!-- Global Available MCP Servers -->
<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="building-2" class="w-5 h-5"></i>
<h3 class="text-lg font-semibold text-foreground">Enterprise MCP Servers</h3>
<span class="text-xs px-2 py-0.5 bg-warning/20 text-warning rounded-full">Managed</span>
<i data-lucide="globe" class="w-5 h-5 text-success"></i>
<h3 class="text-lg font-semibold text-foreground">${t('mcp.globalAvailable')}</h3>
</div>
<span class="text-sm text-muted-foreground">${enterpriseServerEntries.length} servers (read-only)</span>
<button class="px-3 py-1.5 text-sm bg-success text-success-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
onclick="openMcpCreateModal('global')">
<span>+</span> ${t('mcp.newGlobalServer')}
</button>
</div>
<span class="text-sm text-muted-foreground">${globalManagementEntries.length} ${t('mcp.globalServersFrom')}</span>
</div>
${globalManagementEntries.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="globe" class="w-10 h-10 mx-auto"></i></div>
<p class="text-muted-foreground">${t('empty.noGlobalMcpServers')}</p>
<p class="text-sm text-muted-foreground mt-1">${t('empty.globalServersHint')}</p>
</div>
` : `
<div class="mcp-server-grid grid gap-3">
${enterpriseServerEntries.map(([serverName, serverConfig]) => {
return renderEnterpriseServerCard(serverName, serverConfig);
${globalManagementEntries.map(([serverName, serverConfig]) => {
return renderGlobalManagementCard(serverName, serverConfig);
}).join('')}
</div>
</div>
` : ''}
<!-- User MCP Servers -->
${userServerEntries.length > 0 ? `
<div class="mcp-section mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<i data-lucide="user" class="w-5 h-5"></i>
<h3 class="text-lg font-semibold text-foreground">User MCP Servers</h3>
</div>
<span class="text-sm text-muted-foreground">${userServerEntries.length} servers from ~/.claude.json</span>
</div>
<div class="mcp-server-grid grid gap-3">
${userServerEntries.map(([serverName, serverConfig]) => {
return renderGlobalServerCard(serverName, serverConfig, 'user');
}).join('')}
</div>
</div>
` : ''}
`}
</div>
<!-- Available MCP Servers from Other Projects -->
<div class="mcp-section">
@@ -238,6 +292,7 @@ async function renderMcpManager() {
const serverNames = Object.keys(servers);
const isCurrentProject = path === currentPath;
const enabledCount = serverNames.filter(s => !projectDisabled.includes(s)).length;
const projectHasMcpJson = config.hasMcpJson || false;
return `
<tr class="border-b border-border last:border-b-0 ${isCurrentProject ? 'bg-primary/5' : 'hover:bg-hover/50'}">
@@ -245,9 +300,10 @@ async function renderMcpManager() {
<div class="flex items-center gap-2 min-w-0">
<span class="shrink-0">${isCurrentProject ? '<i data-lucide="map-pin" class="w-4 h-4 text-primary"></i>' : '<i data-lucide="folder" class="w-4 h-4"></i>'}</span>
<div class="min-w-0">
<div class="font-medium text-foreground truncate text-sm" title="${escapeHtml(path)}">
${escapeHtml(path.split('\\').pop() || path)}
${isCurrentProject ? `<span class="ml-2 text-xs text-primary font-medium">${t('mcp.current')}</span>` : ''}
<div class="font-medium text-foreground truncate text-sm flex items-center gap-2" title="${escapeHtml(path)}">
<span class="truncate">${escapeHtml(path.split('\\').pop() || path)}</span>
${isCurrentProject ? `<span class="text-xs text-primary font-medium shrink-0">${t('mcp.current')}</span>` : ''}
${projectHasMcpJson ? `<span class="shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 text-xs bg-success/10 text-success rounded" title=".mcp.json detected"><i data-lucide="file-check" class="w-3 h-3"></i></span>` : ''}
</div>
<div class="text-xs text-muted-foreground truncate">${escapeHtml(path)}</div>
</div>
@@ -291,25 +347,40 @@ async function renderMcpManager() {
if (typeof lucide !== 'undefined') lucide.createIcons();
}
function renderMcpServerCard(serverName, serverConfig, isEnabled, isInCurrentProject) {
const command = serverConfig.command || 'N/A';
const args = serverConfig.args || [];
const hasEnv = serverConfig.env && Object.keys(serverConfig.env).length > 0;
// Render card for Project Available MCP (current project can use)
function renderProjectAvailableServerCard(entry) {
const { name, config, source, canRemove, canToggle, isEnabled } = entry;
const command = config.command || 'N/A';
const args = config.args || [];
const hasEnv = config.env && Object.keys(config.env).length > 0;
// 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>';
} else if (source === 'global') {
sourceBadge = '<span class="text-xs px-2 py-0.5 bg-success/10 text-success rounded-full">Global</span>';
} else if (source === 'project') {
sourceBadge = '<span class="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full">Project</span>';
}
return `
<div class="mcp-server-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all ${isEnabled ? '' : 'opacity-60'}">
<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="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-success"></i>' : '<i data-lucide="x-circle" class="w-5 h-5 text-destructive"></i>'}</span>
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
<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>
<h4 class="font-semibold text-foreground">${escapeHtml(name)}</h4>
${sourceBadge}
</div>
<label class="mcp-toggle relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer"
${isEnabled ? 'checked' : ''}
data-server-name="${escapeHtml(serverName)}"
data-action="toggle">
<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-success"></div>
</label>
${canToggle ? `
<label class="mcp-toggle relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer"
${isEnabled ? 'checked' : ''}
data-server-name="${escapeHtml(name)}"
data-action="toggle">
<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-success"></div>
</label>
` : ''}
</div>
<div class="mcp-server-details text-sm space-y-1">
@@ -326,20 +397,85 @@ function renderMcpServerCard(serverName, serverConfig, isEnabled, isInCurrentPro
${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="text-xs">${Object.keys(config.env).length} variables</span>
</div>
` : ''}
</div>
${isInCurrentProject ? `
<div class="mt-3 pt-3 border-t border-border">
<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(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>
${canRemove ? `
<button class="text-xs text-destructive hover:text-destructive/80 transition-colors"
data-server-name="${escapeHtml(serverName)}"
data-server-name="${escapeHtml(name)}"
data-action="remove">
${t('mcp.removeFromProject')}
</button>
` : ''}
</div>
</div>
`;
}
// Render card for Global Management (manage global servers)
function renderGlobalManagementCard(serverName, serverConfig) {
const command = serverConfig.command || serverConfig.url || 'N/A';
const args = serverConfig.args || [];
const hasEnv = serverConfig.env && Object.keys(serverConfig.env).length > 0;
const serverType = serverConfig.type || 'stdio';
return `
<div class="mcp-server-card mcp-server-global bg-card border border-success/30 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="globe" class="w-5 h-5 text-success"></i>
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
</div>
` : ''}
</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">${serverType === 'stdio' ? 'cmd' : '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="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>
</div>
` : ''}
<div class="flex items-center gap-2 text-muted-foreground mt-1">
<span class="text-xs italic">${t('mcp.availableToAll')}</span>
</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>
<button class="text-xs text-destructive hover:text-destructive/80 transition-colors"
data-server-name="${escapeHtml(serverName)}"
data-action="remove-global">
${t('mcp.removeGlobal')}
</button>
</div>
</div>
`;
}
@@ -373,13 +509,26 @@ function renderAvailableServerCard(serverName, serverInfo) {
</span>
` : ''}
</div>
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
data-server-name="${escapeHtml(originalName)}"
data-server-key="${escapeHtml(serverName)}"
data-server-config='${JSON.stringify(serverConfig).replace(/'/g, "&#39;")}'
data-action="add">
${t('mcp.add')}
</button>
<div class="flex gap-2">
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
data-server-name="${escapeHtml(originalName)}"
data-server-key="${escapeHtml(serverName)}"
data-server-config='${JSON.stringify(serverConfig).replace(/'/g, "&#39;")}'
data-scope="project"
data-action="add-from-other"
title="${t('mcp.installToProject')}">
<i data-lucide="folder-plus" class="w-3.5 h-3.5 inline"></i>
</button>
<button class="px-3 py-1 text-xs bg-success text-success-foreground rounded hover:opacity-90 transition-opacity"
data-server-name="${escapeHtml(originalName)}"
data-server-key="${escapeHtml(serverName)}"
data-server-config='${JSON.stringify(serverConfig).replace(/'/g, "&#39;")}'
data-scope="global"
data-action="add-from-other"
title="${t('mcp.installToGlobal')}">
<i data-lucide="globe" class="w-3.5 h-3.5 inline"></i>
</button>
</div>
</div>
<div class="mcp-server-details text-sm space-y-1">
@@ -398,101 +547,21 @@ function renderAvailableServerCard(serverName, serverInfo) {
${sourceProjectName ? `<span class="text-xs text-muted-foreground/70">• from ${escapeHtml(sourceProjectName)}</span>` : ''}
</div>
</div>
</div>
`;
}
function renderGlobalServerCard(serverName, serverConfig, source = 'user') {
const command = serverConfig.command || serverConfig.url || 'N/A';
const args = serverConfig.args || [];
const hasEnv = serverConfig.env && Object.keys(serverConfig.env).length > 0;
const serverType = serverConfig.type || 'stdio';
return `
<div class="mcp-server-card mcp-server-global bg-card border border-primary/30 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="user" class="w-5 h-5"></i>
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
<span class="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full">User</span>
</div>
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
data-server-name="${escapeHtml(serverName)}"
data-server-config='${JSON.stringify(serverConfig).replace(/'/g, "&#39;")}'
data-action="add">
${t('mcp.addToProject')}
<div class="mt-3 pt-3 border-t border-border">
<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')}
</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">${serverType === 'stdio' ? 'cmd' : '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="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>
</div>
` : ''}
<div class="flex items-center gap-2 text-muted-foreground mt-1">
<span class="text-xs italic">Available to all projects from ~/.claude.json</span>
</div>
</div>
</div>
`;
}
function renderEnterpriseServerCard(serverName, serverConfig) {
const command = serverConfig.command || serverConfig.url || 'N/A';
const args = serverConfig.args || [];
const hasEnv = serverConfig.env && Object.keys(serverConfig.env).length > 0;
const serverType = serverConfig.type || 'stdio';
return `
<div class="mcp-server-card mcp-server-enterprise bg-card border border-warning/30 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="building-2" class="w-5 h-5"></i>
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
<span class="text-xs px-2 py-0.5 bg-warning/20 text-warning rounded-full">Enterprise</span>
<i data-lucide="lock" class="w-3 h-3 text-muted-foreground"></i>
</div>
<span class="px-3 py-1 text-xs bg-muted text-muted-foreground rounded cursor-not-allowed">
Read-only
</span>
</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">${serverType === 'stdio' ? 'cmd' : '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="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>
</div>
` : ''}
<div class="flex items-center gap-2 text-muted-foreground mt-1">
<span class="text-xs italic">Managed by organization (highest priority)</span>
</div>
</div>
</div>
`;
}
function attachMcpEventListeners() {
// Toggle switches
@@ -504,16 +573,22 @@ function attachMcpEventListeners() {
});
});
// Add buttons - use btn.dataset instead of e.target.dataset for event bubbling safety
document.querySelectorAll('.mcp-server-card button[data-action="add"]').forEach(btn => {
// Add from other projects (with scope selection)
document.querySelectorAll('.mcp-server-card button[data-action="add-from-other"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const serverName = btn.dataset.serverName;
const serverConfig = JSON.parse(btn.dataset.serverConfig);
await copyMcpServerToProject(serverName, serverConfig);
const scope = btn.dataset.scope; // 'project' or 'global'
if (scope === 'global') {
await addGlobalMcpServer(serverName, serverConfig);
} else {
await copyMcpServerToProject(serverName, serverConfig);
}
});
});
// Remove buttons - use btn.dataset instead of e.target.dataset for event bubbling safety
// Remove buttons (project-level)
document.querySelectorAll('.mcp-server-card button[data-action="remove"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const serverName = btn.dataset.serverName;
@@ -522,4 +597,24 @@ function attachMcpEventListeners() {
}
});
});
// Remove buttons (global-level)
document.querySelectorAll('.mcp-server-card button[data-action="remove-global"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const serverName = btn.dataset.serverName;
if (confirm(t('mcp.removeGlobalConfirm', { name: serverName }))) {
await removeGlobalMcpServer(serverName);
}
});
});
// Copy install command buttons
document.querySelectorAll('.mcp-server-card button[data-action="copy-install-cmd"]').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);
});
});
}

View File

@@ -335,9 +335,482 @@ function editRule(ruleName, location) {
}
}
// ========== Create Rule Modal ==========
var ruleCreateState = {
location: 'project',
fileName: '',
subdirectory: '',
isConditional: false,
paths: [''],
content: '',
mode: 'input',
generationType: 'description',
description: '',
extractScope: '',
extractFocus: ''
};
function openRuleCreateModal() {
// Open create modal (to be implemented with modal)
if (window.showToast) {
showToast(t('rules.createNotImplemented'), 'info');
// Reset state
ruleCreateState = {
location: 'project',
fileName: '',
subdirectory: '',
isConditional: false,
paths: [''],
content: '',
mode: 'input',
generationType: 'description',
description: '',
extractScope: '',
extractFocus: ''
};
// Create modal HTML
const modalHtml = `
<div class="modal-overlay fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onclick="closeRuleCreateModal(event)">
<div class="modal-dialog bg-card rounded-lg shadow-lg w-full max-w-2xl max-h-[90vh] mx-4 flex flex-col" onclick="event.stopPropagation()">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-border">
<h3 class="text-lg font-semibold text-foreground">${t('rules.createRule')}</h3>
<button class="w-8 h-8 flex items-center justify-center text-xl text-muted-foreground hover:text-foreground hover:bg-hover rounded"
onclick="closeRuleCreateModal()">&times;</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto p-6 space-y-5">
<!-- Location Selection -->
<div>
<label class="block text-sm font-medium text-foreground mb-2">${t('rules.location')}</label>
<div class="grid grid-cols-2 gap-3">
<button class="location-btn px-4 py-3 text-left border-2 rounded-lg transition-all ${ruleCreateState.location === 'project' ? 'border-primary bg-primary/10' : 'border-border hover:border-primary/50'}"
onclick="selectRuleLocation('project')">
<div class="flex items-center gap-2">
<i data-lucide="folder" class="w-5 h-5"></i>
<div>
<div class="font-medium">${t('rules.projectRules')}</div>
<div class="text-xs text-muted-foreground">.claude/rules/</div>
</div>
</div>
</button>
<button class="location-btn px-4 py-3 text-left border-2 rounded-lg transition-all ${ruleCreateState.location === 'user' ? 'border-primary bg-primary/10' : 'border-border hover:border-primary/50'}"
onclick="selectRuleLocation('user')">
<div class="flex items-center gap-2">
<i data-lucide="user" class="w-5 h-5"></i>
<div>
<div class="font-medium">${t('rules.userRules')}</div>
<div class="text-xs text-muted-foreground">~/.claude/rules/</div>
</div>
</div>
</button>
</div>
</div>
<!-- Mode Selection -->
<div>
<label class="block text-sm font-medium text-foreground mb-2">${t('rules.createMode')}</label>
<div class="grid grid-cols-2 gap-3">
<button class="mode-btn px-4 py-3 text-left border-2 rounded-lg transition-all ${ruleCreateState.mode === 'input' ? 'border-primary bg-primary/10' : 'border-border hover:border-primary/50'}"
onclick="switchRuleCreateMode('input')">
<div class="flex items-center gap-2">
<i data-lucide="edit" class="w-5 h-5"></i>
<div>
<div class="font-medium">${t('rules.manualInput')}</div>
<div class="text-xs text-muted-foreground">${t('rules.manualInputHint')}</div>
</div>
</div>
</button>
<button class="mode-btn px-4 py-3 text-left border-2 rounded-lg transition-all ${ruleCreateState.mode === 'cli-generate' ? 'border-primary bg-primary/10' : 'border-border hover:border-primary/50'}"
onclick="switchRuleCreateMode('cli-generate')">
<div class="flex items-center gap-2">
<i data-lucide="sparkles" class="w-5 h-5"></i>
<div>
<div class="font-medium">${t('rules.cliGenerate')}</div>
<div class="text-xs text-muted-foreground">${t('rules.cliGenerateHint')}</div>
</div>
</div>
</button>
</div>
</div>
<!-- File Name -->
<div>
<label class="block text-sm font-medium text-foreground mb-2">${t('rules.fileName')}</label>
<input type="text" id="ruleFileName"
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="my-rule.md"
value="${ruleCreateState.fileName}">
<p class="text-xs text-muted-foreground mt-1">${t('rules.fileNameHint')}</p>
</div>
<!-- Subdirectory -->
<div>
<label class="block text-sm font-medium text-foreground mb-2">${t('rules.subdirectory')} <span class="text-muted-foreground">${t('common.optional')}</span></label>
<input type="text" id="ruleSubdirectory"
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="category/subcategory"
value="${ruleCreateState.subdirectory}">
<p class="text-xs text-muted-foreground mt-1">${t('rules.subdirectoryHint')}</p>
</div>
<!-- CLI Generation Type (CLI mode only) -->
<div id="ruleGenerationTypeSection" style="display: ${ruleCreateState.mode === 'cli-generate' ? 'block' : 'none'}">
<label class="block text-sm font-medium text-foreground mb-2">${t('rules.generationType')}</label>
<div class="flex gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="ruleGenType" value="description"
class="w-4 h-4 text-primary bg-background border-border focus:ring-2 focus:ring-primary"
${ruleCreateState.generationType === 'description' ? 'checked' : ''}
onchange="switchRuleGenerationType('description')">
<span class="text-sm">${t('rules.fromDescription')}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="ruleGenType" value="extract"
class="w-4 h-4 text-primary bg-background border-border focus:ring-2 focus:ring-primary"
${ruleCreateState.generationType === 'extract' ? 'checked' : ''}
onchange="switchRuleGenerationType('extract')">
<span class="text-sm">${t('rules.fromCodeExtract')}</span>
</label>
</div>
</div>
<!-- Description Input (CLI mode, description type) -->
<div id="ruleDescriptionSection" style="display: ${ruleCreateState.mode === 'cli-generate' && ruleCreateState.generationType === 'description' ? 'block' : 'none'}">
<label class="block text-sm font-medium text-foreground mb-2">${t('rules.description')}</label>
<textarea id="ruleDescription"
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
rows="4"
placeholder="${t('rules.descriptionPlaceholder')}">${ruleCreateState.description}</textarea>
<p class="text-xs text-muted-foreground mt-1">${t('rules.descriptionHint')}</p>
</div>
<!-- Code Extract Options (CLI mode, extract type) -->
<div id="ruleExtractSection" style="display: ${ruleCreateState.mode === 'cli-generate' && ruleCreateState.generationType === 'extract' ? 'block' : 'none'}">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-foreground mb-2">${t('rules.extractScope')}</label>
<input type="text" id="ruleExtractScope"
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary font-mono"
placeholder="src/**/*.ts"
value="${ruleCreateState.extractScope}">
<p class="text-xs text-muted-foreground mt-1">${t('rules.extractScopeHint')}</p>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-2">${t('rules.extractFocus')}</label>
<input type="text" id="ruleExtractFocus"
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="naming, error-handling, state-management"
value="${ruleCreateState.extractFocus}">
<p class="text-xs text-muted-foreground mt-1">${t('rules.extractFocusHint')}</p>
</div>
</div>
</div>
<!-- Conditional Rule Toggle (Manual mode only) -->
<div id="ruleConditionalSection" style="display: ${ruleCreateState.mode === 'input' ? 'block' : 'none'}">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="ruleConditional"
class="w-4 h-4 text-primary bg-background border-border rounded focus:ring-2 focus:ring-primary"
${ruleCreateState.isConditional ? 'checked' : ''}
onchange="toggleRuleConditional()">
<span class="text-sm font-medium text-foreground">${t('rules.conditionalRule')}</span>
</label>
<p class="text-xs text-muted-foreground mt-1 ml-6">${t('rules.conditionalHint')}</p>
</div>
<!-- Path Conditions -->
<div id="rulePathsContainer" style="display: ${ruleCreateState.isConditional ? 'block' : 'none'}">
<label class="block text-sm font-medium text-foreground mb-2">${t('rules.pathConditions')}</label>
<div id="rulePathsList" class="space-y-2">
${ruleCreateState.paths.map((path, index) => `
<div class="flex gap-2">
<input type="text" class="rule-path-input flex-1 px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="src/**/*.ts"
value="${path}"
data-index="${index}">
${index > 0 ? `
<button class="px-3 py-2 text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
onclick="removeRulePath(${index})">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
` : ''}
</div>
`).join('')}
</div>
<button class="mt-2 px-3 py-1.5 text-sm text-primary hover:bg-primary/10 rounded-lg transition-colors flex items-center gap-1"
onclick="addRulePath()">
<i data-lucide="plus" class="w-4 h-4"></i>
${t('rules.addPath')}
</button>
</div>
<!-- Content (Manual mode only) -->
<div id="ruleContentSection" style="display: ${ruleCreateState.mode === 'input' ? 'block' : 'none'}">
<label class="block text-sm font-medium text-foreground mb-2">${t('rules.content')}</label>
<textarea id="ruleContent"
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary font-mono"
rows="10"
placeholder="${t('rules.contentPlaceholder')}">${ruleCreateState.content}</textarea>
<p class="text-xs text-muted-foreground mt-1">${t('rules.contentHint')}</p>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-border">
<button class="px-4 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
onclick="closeRuleCreateModal()">
${t('common.cancel')}
</button>
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
onclick="createRule()">
${t('rules.create')}
</button>
</div>
</div>
</div>
`;
// Add to DOM
const modalContainer = document.createElement('div');
modalContainer.id = 'ruleCreateModal';
modalContainer.innerHTML = modalHtml;
document.body.appendChild(modalContainer);
// Initialize Lucide icons
if (typeof lucide !== 'undefined') lucide.createIcons();
}
function closeRuleCreateModal(event) {
if (event && event.target !== event.currentTarget) return;
const modal = document.getElementById('ruleCreateModal');
if (modal) modal.remove();
}
function selectRuleLocation(location) {
ruleCreateState.location = location;
// Re-render modal
closeRuleCreateModal();
openRuleCreateModal();
}
function toggleRuleConditional() {
ruleCreateState.isConditional = !ruleCreateState.isConditional;
const pathsContainer = document.getElementById('rulePathsContainer');
if (pathsContainer) {
pathsContainer.style.display = ruleCreateState.isConditional ? 'block' : 'none';
}
}
function addRulePath() {
ruleCreateState.paths.push('');
// Re-render paths list
const pathsList = document.getElementById('rulePathsList');
if (pathsList) {
const index = ruleCreateState.paths.length - 1;
const pathHtml = `
<div class="flex gap-2">
<input type="text" class="rule-path-input flex-1 px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="src/**/*.ts"
value=""
data-index="${index}">
<button class="px-3 py-2 text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
onclick="removeRulePath(${index})">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
`;
pathsList.insertAdjacentHTML('beforeend', pathHtml);
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
function removeRulePath(index) {
ruleCreateState.paths.splice(index, 1);
// Re-render paths list
closeRuleCreateModal();
openRuleCreateModal();
}
function switchRuleCreateMode(mode) {
ruleCreateState.mode = mode;
// Toggle visibility of sections
const generationTypeSection = document.getElementById('ruleGenerationTypeSection');
const descriptionSection = document.getElementById('ruleDescriptionSection');
const extractSection = document.getElementById('ruleExtractSection');
const conditionalSection = document.getElementById('ruleConditionalSection');
const contentSection = document.getElementById('ruleContentSection');
if (mode === 'cli-generate') {
if (generationTypeSection) generationTypeSection.style.display = 'block';
if (conditionalSection) conditionalSection.style.display = 'none';
if (contentSection) contentSection.style.display = 'none';
// Show appropriate generation section
if (ruleCreateState.generationType === 'description') {
if (descriptionSection) descriptionSection.style.display = 'block';
if (extractSection) extractSection.style.display = 'none';
} else {
if (descriptionSection) descriptionSection.style.display = 'none';
if (extractSection) extractSection.style.display = 'block';
}
} else {
if (generationTypeSection) generationTypeSection.style.display = 'none';
if (descriptionSection) descriptionSection.style.display = 'none';
if (extractSection) extractSection.style.display = 'none';
if (conditionalSection) conditionalSection.style.display = 'block';
if (contentSection) contentSection.style.display = 'block';
}
// Re-render modal to update button states
closeRuleCreateModal();
openRuleCreateModal();
}
function switchRuleGenerationType(type) {
ruleCreateState.generationType = type;
// Toggle visibility of generation sections
const descriptionSection = document.getElementById('ruleDescriptionSection');
const extractSection = document.getElementById('ruleExtractSection');
if (type === 'description') {
if (descriptionSection) descriptionSection.style.display = 'block';
if (extractSection) extractSection.style.display = 'none';
} else if (type === 'extract') {
if (descriptionSection) descriptionSection.style.display = 'none';
if (extractSection) extractSection.style.display = 'block';
}
}
async function createRule() {
const fileNameInput = document.getElementById('ruleFileName');
const subdirectoryInput = document.getElementById('ruleSubdirectory');
const contentInput = document.getElementById('ruleContent');
const pathInputs = document.querySelectorAll('.rule-path-input');
const descriptionInput = document.getElementById('ruleDescription');
const extractScopeInput = document.getElementById('ruleExtractScope');
const extractFocusInput = document.getElementById('ruleExtractFocus');
const fileName = fileNameInput ? fileNameInput.value.trim() : ruleCreateState.fileName;
const subdirectory = subdirectoryInput ? subdirectoryInput.value.trim() : ruleCreateState.subdirectory;
// Validate file name
if (!fileName) {
if (window.showToast) {
showToast(t('rules.fileNameRequired'), 'error');
}
return;
}
if (!fileName.endsWith('.md')) {
if (window.showToast) {
showToast(t('rules.fileNameMustEndMd'), 'error');
}
return;
}
// Prepare request based on mode
let requestBody;
if (ruleCreateState.mode === 'cli-generate') {
// CLI generation mode
const description = descriptionInput ? descriptionInput.value.trim() : ruleCreateState.description;
const extractScope = extractScopeInput ? extractScopeInput.value.trim() : ruleCreateState.extractScope;
const extractFocus = extractFocusInput ? extractFocusInput.value.trim() : ruleCreateState.extractFocus;
// Validate based on generation type
if (ruleCreateState.generationType === 'description' && !description) {
if (window.showToast) {
showToast(t('rules.descriptionRequired'), 'error');
}
return;
}
if (ruleCreateState.generationType === 'extract' && !extractScope) {
if (window.showToast) {
showToast(t('rules.extractScopeRequired'), 'error');
}
return;
}
requestBody = {
mode: 'cli-generate',
fileName,
location: ruleCreateState.location,
subdirectory: subdirectory || undefined,
projectPath,
generationType: ruleCreateState.generationType,
description: ruleCreateState.generationType === 'description' ? description : undefined,
extractScope: ruleCreateState.generationType === 'extract' ? extractScope : undefined,
extractFocus: ruleCreateState.generationType === 'extract' ? extractFocus : undefined
};
// Show progress message
if (window.showToast) {
showToast(t('rules.cliGenerating'), 'info');
}
} else {
// Manual input mode
const content = contentInput ? contentInput.value.trim() : ruleCreateState.content;
// Collect paths from inputs
const paths = [];
if (ruleCreateState.isConditional && pathInputs) {
pathInputs.forEach(input => {
const path = input.value.trim();
if (path) paths.push(path);
});
}
// Validate content
if (!content) {
if (window.showToast) {
showToast(t('rules.contentRequired'), 'error');
}
return;
}
requestBody = {
mode: 'input',
fileName,
content,
paths: paths.length > 0 ? paths : undefined,
location: ruleCreateState.location,
subdirectory: subdirectory || undefined,
projectPath
};
}
try {
const response = await fetch('/api/rules/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to create rule');
}
const result = await response.json();
// Close modal
closeRuleCreateModal();
// Reload rules data
await loadRulesData();
renderRulesView();
// Show success message
if (window.showToast) {
showToast(t('rules.created', { name: result.fileName }), 'success');
}
} catch (err) {
console.error('Failed to create rule:', err);
if (window.showToast) {
showToast(err.message || t('rules.createError'), 'error');
}
}
}

View File

@@ -337,9 +337,471 @@ function editSkill(skillName, location) {
}
}
// ========== Create Skill Modal ==========
var skillCreateState = {
mode: 'import', // 'import' or 'cli-generate'
location: 'project',
sourcePath: '',
customName: '',
validationResult: null,
// CLI Generate mode fields
generationType: 'description', // 'description' or 'template'
description: '',
skillName: ''
};
function openSkillCreateModal() {
// Open create modal (to be implemented with modal)
if (window.showToast) {
showToast(t('skills.createNotImplemented'), 'info');
// Reset state
skillCreateState = {
mode: 'import',
location: 'project',
sourcePath: '',
customName: '',
validationResult: null,
generationType: 'description',
description: '',
skillName: ''
};
// Create modal HTML
const modalHtml = `
<div class="modal-overlay fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onclick="closeSkillCreateModal(event)">
<div class="modal-dialog bg-card rounded-lg shadow-lg w-full max-w-2xl mx-4" onclick="event.stopPropagation()">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-border">
<h3 class="text-lg font-semibold text-foreground">${t('skills.createSkill')}</h3>
<button class="w-8 h-8 flex items-center justify-center text-xl text-muted-foreground hover:text-foreground hover:bg-hover rounded"
onclick="closeSkillCreateModal()">&times;</button>
</div>
<!-- Body -->
<div class="p-6 space-y-5">
<!-- Location Selection -->
<div>
<label class="block text-sm font-medium text-foreground mb-2">${t('skills.location')}</label>
<div class="grid grid-cols-2 gap-3">
<button class="location-btn px-4 py-3 text-left border-2 rounded-lg transition-all ${skillCreateState.location === 'project' ? 'border-primary bg-primary/10' : 'border-border hover:border-primary/50'}"
onclick="selectSkillLocation('project')">
<div class="flex items-center gap-2">
<i data-lucide="folder" class="w-5 h-5"></i>
<div>
<div class="font-medium">${t('skills.projectSkills')}</div>
<div class="text-xs text-muted-foreground">.claude/skills/</div>
</div>
</div>
</button>
<button class="location-btn px-4 py-3 text-left border-2 rounded-lg transition-all ${skillCreateState.location === 'user' ? 'border-primary bg-primary/10' : 'border-border hover:border-primary/50'}"
onclick="selectSkillLocation('user')">
<div class="flex items-center gap-2">
<i data-lucide="user" class="w-5 h-5"></i>
<div>
<div class="font-medium">${t('skills.userSkills')}</div>
<div class="text-xs text-muted-foreground">~/.claude/skills/</div>
</div>
</div>
</button>
</div>
</div>
<!-- Mode Selection -->
<div>
<label class="block text-sm font-medium text-foreground mb-2">${t('skills.createMode')}</label>
<div class="grid grid-cols-2 gap-3">
<button class="mode-btn px-4 py-3 text-left border-2 rounded-lg transition-all ${skillCreateState.mode === 'import' ? 'border-primary bg-primary/10' : 'border-border hover:border-primary/50'}"
onclick="switchSkillCreateMode('import')">
<div class="flex items-center gap-2">
<i data-lucide="folder-input" class="w-5 h-5"></i>
<div>
<div class="font-medium">${t('skills.importFolder')}</div>
<div class="text-xs text-muted-foreground">${t('skills.importFolderHint')}</div>
</div>
</div>
</button>
<button class="mode-btn px-4 py-3 text-left border-2 rounded-lg transition-all ${skillCreateState.mode === 'cli-generate' ? 'border-primary bg-primary/10' : 'border-border hover:border-primary/50'}"
onclick="switchSkillCreateMode('cli-generate')">
<div class="flex items-center gap-2">
<i data-lucide="sparkles" class="w-5 h-5"></i>
<div>
<div class="font-medium">${t('skills.cliGenerate')}</div>
<div class="text-xs text-muted-foreground">${t('skills.cliGenerateHint')}</div>
</div>
</div>
</button>
</div>
</div>
<!-- Import Mode Content -->
<div id="skillImportMode" style="display: ${skillCreateState.mode === 'import' ? 'block' : 'none'}">
<!-- Source Folder Path -->
<div>
<label class="block text-sm font-medium text-foreground mb-2">${t('skills.sourceFolder')}</label>
<div class="flex gap-2">
<input type="text" id="skillSourcePath"
class="flex-1 px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="${t('skills.sourceFolderPlaceholder')}"
value="${skillCreateState.sourcePath}">
<button class="px-4 py-2 bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors text-sm"
onclick="browseSkillFolder()">
<i data-lucide="folder-open" class="w-4 h-4"></i>
</button>
</div>
<p class="text-xs text-muted-foreground mt-1">${t('skills.sourceFolderHint')}</p>
</div>
<!-- Custom Name -->
<div>
<label class="block text-sm font-medium text-foreground mb-2">${t('skills.customName')} <span class="text-muted-foreground">${t('common.optional')}</span></label>
<input type="text" id="skillCustomName"
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="${t('skills.customNamePlaceholder')}"
value="${skillCreateState.customName}">
<p class="text-xs text-muted-foreground mt-1">${t('skills.customNameHint')}</p>
</div>
<!-- Validation Result -->
<div id="skillValidationResult"></div>
</div>
<!-- CLI Generate Mode Content -->
<div id="skillCliGenerateMode" style="display: ${skillCreateState.mode === 'cli-generate' ? 'block' : 'none'}">
<!-- Skill Name (Required for CLI Generate) -->
<div>
<label class="block text-sm font-medium text-foreground mb-2">${t('skills.skillName')} <span class="text-destructive">*</span></label>
<input type="text" id="skillGenerateName"
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="${t('skills.skillNamePlaceholder')}"
value="${skillCreateState.skillName}">
<p class="text-xs text-muted-foreground mt-1">${t('skills.skillNameHint')}</p>
</div>
<!-- Generation Type Selection -->
<div>
<label class="block text-sm font-medium text-foreground mb-2">${t('skills.generationType')}</label>
<div class="flex gap-3">
<button class="flex-1 px-4 py-3 text-left border-2 rounded-lg transition-all ${skillCreateState.generationType === 'description' ? 'border-primary bg-primary/10' : 'border-border hover:border-primary/50'}"
onclick="switchSkillGenerationType('description')">
<div class="flex items-center gap-2">
<i data-lucide="file-text" class="w-5 h-5"></i>
<div>
<div class="font-medium text-sm">${t('skills.fromDescription')}</div>
<div class="text-xs text-muted-foreground">${t('skills.fromDescriptionHint')}</div>
</div>
</div>
</button>
<button class="flex-1 px-4 py-3 text-left border-2 rounded-lg transition-all opacity-50 cursor-not-allowed"
disabled>
<div class="flex items-center gap-2">
<i data-lucide="layout-template" class="w-5 h-5"></i>
<div>
<div class="font-medium text-sm">${t('skills.fromTemplate')}</div>
<div class="text-xs text-muted-foreground">${t('skills.comingSoon')}</div>
</div>
</div>
</button>
</div>
</div>
<!-- Description Text Area (for 'description' type) -->
<div id="skillDescriptionArea" style="display: ${skillCreateState.generationType === 'description' ? 'block' : 'none'}">
<label class="block text-sm font-medium text-foreground mb-2">${t('skills.descriptionLabel')} <span class="text-destructive">*</span></label>
<textarea id="skillDescription"
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="${t('skills.descriptionPlaceholder')}"
rows="6">${skillCreateState.description}</textarea>
<p class="text-xs text-muted-foreground mt-1">${t('skills.descriptionGenerateHint')}</p>
</div>
<!-- CLI Generate Info -->
<div class="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<div class="flex items-start gap-2">
<i data-lucide="info" class="w-4 h-4 text-blue-600 mt-0.5"></i>
<div class="text-sm text-blue-600">
<p class="font-medium">${t('skills.cliGenerateInfo')}</p>
<p class="text-xs mt-1">${t('skills.cliGenerateTimeHint')}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-border">
<button class="px-4 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
onclick="closeSkillCreateModal()">
${t('common.cancel')}
</button>
${skillCreateState.mode === 'import' ? `
<button class="px-4 py-2 text-sm bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors"
onclick="validateSkillImport()">
${t('skills.validate')}
</button>
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
onclick="createSkill()">
${t('skills.import')}
</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="createSkill()">
<i data-lucide="sparkles" class="w-4 h-4"></i>
${t('skills.generate')}
</button>
`}
</div>
</div>
</div>
`;
// Add to DOM
const modalContainer = document.createElement('div');
modalContainer.id = 'skillCreateModal';
modalContainer.innerHTML = modalHtml;
document.body.appendChild(modalContainer);
// Initialize Lucide icons
if (typeof lucide !== 'undefined') lucide.createIcons();
}
function closeSkillCreateModal(event) {
if (event && event.target !== event.currentTarget) return;
const modal = document.getElementById('skillCreateModal');
if (modal) modal.remove();
}
function selectSkillLocation(location) {
skillCreateState.location = location;
// Re-render modal
closeSkillCreateModal();
openSkillCreateModal();
}
function switchSkillCreateMode(mode) {
skillCreateState.mode = mode;
// Re-render modal
closeSkillCreateModal();
openSkillCreateModal();
}
function switchSkillGenerationType(type) {
skillCreateState.generationType = type;
// Re-render modal
closeSkillCreateModal();
openSkillCreateModal();
}
function browseSkillFolder() {
// Use browser prompt for now (Phase 3 will implement file browser)
const path = prompt(t('skills.enterFolderPath'), skillCreateState.sourcePath);
if (path !== null) {
skillCreateState.sourcePath = path;
document.getElementById('skillSourcePath').value = path;
}
}
async function validateSkillImport() {
const sourcePathInput = document.getElementById('skillSourcePath');
const sourcePath = sourcePathInput ? sourcePathInput.value.trim() : skillCreateState.sourcePath;
if (!sourcePath) {
showValidationResult({ valid: false, errors: [t('skills.sourceFolderRequired')], skillInfo: null });
return;
}
skillCreateState.sourcePath = sourcePath;
// Show loading state
showValidationResult({ loading: true });
try {
const response = await fetch('/api/skills/validate-import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourcePath })
});
if (!response.ok) throw new Error('Validation request failed');
const result = await response.json();
skillCreateState.validationResult = result;
showValidationResult(result);
} catch (err) {
console.error('Failed to validate skill:', err);
showValidationResult({ valid: false, errors: [t('skills.validationError')], skillInfo: null });
}
}
function showValidationResult(result) {
const container = document.getElementById('skillValidationResult');
if (!container) return;
if (result.loading) {
container.innerHTML = `
<div class="flex items-center gap-2 p-3 bg-muted/50 rounded-lg">
<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i>
<span class="text-sm text-muted-foreground">${t('skills.validating')}</span>
</div>
`;
if (typeof lucide !== 'undefined') lucide.createIcons();
return;
}
if (result.valid) {
container.innerHTML = `
<div class="p-4 bg-green-500/10 border border-green-500/20 rounded-lg">
<div class="flex items-center gap-2 text-green-600 mb-2">
<i data-lucide="check-circle" class="w-5 h-5"></i>
<span class="font-medium">${t('skills.validSkill')}</span>
</div>
<div class="space-y-1 text-sm">
<div><span class="text-muted-foreground">${t('skills.name')}:</span> <span class="font-medium">${escapeHtml(result.skillInfo.name)}</span></div>
<div><span class="text-muted-foreground">${t('skills.description')}:</span> <span>${escapeHtml(result.skillInfo.description)}</span></div>
${result.skillInfo.version ? `<div><span class="text-muted-foreground">${t('skills.version')}:</span> <span>${escapeHtml(result.skillInfo.version)}</span></div>` : ''}
${result.skillInfo.supportingFiles && result.skillInfo.supportingFiles.length > 0 ? `<div><span class="text-muted-foreground">${t('skills.supportingFiles')}:</span> <span>${result.skillInfo.supportingFiles.length} ${t('skills.files')}</span></div>` : ''}
</div>
</div>
`;
} else {
container.innerHTML = `
<div class="p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
<div class="flex items-center gap-2 text-destructive mb-2">
<i data-lucide="x-circle" class="w-5 h-5"></i>
<span class="font-medium">${t('skills.invalidSkill')}</span>
</div>
<ul class="space-y-1 text-sm">
${result.errors.map(error => `<li class="text-destructive">• ${escapeHtml(error)}</li>`).join('')}
</ul>
</div>
`;
}
if (typeof lucide !== 'undefined') lucide.createIcons();
}
async function createSkill() {
if (skillCreateState.mode === 'import') {
// Import Mode Logic
const sourcePathInput = document.getElementById('skillSourcePath');
const customNameInput = document.getElementById('skillCustomName');
const sourcePath = sourcePathInput ? sourcePathInput.value.trim() : skillCreateState.sourcePath;
const customName = customNameInput ? customNameInput.value.trim() : skillCreateState.customName;
if (!sourcePath) {
if (window.showToast) {
showToast(t('skills.sourceFolderRequired'), 'error');
}
return;
}
// Validate first if not already validated
if (!skillCreateState.validationResult || !skillCreateState.validationResult.valid) {
if (window.showToast) {
showToast(t('skills.validateFirst'), 'error');
}
return;
}
try {
const response = await fetch('/api/skills/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: 'import',
location: skillCreateState.location,
sourcePath,
skillName: customName || undefined,
projectPath
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to create skill');
}
const result = await response.json();
// Close modal
closeSkillCreateModal();
// Reload skills data
await loadSkillsData();
renderSkillsView();
// Show success message
if (window.showToast) {
showToast(t('skills.created', { name: result.skillName }), 'success');
}
} catch (err) {
console.error('Failed to create skill:', err);
if (window.showToast) {
showToast(err.message || t('skills.createError'), 'error');
}
}
} else if (skillCreateState.mode === 'cli-generate') {
// CLI Generate Mode Logic
const skillNameInput = document.getElementById('skillGenerateName');
const descriptionInput = document.getElementById('skillDescription');
const skillName = skillNameInput ? skillNameInput.value.trim() : skillCreateState.skillName;
const description = descriptionInput ? descriptionInput.value.trim() : skillCreateState.description;
// Validation
if (!skillName) {
if (window.showToast) {
showToast(t('skills.skillNameRequired'), 'error');
}
return;
}
if (skillCreateState.generationType === 'description' && !description) {
if (window.showToast) {
showToast(t('skills.descriptionRequired'), 'error');
}
return;
}
try {
// Show generating progress toast
if (window.showToast) {
showToast(t('skills.generating'), 'info');
}
const response = await fetch('/api/skills/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: 'cli-generate',
location: skillCreateState.location,
generationType: skillCreateState.generationType,
skillName,
description: skillCreateState.generationType === 'description' ? description : undefined,
projectPath
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to generate skill');
}
const result = await response.json();
// Close modal
closeSkillCreateModal();
// Reload skills data
await loadSkillsData();
renderSkillsView();
// Show success message
if (window.showToast) {
showToast(t('skills.generated', { name: result.skillName }), 'success');
}
} catch (err) {
console.error('Failed to generate skill:', err);
if (window.showToast) {
showToast(err.message || t('skills.generateError'), 'error');
}
}
}
}

View File

@@ -434,6 +434,11 @@
<span class="nav-text flex-1" data-i18n="nav.rules">Rules</span>
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeRules">0</span>
</li>
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="claude-manager" data-tooltip="CLAUDE.md Manager">
<i data-lucide="file-code" class="nav-icon"></i>
<span class="nav-text flex-1" data-i18n="nav.claudeManager">CLAUDE.md</span>
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeClaude">0</span>
</li>
</ul>
</div>
@@ -592,6 +597,14 @@
<input type="text" id="mcpServerName" data-i18n-placeholder="mcp.serverNamePlaceholder" placeholder="e.g., my-mcp-server"
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1"><span data-i18n="mcp.scope">Scope</span> <span class="text-destructive">*</span></label>
<select id="mcpServerScope" class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
<option value="project" data-i18n="mcp.scopeProject">Project - Only this project</option>
<option value="global" data-i18n="mcp.scopeGlobal">Global - All projects (~/. claude.json)</option>
</select>
<p class="text-xs text-muted-foreground mt-1">Choose where to save this MCP server configuration</p>
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1"><span data-i18n="mcp.command">Command</span> <span class="text-destructive">*</span></label>
<input type="text" id="mcpServerCommand" data-i18n-placeholder="mcp.commandPlaceholder" placeholder="e.g., npx, uvx, node, python"

View File

@@ -834,8 +834,37 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
}
}
/**
* Uninstall CodexLens by removing the venv directory
* @returns Uninstall result
*/
async function uninstallCodexLens(): Promise<BootstrapResult> {
try {
// Check if venv exists
if (!existsSync(CODEXLENS_VENV)) {
return { success: false, error: 'CodexLens not installed (venv not found)' };
}
console.log('[CodexLens] Uninstalling CodexLens...');
console.log(`[CodexLens] Removing directory: ${CODEXLENS_DATA_DIR}`);
// Remove the entire .codexlens directory
const fs = await import('fs');
fs.rmSync(CODEXLENS_DATA_DIR, { recursive: true, force: true });
// Reset bootstrap cache
bootstrapChecked = false;
bootstrapReady = false;
console.log('[CodexLens] CodexLens uninstalled successfully');
return { success: true, message: 'CodexLens uninstalled successfully' };
} catch (err) {
return { success: false, error: `Failed to uninstall CodexLens: ${(err as Error).message}` };
}
}
// Export for direct usage
export { ensureReady, executeCodexLens, checkVenvStatus, bootstrapVenv, checkSemanticStatus, installSemantic };
export { ensureReady, executeCodexLens, checkVenvStatus, bootstrapVenv, checkSemanticStatus, installSemantic, uninstallCodexLens };
// Backward-compatible export for tests
export const codexLensTool = {

View File

@@ -11,7 +11,10 @@ Requires-Dist: typer>=0.9
Requires-Dist: rich>=13
Requires-Dist: pydantic>=2.0
Requires-Dist: tree-sitter>=0.20
Requires-Dist: tree-sitter-python>=0.25
Requires-Dist: tree-sitter-javascript>=0.25
Requires-Dist: tree-sitter-typescript>=0.23
Requires-Dist: pathspec>=0.11
Provides-Extra: semantic
Requires-Dist: numpy>=1.24; extra == "semantic"
Requires-Dist: sentence-transformers>=2.2; extra == "semantic"
Requires-Dist: fastembed>=0.2; extra == "semantic"

View File

@@ -14,10 +14,37 @@ src/codexlens/cli/commands.py
src/codexlens/cli/output.py
src/codexlens/parsers/__init__.py
src/codexlens/parsers/factory.py
src/codexlens/search/__init__.py
src/codexlens/search/chain_search.py
src/codexlens/semantic/__init__.py
src/codexlens/semantic/chunker.py
src/codexlens/semantic/code_extractor.py
src/codexlens/semantic/embedder.py
src/codexlens/semantic/llm_enhancer.py
src/codexlens/semantic/vector_store.py
src/codexlens/storage/__init__.py
src/codexlens/storage/dir_index.py
src/codexlens/storage/file_cache.py
src/codexlens/storage/sqlite_store.py
src/codexlens/storage/index_tree.py
src/codexlens/storage/migration_manager.py
src/codexlens/storage/path_mapper.py
src/codexlens/storage/registry.py
src/codexlens/storage/sqlite_store.py
src/codexlens/storage/migrations/__init__.py
src/codexlens/storage/migrations/migration_001_normalize_keywords.py
tests/test_cli_output.py
tests/test_code_extractor.py
tests/test_config.py
tests/test_entities.py
tests/test_errors.py
tests/test_file_cache.py
tests/test_llm_enhancer.py
tests/test_parsers.py
tests/test_performance_optimizations.py
tests/test_search_comprehensive.py
tests/test_search_full_coverage.py
tests/test_search_performance.py
tests/test_semantic.py
tests/test_semantic_search.py
tests/test_storage.py
tests/test_vector_search_full.py

View File

@@ -2,8 +2,11 @@ typer>=0.9
rich>=13
pydantic>=2.0
tree-sitter>=0.20
tree-sitter-python>=0.25
tree-sitter-javascript>=0.25
tree-sitter-typescript>=0.23
pathspec>=0.11
[semantic]
numpy>=1.24
sentence-transformers>=2.2
fastembed>=0.2

View File

@@ -0,0 +1,138 @@
# Test: Rules CLI Generation Feature
## Overview
This document demonstrates the CLI generation feature for Rules Manager.
## API Usage
### Endpoint
```
POST /api/rules/create
```
### Mode: CLI Generation
Set `mode: 'cli-generate'` in the request body.
## Generation Types
### 1. From Description
Generate rule from natural language description:
```json
{
"mode": "cli-generate",
"generationType": "description",
"description": "Always use TypeScript strict mode and proper type annotations",
"fileName": "typescript-strict.md",
"location": "project",
"subdirectory": "",
"projectPath": "D:/Claude_dms3"
}
```
### 2. From Template
Generate rule from template type:
```json
{
"mode": "cli-generate",
"generationType": "template",
"templateType": "error-handling",
"fileName": "error-handling-rules.md",
"location": "project",
"subdirectory": "",
"projectPath": "D:/Claude_dms3"
}
```
### 3. From Code Extraction
Extract rules from existing codebase:
```json
{
"mode": "cli-generate",
"generationType": "extract",
"extractScope": "ccw/src/**/*.ts",
"extractFocus": "error handling, async patterns, type safety",
"fileName": "extracted-patterns.md",
"location": "project",
"subdirectory": "",
"projectPath": "D:/Claude_dms3"
}
```
## Response Format
### Success Response
```json
{
"success": true,
"fileName": "typescript-strict.md",
"location": "project",
"path": "D:/Claude_dms3/.claude/rules/typescript-strict.md",
"subdirectory": null,
"generatedContent": "# TypeScript Strict Mode\n\n...",
"executionId": "1734168000000-gemini"
}
```
### Error Response
```json
{
"error": "CLI execution failed: ...",
"stderr": "..."
}
```
## Implementation Details
### Function: `generateRuleViaCLI()`
**Parameters:**
- `generationType`: 'description' | 'template' | 'extract'
- `description`: Rule description (for 'description' mode)
- `templateType`: Template type (for 'template' mode)
- `extractScope`: File pattern like 'src/**/*.ts' (for 'extract' mode)
- `extractFocus`: Focus areas like 'error handling, naming' (for 'extract' mode)
- `fileName`: Target file name (must end with .md)
- `location`: 'project' or 'user'
- `subdirectory`: Optional subdirectory within rules folder
- `projectPath`: Project root path
**Process:**
1. Build CLI prompt based on generation type
2. Execute Gemini CLI with 10-minute timeout
3. Extract generated content from stdout
4. Create rule file using `createRule()`
5. Return result with execution ID
### Prompt Templates
#### Description Mode
```
PURPOSE: Generate Claude Code memory rule from description
MODE: write
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt)
```
#### Extract Mode
```
PURPOSE: Extract coding rules from codebase
MODE: analysis
CONTEXT: @{extractScope}
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/analysis/02-analyze-code-patterns.txt)
```
## Error Handling
- CLI execution timeout: 10 minutes (600000ms)
- Empty content validation
- File existence checking
- Generation type validation
## Integration
The feature integrates with:
- **cli-executor.ts**: For Gemini CLI execution
- **createRule()**: For file creation
- **Rules Manager UI**: For user interface (to be implemented)