From 91e4792aa942d709d8ee65c92cf43690209eb657 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Mon, 8 Dec 2025 21:10:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(ccw):=20=E6=B7=BB=E5=8A=A0=20ccw=20tool=20?= =?UTF-8?q?exec=20=E5=B7=A5=E5=85=B7=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增工具: - edit_file: AI辅助文件编辑 (update/line模式) - get_modules_by_depth: 项目结构分析 - update_module_claude: CLAUDE.md文档生成 - generate_module_docs: 模块文档生成 - detect_changed_modules: Git变更检测 - classify_folders: 文件夹分类 - discover_design_files: 设计文件发现 - convert_tokens_to_css: 设计token转CSS - ui_generate_preview: UI预览生成 - ui_instantiate_prototypes: 原型实例化 使用方式: ccw tool list # 列出所有工具 ccw tool exec '{}' # 执行工具 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ccw/src/cli.js | 7 + ccw/src/commands/tool.js | 181 +++++++++ ccw/src/core/lite-scanner.js | 46 ++- ccw/src/core/server.js | 127 ++++-- .../dashboard-js/components/mcp-manager.js | 12 +- .../dashboard-js/components/navigation.js | 12 +- .../dashboard-js/views/mcp-manager.js | 98 ++++- ccw/src/tools/classify-folders.js | 204 ++++++++++ ccw/src/tools/convert-tokens-to-css.js | 250 ++++++++++++ ccw/src/tools/detect-changed-modules.js | 288 ++++++++++++++ ccw/src/tools/discover-design-files.js | 134 +++++++ ccw/src/tools/edit-file.js | 238 +++++++++++ ccw/src/tools/generate-module-docs.js | 368 ++++++++++++++++++ ccw/src/tools/get-modules-by-depth.js | 308 +++++++++++++++ ccw/src/tools/index.js | 176 +++++++++ ccw/src/tools/ui-generate-preview.js | 327 ++++++++++++++++ ccw/src/tools/ui-instantiate-prototypes.js | 301 ++++++++++++++ ccw/src/tools/update-module-claude.js | 328 ++++++++++++++++ 18 files changed, 3332 insertions(+), 73 deletions(-) create mode 100644 ccw/src/commands/tool.js create mode 100644 ccw/src/tools/classify-folders.js create mode 100644 ccw/src/tools/convert-tokens-to-css.js create mode 100644 ccw/src/tools/detect-changed-modules.js create mode 100644 ccw/src/tools/discover-design-files.js create mode 100644 ccw/src/tools/edit-file.js create mode 100644 ccw/src/tools/generate-module-docs.js create mode 100644 ccw/src/tools/get-modules-by-depth.js create mode 100644 ccw/src/tools/index.js create mode 100644 ccw/src/tools/ui-generate-preview.js create mode 100644 ccw/src/tools/ui-instantiate-prototypes.js create mode 100644 ccw/src/tools/update-module-claude.js diff --git a/ccw/src/cli.js b/ccw/src/cli.js index 4f053275..530ea7c7 100644 --- a/ccw/src/cli.js +++ b/ccw/src/cli.js @@ -6,6 +6,7 @@ import { installCommand } from './commands/install.js'; import { uninstallCommand } from './commands/uninstall.js'; import { upgradeCommand } from './commands/upgrade.js'; import { listCommand } from './commands/list.js'; +import { toolCommand } from './commands/tool.js'; import { readFileSync, existsSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; @@ -105,5 +106,11 @@ export function run(argv) { .description('List all installed Claude Code Workflow instances') .action(listCommand); + // Tool command + program + .command('tool [subcommand] [args] [json]') + .description('Execute CCW tools') + .action((subcommand, args, json) => toolCommand(subcommand, args, { json })); + program.parse(argv); } diff --git a/ccw/src/commands/tool.js b/ccw/src/commands/tool.js new file mode 100644 index 00000000..fbcc36f7 --- /dev/null +++ b/ccw/src/commands/tool.js @@ -0,0 +1,181 @@ +/** + * Tool Command - Execute and manage CCW tools + */ + +import chalk from 'chalk'; +import { listTools, executeTool, getTool, getAllToolSchemas } from '../tools/index.js'; + +/** + * List all available tools + */ +async function listAction() { + const tools = listTools(); + + if (tools.length === 0) { + console.log(chalk.yellow('No tools registered')); + return; + } + + console.log(chalk.bold.cyan('\nAvailable Tools:\n')); + + for (const tool of tools) { + console.log(chalk.bold.white(` ${tool.name}`)); + console.log(chalk.gray(` ${tool.description}`)); + + if (tool.parameters?.properties) { + const props = tool.parameters.properties; + const required = tool.parameters.required || []; + + console.log(chalk.gray(' Parameters:')); + for (const [name, schema] of Object.entries(props)) { + const req = required.includes(name) ? chalk.red('*') : ''; + const defaultVal = schema.default !== undefined ? chalk.gray(` (default: ${schema.default})`) : ''; + console.log(chalk.gray(` - ${name}${req}: ${schema.description}${defaultVal}`)); + } + } + console.log(); + } +} + +/** + * Show tool schema in MCP-compatible JSON format + */ +async function schemaAction(options) { + const { name } = options; + + if (name) { + const tool = getTool(name); + if (!tool) { + console.error(chalk.red(`Tool not found: ${name}`)); + process.exit(1); + } + + const schema = { + name: tool.name, + description: tool.description, + inputSchema: { + type: 'object', + properties: tool.parameters?.properties || {}, + required: tool.parameters?.required || [] + } + }; + console.log(JSON.stringify(schema, null, 2)); + } else { + const schemas = getAllToolSchemas(); + console.log(JSON.stringify({ tools: schemas }, null, 2)); + } +} + +/** + * Read from stdin if available + */ +async function readStdin() { + // Check if stdin is a TTY (interactive terminal) + if (process.stdin.isTTY) { + return null; + } + + return new Promise((resolve, reject) => { + let data = ''; + + process.stdin.setEncoding('utf8'); + + process.stdin.on('readable', () => { + let chunk; + while ((chunk = process.stdin.read()) !== null) { + data += chunk; + } + }); + + process.stdin.on('end', () => { + resolve(data.trim() || null); + }); + + process.stdin.on('error', (err) => { + reject(err); + }); + }); +} + +/** + * Execute a tool with given parameters + */ +async function execAction(toolName, jsonInput, options) { + if (!toolName) { + console.error(chalk.red('Tool name is required')); + console.error(chalk.gray('Usage: ccw tool exec \'{"param": "value"}\'')); + process.exit(1); + } + + const tool = getTool(toolName); + if (!tool) { + console.error(chalk.red(`Tool not found: ${toolName}`)); + console.error(chalk.gray('Use "ccw tool list" to see available tools')); + process.exit(1); + } + + // Parse JSON input (default format) + let params = {}; + + if (jsonInput) { + try { + params = JSON.parse(jsonInput); + } catch (error) { + console.error(chalk.red(`Invalid JSON: ${error.message}`)); + process.exit(1); + } + } + + // Check for stdin input (for piped commands) + const stdinData = await readStdin(); + if (stdinData) { + // If tool has an 'input' parameter, use it + // Otherwise, try to parse stdin as JSON and merge with params + if (tool.parameters?.properties?.input) { + params.input = stdinData; + } else { + try { + const stdinJson = JSON.parse(stdinData); + params = { ...stdinJson, ...params }; + } catch { + // If not JSON, store as 'input' anyway + params.input = stdinData; + } + } + } + + // Execute tool + const result = await executeTool(toolName, params); + + // Always output JSON + console.log(JSON.stringify(result, null, 2)); +} + +/** + * Tool command entry point + */ +export async function toolCommand(subcommand, args, options) { + // Handle subcommands + switch (subcommand) { + case 'list': + await listAction(); + break; + case 'schema': + await schemaAction({ name: args }); + break; + case 'exec': + await execAction(args, options.json, options); + break; + default: + console.log(chalk.bold.cyan('\nCCW Tool System\n')); + console.log('Subcommands:'); + console.log(chalk.gray(' list List all available tools')); + console.log(chalk.gray(' schema [name] Show tool schema (JSON)')); + console.log(chalk.gray(' exec Execute a tool')); + console.log(); + console.log('Examples:'); + console.log(chalk.gray(' ccw tool list')); + console.log(chalk.gray(' ccw tool schema edit_file')); + console.log(chalk.gray(' ccw tool exec edit_file \'{"path":"file.txt","oldText":"old","newText":"new"}\'')); + } +} diff --git a/ccw/src/core/lite-scanner.js b/ccw/src/core/lite-scanner.js index ea32e539..6eeca0c8 100644 --- a/ccw/src/core/lite-scanner.js +++ b/ccw/src/core/lite-scanner.js @@ -54,20 +54,36 @@ function scanLiteDir(dir, type) { } /** - * Load plan.json from session directory + * Load plan.json or fix-plan.json from session directory * @param {string} sessionPath - Session directory path * @returns {Object|null} - Plan data or null */ function loadPlanJson(sessionPath) { + // Try fix-plan.json first (for lite-fix), then plan.json (for lite-plan) + const fixPlanPath = join(sessionPath, 'fix-plan.json'); const planPath = join(sessionPath, 'plan.json'); - if (!existsSync(planPath)) return null; - try { - const content = readFileSync(planPath, 'utf8'); - return JSON.parse(content); - } catch { - return null; + // Try fix-plan.json first + if (existsSync(fixPlanPath)) { + try { + const content = readFileSync(fixPlanPath, 'utf8'); + return JSON.parse(content); + } catch { + // Continue to try plan.json + } } + + // Fallback to plan.json + if (existsSync(planPath)) { + try { + const content = readFileSync(planPath, 'utf8'); + return JSON.parse(content); + } catch { + return null; + } + } + + return null; } /** @@ -91,6 +107,7 @@ function loadTaskJsons(sessionPath) { f.startsWith('IMPL-') || f.startsWith('TASK-') || f.startsWith('task-') || + f.startsWith('diagnosis-') || /^T\d+\.json$/i.test(f) )) .map(f => { @@ -109,12 +126,18 @@ function loadTaskJsons(sessionPath) { } } - // Method 2: Check plan.json for embedded tasks array + // Method 2: Check plan.json or fix-plan.json for embedded tasks array if (tasks.length === 0) { + // Try fix-plan.json first (for lite-fix), then plan.json (for lite-plan) + const fixPlanPath = join(sessionPath, 'fix-plan.json'); const planPath = join(sessionPath, 'plan.json'); - if (existsSync(planPath)) { + + const planFile = existsSync(fixPlanPath) ? fixPlanPath : + existsSync(planPath) ? planPath : null; + + if (planFile) { try { - const plan = JSON.parse(readFileSync(planPath, 'utf8')); + const plan = JSON.parse(readFileSync(planFile, 'utf8')); if (Array.isArray(plan.tasks)) { tasks = plan.tasks.map(t => normalizeTask(t)); } @@ -124,13 +147,14 @@ function loadTaskJsons(sessionPath) { } } - // Method 3: Check for task-*.json files in session root + // Method 3: Check for task-*.json and diagnosis-*.json files in session root if (tasks.length === 0) { try { const rootTasks = readdirSync(sessionPath) .filter(f => f.endsWith('.json') && ( f.startsWith('task-') || f.startsWith('TASK-') || + f.startsWith('diagnosis-') || /^T\d+\.json$/i.test(f) )) .map(f => { diff --git a/ccw/src/core/server.js b/ccw/src/core/server.js index 9bb39e7a..9545e5b0 100644 --- a/ccw/src/core/server.js +++ b/ccw/src/core/server.js @@ -14,6 +14,19 @@ const CLAUDE_SETTINGS_DIR = join(homedir(), '.claude'); const CLAUDE_GLOBAL_SETTINGS = join(CLAUDE_SETTINGS_DIR, 'settings.json'); const CLAUDE_GLOBAL_SETTINGS_LOCAL = join(CLAUDE_SETTINGS_DIR, 'settings.local.json'); +// Enterprise managed MCP paths (platform-specific) +function getEnterpriseMcpPath() { + 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'; + } +} + // WebSocket clients for real-time notifications const wsClients = new Set(); @@ -1042,67 +1055,99 @@ function safeReadJson(filePath) { } /** - * Get MCP servers from a settings file + * Get MCP servers from a JSON file (expects mcpServers key at top level) * @param {string} filePath * @returns {Object} mcpServers object or empty object */ -function getMcpServersFromSettings(filePath) { +function getMcpServersFromFile(filePath) { const config = safeReadJson(filePath); if (!config) return {}; return config.mcpServers || {}; } /** - * Get MCP configuration from multiple sources: - * 1. ~/.claude.json (project-level MCP servers) - * 2. ~/.claude/settings.json and settings.local.json (global MCP servers) - * 3. Each workspace's .claude/settings.json and settings.local.json + * 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: {}, globalServers: {} }; + 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 from ~/.claude.json (primary source for project MCP) - if (existsSync(CLAUDE_CONFIG_PATH)) { - const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); - const config = JSON.parse(content); - result.projects = config.projects || {}; - } - - // 2. Read global MCP servers from ~/.claude/settings.json and settings.local.json - const globalSettings = getMcpServersFromSettings(CLAUDE_GLOBAL_SETTINGS); - const globalSettingsLocal = getMcpServersFromSettings(CLAUDE_GLOBAL_SETTINGS_LOCAL); - result.globalServers = { ...globalSettings, ...globalSettingsLocal }; - - // 3. For each project, also check .claude/settings.json and settings.local.json - for (const projectPath of Object.keys(result.projects)) { - const projectClaudeDir = join(projectPath, '.claude'); - const projectSettings = join(projectClaudeDir, 'settings.json'); - const projectSettingsLocal = join(projectClaudeDir, 'settings.local.json'); - - // Merge MCP servers from workspace settings into project config - const workspaceServers = { - ...getMcpServersFromSettings(projectSettings), - ...getMcpServersFromSettings(projectSettingsLocal) - }; - - if (Object.keys(workspaceServers).length > 0) { - // Merge workspace servers with existing project servers (workspace takes precedence) - result.projects[projectPath] = { - ...result.projects[projectPath], - mcpServers: { - ...(result.projects[projectPath]?.mcpServers || {}), - ...workspaceServers - } - }; + // 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) { console.error('Error reading MCP config:', error); - return { projects: {}, globalServers: {}, error: error.message }; + return { projects: {}, globalServers: {}, userServers: {}, enterpriseServers: {}, configSources: [], error: error.message }; } } diff --git a/ccw/src/templates/dashboard-js/components/mcp-manager.js b/ccw/src/templates/dashboard-js/components/mcp-manager.js index cb046d6d..4084f873 100644 --- a/ccw/src/templates/dashboard-js/components/mcp-manager.js +++ b/ccw/src/templates/dashboard-js/components/mcp-manager.js @@ -1,11 +1,18 @@ // MCP Manager Component -// Manages MCP server configuration from .claude.json +// Manages MCP server configuration from multiple sources: +// - Enterprise: managed-mcp.json (highest priority) +// - User: ~/.claude.json mcpServers +// - Project: .mcp.json in project root +// - Local: ~/.claude.json projects[path].mcpServers // ========== MCP State ========== let mcpConfig = null; let mcpAllProjects = {}; let mcpGlobalServers = {}; +let mcpUserServers = {}; +let mcpEnterpriseServers = {}; let mcpCurrentProjectServers = {}; +let mcpConfigSources = []; let mcpCreateMode = 'form'; // 'form' or 'json' // ========== Initialization ========== @@ -33,6 +40,9 @@ async function loadMcpConfig() { mcpConfig = data; mcpAllProjects = data.projects || {}; mcpGlobalServers = data.globalServers || {}; + mcpUserServers = data.userServers || {}; + mcpEnterpriseServers = data.enterpriseServers || {}; + mcpConfigSources = data.configSources || []; // Get current project servers const currentPath = projectPath.replace(/\//g, '\\'); diff --git a/ccw/src/templates/dashboard-js/components/navigation.js b/ccw/src/templates/dashboard-js/components/navigation.js index 559605fd..935f8dcf 100644 --- a/ccw/src/templates/dashboard-js/components/navigation.js +++ b/ccw/src/templates/dashboard-js/components/navigation.js @@ -157,17 +157,19 @@ async function refreshWorkspace() { // Reload data from server const data = await loadDashboardData(projectPath); if (data) { - // Update stores - sessionDataStore = {}; - liteTaskDataStore = {}; + // Clear and repopulate stores + Object.keys(sessionDataStore).forEach(k => delete sessionDataStore[k]); + Object.keys(liteTaskDataStore).forEach(k => delete liteTaskDataStore[k]); // Populate stores [...(data.activeSessions || []), ...(data.archivedSessions || [])].forEach(s => { - sessionDataStore[s.session_id] = s; + const sessionKey = `session-${s.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-'); + sessionDataStore[sessionKey] = s; }); [...(data.liteTasks?.litePlan || []), ...(data.liteTasks?.liteFix || [])].forEach(s => { - liteTaskDataStore[s.session_id] = s; + const sessionKey = `lite-${s.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-'); + liteTaskDataStore[sessionKey] = s; }); // Update global data diff --git a/ccw/src/templates/dashboard-js/views/mcp-manager.js b/ccw/src/templates/dashboard-js/views/mcp-manager.js index 290a481a..0977aae7 100644 --- a/ccw/src/templates/dashboard-js/views/mcp-manager.js +++ b/ccw/src/templates/dashboard-js/views/mcp-manager.js @@ -27,9 +27,11 @@ async function renderMcpManager() { // Separate current project servers and available servers const currentProjectServerNames = Object.keys(projectServers); - // Separate global servers and project servers that are not in current project - const globalServerEntries = Object.entries(mcpGlobalServers) + // 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 otherProjectServers = Object.entries(allAvailableServers) .filter(([name, info]) => !currentProjectServerNames.includes(name) && !info.isGlobal); @@ -65,20 +67,40 @@ async function renderMcpManager() { `} - - ${globalServerEntries.length > 0 ? ` + + ${enterpriseServerEntries.length > 0 ? `
- 🌐 -

Global MCP Servers

+ 🏢 +

Enterprise MCP Servers

+ Managed
- ${globalServerEntries.length} servers from ~/.claude/settings + ${enterpriseServerEntries.length} servers (read-only)
- ${globalServerEntries.map(([serverName, serverConfig]) => { - return renderGlobalServerCard(serverName, serverConfig); + ${enterpriseServerEntries.map(([serverName, serverConfig]) => { + return renderEnterpriseServerCard(serverName, serverConfig); + }).join('')} +
+
+ ` : ''} + + + ${userServerEntries.length > 0 ? ` +
+
+
+ 👤 +

User MCP Servers

+
+ ${userServerEntries.length} servers from ~/.claude.json +
+ +
+ ${userServerEntries.map(([serverName, serverConfig]) => { + return renderGlobalServerCard(serverName, serverConfig, 'user'); }).join('')}
@@ -263,18 +285,19 @@ function renderAvailableServerCard(serverName, serverInfo) { `; } -function renderGlobalServerCard(serverName, serverConfig) { - const command = serverConfig.command || 'N/A'; +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 `
- 🌐 + 👤

${escapeHtml(serverName)}

- Global + User
` : ''}
- Available to all projects from ~/.claude/settings + Available to all projects from ~/.claude.json +
+
+ + `; +} + +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 ` +
+
+
+ 🏢 +

${escapeHtml(serverName)}

+ Enterprise + 🔒 +
+ + Read-only + +
+ +
+
+ ${serverType === 'stdio' ? 'cmd' : 'url'} + ${escapeHtml(command)} +
+ ${args.length > 0 ? ` +
+ args + ${escapeHtml(args.slice(0, 3).join(' '))}${args.length > 3 ? '...' : ''} +
+ ` : ''} + ${hasEnv ? ` +
+ env + ${Object.keys(serverConfig.env).length} variables +
+ ` : ''} +
+ Managed by organization (highest priority)
diff --git a/ccw/src/tools/classify-folders.js b/ccw/src/tools/classify-folders.js new file mode 100644 index 00000000..b7cf33a0 --- /dev/null +++ b/ccw/src/tools/classify-folders.js @@ -0,0 +1,204 @@ +/** + * Classify Folders Tool + * Categorize folders by type for documentation generation + * Types: code (API.md + README.md), navigation (README.md only), skip (empty) + */ + +import { readdirSync, statSync, existsSync } from 'fs'; +import { join, resolve, extname } from 'path'; + +// Code file extensions +const CODE_EXTENSIONS = [ + '.ts', '.tsx', '.js', '.jsx', + '.py', '.go', '.java', '.rs', + '.c', '.cpp', '.cs', '.rb', + '.php', '.swift', '.kt' +]; + +/** + * Count code files in a directory (non-recursive) + */ +function countCodeFiles(dirPath) { + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + return entries.filter(e => { + if (!e.isFile()) return false; + const ext = extname(e.name).toLowerCase(); + return CODE_EXTENSIONS.includes(ext); + }).length; + } catch (e) { + return 0; + } +} + +/** + * Count subdirectories in a directory + */ +function countSubdirs(dirPath) { + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + return entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).length; + } catch (e) { + return 0; + } +} + +/** + * Determine folder type + */ +function classifyFolder(dirPath) { + const codeFiles = countCodeFiles(dirPath); + const subdirs = countSubdirs(dirPath); + + if (codeFiles > 0) { + return { type: 'code', codeFiles, subdirs }; // Generates API.md + README.md + } else if (subdirs > 0) { + return { type: 'navigation', codeFiles, subdirs }; // README.md only + } else { + return { type: 'skip', codeFiles, subdirs }; // Empty or no relevant content + } +} + +/** + * Parse input from get_modules_by_depth format + * Format: depth:N|path:./path|files:N|types:[ext,ext]|has_claude:yes/no + */ +function parseModuleInput(line) { + const parts = {}; + line.split('|').forEach(part => { + const [key, value] = part.split(':'); + if (key && value !== undefined) { + parts[key] = value; + } + }); + return parts; +} + +/** + * Main execute function + */ +async function execute(params) { + const { input, path: targetPath } = params; + + const results = []; + + // Mode 1: Process piped input from get_modules_by_depth + if (input) { + let lines; + + // Check if input is JSON (from ccw tool exec output) + if (typeof input === 'string' && input.trim().startsWith('{')) { + try { + const jsonInput = JSON.parse(input); + // Handle output from get_modules_by_depth tool (wrapped in result) + const output = jsonInput.result?.output || jsonInput.output; + if (output) { + lines = output.split('\n'); + } else { + lines = [input]; + } + } catch { + // Not JSON, treat as line-delimited text + lines = input.split('\n'); + } + } else if (Array.isArray(input)) { + lines = input; + } else { + lines = input.split('\n'); + } + + for (const line of lines) { + if (!line.trim()) continue; + + const parsed = parseModuleInput(line); + const folderPath = parsed.path; + + if (!folderPath) continue; + + const basePath = targetPath ? resolve(process.cwd(), targetPath) : process.cwd(); + const fullPath = resolve(basePath, folderPath); + + if (!existsSync(fullPath) || !statSync(fullPath).isDirectory()) { + continue; + } + + const classification = classifyFolder(fullPath); + + results.push({ + path: folderPath, + type: classification.type, + code_files: classification.codeFiles, + subdirs: classification.subdirs + }); + } + } + // Mode 2: Classify a single directory + else if (targetPath) { + const fullPath = resolve(process.cwd(), targetPath); + + if (!existsSync(fullPath)) { + throw new Error(`Directory not found: ${fullPath}`); + } + + if (!statSync(fullPath).isDirectory()) { + throw new Error(`Not a directory: ${fullPath}`); + } + + const classification = classifyFolder(fullPath); + + results.push({ + path: targetPath, + type: classification.type, + code_files: classification.codeFiles, + subdirs: classification.subdirs + }); + } + else { + throw new Error('Either "input" or "path" parameter is required'); + } + + // Format output + const output = results.map(r => + `${r.path}|${r.type}|code:${r.code_files}|dirs:${r.subdirs}` + ).join('\n'); + + return { + total: results.length, + by_type: { + code: results.filter(r => r.type === 'code').length, + navigation: results.filter(r => r.type === 'navigation').length, + skip: results.filter(r => r.type === 'skip').length + }, + results, + output + }; +} + +/** + * Tool Definition + */ +export const classifyFoldersTool = { + name: 'classify_folders', + description: `Classify folders by type for documentation generation. +Types: +- code: Contains code files (generates API.md + README.md) +- navigation: Contains subdirectories only (generates README.md only) +- skip: Empty or no relevant content + +Input: Either piped output from get_modules_by_depth or a single directory path.`, + parameters: { + type: 'object', + properties: { + input: { + type: 'string', + description: 'Piped input from get_modules_by_depth (one module per line)' + }, + path: { + type: 'string', + description: 'Single directory path to classify' + } + }, + required: [] + }, + execute +}; diff --git a/ccw/src/tools/convert-tokens-to-css.js b/ccw/src/tools/convert-tokens-to-css.js new file mode 100644 index 00000000..625d4954 --- /dev/null +++ b/ccw/src/tools/convert-tokens-to-css.js @@ -0,0 +1,250 @@ +/** + * Convert Tokens to CSS Tool + * Transform design-tokens.json to CSS custom properties + */ + +/** + * Generate Google Fonts import URL + */ +function generateFontImport(fonts) { + if (!fonts || typeof fonts !== 'object') return ''; + + const fontParams = []; + const processedFonts = new Set(); + + // Extract font families from typography.font_family + Object.values(fonts).forEach(fontValue => { + if (typeof fontValue !== 'string') return; + + // Get the primary font (before comma) + const primaryFont = fontValue.split(',')[0].trim().replace(/['"]/g, ''); + + // Skip system fonts + const systemFonts = ['system-ui', 'sans-serif', 'serif', 'monospace', 'cursive', 'fantasy']; + if (systemFonts.includes(primaryFont.toLowerCase())) return; + if (processedFonts.has(primaryFont)) return; + + processedFonts.add(primaryFont); + + // URL encode font name + const encodedFont = primaryFont.replace(/ /g, '+'); + + // Special handling for common fonts + const specialFonts = { + 'Comic Neue': 'Comic+Neue:wght@300;400;700', + 'Patrick Hand': 'Patrick+Hand:wght@400;700', + 'Caveat': 'Caveat:wght@400;700', + 'Dancing Script': 'Dancing+Script:wght@400;700' + }; + + if (specialFonts[primaryFont]) { + fontParams.push(`family=${specialFonts[primaryFont]}`); + } else { + fontParams.push(`family=${encodedFont}:wght@400;500;600;700`); + } + }); + + if (fontParams.length === 0) return ''; + + return `@import url('https://fonts.googleapis.com/css2?${fontParams.join('&')}&display=swap');`; +} + +/** + * Generate CSS variables for a category + */ +function generateCssVars(prefix, obj, indent = ' ') { + if (!obj || typeof obj !== 'object') return []; + + const lines = []; + Object.entries(obj).forEach(([key, value]) => { + const varName = `--${prefix}-${key.replace(/_/g, '-')}`; + lines.push(`${indent}${varName}: ${value};`); + }); + return lines; +} + +/** + * Main execute function + */ +async function execute(params) { + const { input } = params; + + if (!input) { + throw new Error('Parameter "input" (design tokens JSON) is required'); + } + + // Parse input + let tokens; + try { + tokens = typeof input === 'string' ? JSON.parse(input) : input; + } catch (e) { + throw new Error(`Invalid JSON input: ${e.message}`); + } + + const lines = []; + + // Header + const styleName = tokens.meta?.name || 'Design Tokens'; + lines.push('/* ========================================'); + lines.push(` Design Tokens: ${styleName}`); + lines.push(' Auto-generated from design-tokens.json'); + lines.push(' ======================================== */'); + lines.push(''); + + // Google Fonts import + if (tokens.typography?.font_family) { + const fontImport = generateFontImport(tokens.typography.font_family); + if (fontImport) { + lines.push('/* Import Web Fonts */'); + lines.push(fontImport); + lines.push(''); + } + } + + // CSS Custom Properties + lines.push(':root {'); + + // Colors + if (tokens.colors) { + if (tokens.colors.brand) { + lines.push(' /* Colors - Brand */'); + lines.push(...generateCssVars('color-brand', tokens.colors.brand)); + lines.push(''); + } + if (tokens.colors.surface) { + lines.push(' /* Colors - Surface */'); + lines.push(...generateCssVars('color-surface', tokens.colors.surface)); + lines.push(''); + } + if (tokens.colors.semantic) { + lines.push(' /* Colors - Semantic */'); + lines.push(...generateCssVars('color-semantic', tokens.colors.semantic)); + lines.push(''); + } + if (tokens.colors.text) { + lines.push(' /* Colors - Text */'); + lines.push(...generateCssVars('color-text', tokens.colors.text)); + lines.push(''); + } + if (tokens.colors.border) { + lines.push(' /* Colors - Border */'); + lines.push(...generateCssVars('color-border', tokens.colors.border)); + lines.push(''); + } + } + + // Typography + if (tokens.typography) { + if (tokens.typography.font_family) { + lines.push(' /* Typography - Font Family */'); + lines.push(...generateCssVars('font-family', tokens.typography.font_family)); + lines.push(''); + } + if (tokens.typography.font_size) { + lines.push(' /* Typography - Font Size */'); + lines.push(...generateCssVars('font-size', tokens.typography.font_size)); + lines.push(''); + } + if (tokens.typography.font_weight) { + lines.push(' /* Typography - Font Weight */'); + lines.push(...generateCssVars('font-weight', tokens.typography.font_weight)); + lines.push(''); + } + if (tokens.typography.line_height) { + lines.push(' /* Typography - Line Height */'); + lines.push(...generateCssVars('line-height', tokens.typography.line_height)); + lines.push(''); + } + if (tokens.typography.letter_spacing) { + lines.push(' /* Typography - Letter Spacing */'); + lines.push(...generateCssVars('letter-spacing', tokens.typography.letter_spacing)); + lines.push(''); + } + } + + // Spacing + if (tokens.spacing) { + lines.push(' /* Spacing */'); + lines.push(...generateCssVars('spacing', tokens.spacing)); + lines.push(''); + } + + // Border Radius + if (tokens.border_radius) { + lines.push(' /* Border Radius */'); + lines.push(...generateCssVars('border-radius', tokens.border_radius)); + lines.push(''); + } + + // Shadows + if (tokens.shadows) { + lines.push(' /* Shadows */'); + lines.push(...generateCssVars('shadow', tokens.shadows)); + lines.push(''); + } + + // Breakpoints + if (tokens.breakpoints) { + lines.push(' /* Breakpoints */'); + lines.push(...generateCssVars('breakpoint', tokens.breakpoints)); + lines.push(''); + } + + lines.push('}'); + lines.push(''); + + // Global Font Application + lines.push('/* ========================================'); + lines.push(' Global Font Application'); + lines.push(' ======================================== */'); + lines.push(''); + lines.push('body {'); + lines.push(' font-family: var(--font-family-body);'); + lines.push(' font-size: var(--font-size-base);'); + lines.push(' line-height: var(--line-height-normal);'); + lines.push(' color: var(--color-text-primary);'); + lines.push(' background-color: var(--color-surface-background);'); + lines.push('}'); + lines.push(''); + lines.push('h1, h2, h3, h4, h5, h6, legend {'); + lines.push(' font-family: var(--font-family-heading);'); + lines.push('}'); + lines.push(''); + lines.push('/* Reset default margins for better control */'); + lines.push('* {'); + lines.push(' margin: 0;'); + lines.push(' padding: 0;'); + lines.push(' box-sizing: border-box;'); + lines.push('}'); + + const css = lines.join('\n'); + + return { + style_name: styleName, + lines_count: lines.length, + css + }; +} + +/** + * Tool Definition + */ +export const convertTokensToCssTool = { + name: 'convert_tokens_to_css', + description: `Transform design-tokens.json to CSS custom properties. +Generates: +- Google Fonts @import URL +- CSS custom properties for colors, typography, spacing, etc. +- Global font application rules`, + parameters: { + type: 'object', + properties: { + input: { + type: 'string', + description: 'Design tokens JSON string or object' + } + }, + required: ['input'] + }, + execute +}; diff --git a/ccw/src/tools/detect-changed-modules.js b/ccw/src/tools/detect-changed-modules.js new file mode 100644 index 00000000..0cc89239 --- /dev/null +++ b/ccw/src/tools/detect-changed-modules.js @@ -0,0 +1,288 @@ +/** + * Detect Changed Modules Tool + * Find modules affected by git changes or recent modifications + */ + +import { readdirSync, statSync, existsSync, readFileSync } from 'fs'; +import { join, resolve, dirname, extname, relative } from 'path'; +import { execSync } from 'child_process'; + +// Source file extensions to track +const SOURCE_EXTENSIONS = [ + '.md', '.js', '.ts', '.jsx', '.tsx', + '.py', '.go', '.rs', '.java', '.cpp', '.c', '.h', + '.sh', '.ps1', '.json', '.yaml', '.yml' +]; + +// Directories to exclude +const EXCLUDE_DIRS = [ + '.git', '__pycache__', 'node_modules', '.venv', 'venv', 'env', + 'dist', 'build', '.cache', '.pytest_cache', '.mypy_cache', + 'coverage', '.nyc_output', 'logs', 'tmp', 'temp' +]; + +/** + * Check if git is available and we're in a repo + */ +function isGitRepo(basePath) { + try { + execSync('git rev-parse --git-dir', { cwd: basePath, stdio: 'pipe' }); + return true; + } catch (e) { + return false; + } +} + +/** + * Get changed files from git + */ +function getGitChangedFiles(basePath) { + try { + // Get staged + unstaged changes + let output = execSync('git diff --name-only HEAD 2>/dev/null', { + cwd: basePath, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + const cachedOutput = execSync('git diff --name-only --cached 2>/dev/null', { + cwd: basePath, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + if (cachedOutput) { + output = output ? `${output}\n${cachedOutput}` : cachedOutput; + } + + // If no working changes, check last commit + if (!output) { + output = execSync('git diff --name-only HEAD~1 HEAD 2>/dev/null', { + cwd: basePath, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + } + + return output ? output.split('\n').filter(f => f.trim()) : []; + } catch (e) { + return []; + } +} + +/** + * Find recently modified files (fallback when no git changes) + */ +function findRecentlyModified(basePath, hoursAgo = 24) { + const results = []; + const cutoffTime = Date.now() - (hoursAgo * 60 * 60 * 1000); + + function scan(dirPath) { + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + if (EXCLUDE_DIRS.includes(entry.name)) continue; + scan(join(dirPath, entry.name)); + } else if (entry.isFile()) { + const ext = extname(entry.name).toLowerCase(); + if (!SOURCE_EXTENSIONS.includes(ext)) continue; + + const fullPath = join(dirPath, entry.name); + try { + const stat = statSync(fullPath); + if (stat.mtimeMs > cutoffTime) { + results.push(relative(basePath, fullPath)); + } + } catch (e) { + // Skip files we can't stat + } + } + } + } catch (e) { + // Ignore permission errors + } + } + + scan(basePath); + return results; +} + +/** + * Extract unique parent directories from file list + */ +function extractDirectories(files, basePath) { + const dirs = new Set(); + + for (const file of files) { + const dir = dirname(file); + if (dir === '.' || dir === '') { + dirs.add('.'); + } else { + dirs.add('./' + dir.replace(/\\/g, '/')); + } + } + + return Array.from(dirs).sort(); +} + +/** + * Count files in directory + */ +function countFiles(dirPath) { + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + return entries.filter(e => e.isFile()).length; + } catch (e) { + return 0; + } +} + +/** + * Get file types in directory + */ +function getFileTypes(dirPath) { + const types = new Set(); + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + entries.forEach(entry => { + if (entry.isFile()) { + const ext = extname(entry.name).slice(1); + if (ext) types.add(ext); + } + }); + } catch (e) { + // Ignore + } + return Array.from(types); +} + +/** + * Main execute function + */ +async function execute(params) { + const { format = 'paths', path: targetPath = '.' } = params; + + const basePath = resolve(process.cwd(), targetPath); + + if (!existsSync(basePath)) { + throw new Error(`Directory not found: ${basePath}`); + } + + // Get changed files + let changedFiles = []; + let changeSource = 'none'; + + if (isGitRepo(basePath)) { + changedFiles = getGitChangedFiles(basePath); + changeSource = changedFiles.length > 0 ? 'git' : 'none'; + } + + // Fallback to recently modified files + if (changedFiles.length === 0) { + changedFiles = findRecentlyModified(basePath); + changeSource = changedFiles.length > 0 ? 'mtime' : 'none'; + } + + // Extract affected directories + const affectedDirs = extractDirectories(changedFiles, basePath); + + // Format output + let output; + const results = []; + + for (const dir of affectedDirs) { + const fullPath = dir === '.' ? basePath : resolve(basePath, dir); + if (!existsSync(fullPath) || !statSync(fullPath).isDirectory()) continue; + + const fileCount = countFiles(fullPath); + const types = getFileTypes(fullPath); + const depth = dir === '.' ? 0 : (dir.match(/\//g) || []).length; + const hasClaude = existsSync(join(fullPath, 'CLAUDE.md')); + + results.push({ + depth, + path: dir, + files: fileCount, + types, + has_claude: hasClaude + }); + } + + switch (format) { + case 'list': + output = results.map(r => + `depth:${r.depth}|path:${r.path}|files:${r.files}|types:[${r.types.join(',')}]|has_claude:${r.has_claude ? 'yes' : 'no'}|status:changed` + ).join('\n'); + break; + + case 'grouped': + const maxDepth = results.length > 0 ? Math.max(...results.map(r => r.depth)) : 0; + const lines = ['Affected modules by changes:']; + + for (let d = 0; d <= maxDepth; d++) { + const atDepth = results.filter(r => r.depth === d); + if (atDepth.length > 0) { + lines.push(` Depth ${d}:`); + atDepth.forEach(r => { + const claudeIndicator = r.has_claude ? ' [OK]' : ''; + lines.push(` - ${r.path}${claudeIndicator} (changed)`); + }); + } + } + + if (results.length === 0) { + lines.push(' No recent changes detected'); + } + + output = lines.join('\n'); + break; + + case 'paths': + default: + output = affectedDirs.join('\n'); + break; + } + + return { + format, + change_source: changeSource, + changed_files_count: changedFiles.length, + affected_modules_count: results.length, + results, + output + }; +} + +/** + * Tool Definition + */ +export const detectChangedModulesTool = { + name: 'detect_changed_modules', + description: `Detect modules affected by git changes or recent file modifications. +Features: +- Git-aware: detects staged, unstaged, or last commit changes +- Fallback: finds files modified in last 24 hours +- Respects .gitignore patterns + +Output formats: list, grouped, paths (default)`, + parameters: { + type: 'object', + properties: { + format: { + type: 'string', + enum: ['list', 'grouped', 'paths'], + description: 'Output format (default: paths)', + default: 'paths' + }, + path: { + type: 'string', + description: 'Target directory path (default: current directory)', + default: '.' + } + }, + required: [] + }, + execute +}; diff --git a/ccw/src/tools/discover-design-files.js b/ccw/src/tools/discover-design-files.js new file mode 100644 index 00000000..533de8c7 --- /dev/null +++ b/ccw/src/tools/discover-design-files.js @@ -0,0 +1,134 @@ +/** + * Discover Design Files Tool + * Find CSS/JS/HTML design-related files and output JSON + */ + +import { readdirSync, statSync, existsSync, writeFileSync } from 'fs'; +import { join, resolve, relative, extname } from 'path'; + +// Directories to exclude +const EXCLUDE_DIRS = [ + 'node_modules', 'dist', '.git', 'build', 'coverage', + '.cache', '.next', '.nuxt', '__pycache__', '.venv' +]; + +// File type patterns +const FILE_PATTERNS = { + css: ['.css', '.scss', '.sass', '.less', '.styl'], + js: ['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', '.vue', '.svelte'], + html: ['.html', '.htm'] +}; + +/** + * Find files matching extensions recursively + */ +function findFiles(basePath, extensions) { + const results = []; + + function scan(dirPath) { + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + if (EXCLUDE_DIRS.includes(entry.name)) continue; + scan(join(dirPath, entry.name)); + } else if (entry.isFile()) { + const ext = extname(entry.name).toLowerCase(); + if (extensions.includes(ext)) { + results.push(relative(basePath, join(dirPath, entry.name)).replace(/\\/g, '/')); + } + } + } + } catch (e) { + // Ignore permission errors + } + } + + scan(basePath); + return results.sort(); +} + +/** + * Main execute function + */ +async function execute(params) { + const { sourceDir = '.', outputPath } = params; + + const basePath = resolve(process.cwd(), sourceDir); + + if (!existsSync(basePath)) { + throw new Error(`Directory not found: ${basePath}`); + } + + if (!statSync(basePath).isDirectory()) { + throw new Error(`Not a directory: ${basePath}`); + } + + // Find files by type + const cssFiles = findFiles(basePath, FILE_PATTERNS.css); + const jsFiles = findFiles(basePath, FILE_PATTERNS.js); + const htmlFiles = findFiles(basePath, FILE_PATTERNS.html); + + // Build result + const result = { + discovery_time: new Date().toISOString(), + source_directory: basePath, + file_types: { + css: { + count: cssFiles.length, + files: cssFiles + }, + js: { + count: jsFiles.length, + files: jsFiles + }, + html: { + count: htmlFiles.length, + files: htmlFiles + } + }, + total_files: cssFiles.length + jsFiles.length + htmlFiles.length + }; + + // Write to file if outputPath specified + if (outputPath) { + const outPath = resolve(process.cwd(), outputPath); + writeFileSync(outPath, JSON.stringify(result, null, 2), 'utf8'); + } + + return { + css_count: cssFiles.length, + js_count: jsFiles.length, + html_count: htmlFiles.length, + total_files: result.total_files, + output_path: outputPath || null, + result + }; +} + +/** + * Tool Definition + */ +export const discoverDesignFilesTool = { + name: 'discover_design_files', + description: `Discover CSS/JS/HTML design-related files in a directory. +Scans recursively and excludes common build/cache directories. +Returns JSON with file discovery results.`, + parameters: { + type: 'object', + properties: { + sourceDir: { + type: 'string', + description: 'Source directory to scan (default: current directory)', + default: '.' + }, + outputPath: { + type: 'string', + description: 'Optional path to write JSON output file' + } + }, + required: [] + }, + execute +}; diff --git a/ccw/src/tools/edit-file.js b/ccw/src/tools/edit-file.js new file mode 100644 index 00000000..8480390f --- /dev/null +++ b/ccw/src/tools/edit-file.js @@ -0,0 +1,238 @@ +/** + * Edit File Tool - AI-focused file editing + * Two complementary modes: + * - update: Content-driven text replacement (AI primary use) + * - line: Position-driven line operations (precise control) + */ + +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { resolve, isAbsolute } from 'path'; + +/** + * Resolve file path and read content + * @param {string} filePath - Path to file + * @returns {{resolvedPath: string, content: string}} + */ +function readFile(filePath) { + const resolvedPath = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath); + + if (!existsSync(resolvedPath)) { + throw new Error(`File not found: ${resolvedPath}`); + } + + try { + const content = readFileSync(resolvedPath, 'utf8'); + return { resolvedPath, content }; + } catch (error) { + throw new Error(`Failed to read file: ${error.message}`); + } +} + +/** + * Write content to file + * @param {string} filePath - Path to file + * @param {string} content - Content to write + */ +function writeFile(filePath, content) { + try { + writeFileSync(filePath, content, 'utf8'); + } catch (error) { + throw new Error(`Failed to write file: ${error.message}`); + } +} + +/** + * Mode: update - Simple text replacement + * Auto-adapts line endings (CRLF/LF) + */ +function executeUpdateMode(content, params) { + const { oldText, newText } = params; + + if (!oldText) throw new Error('Parameter "oldText" is required for update mode'); + if (newText === undefined) throw new Error('Parameter "newText" is required for update mode'); + + // Detect original line ending + const hasCRLF = content.includes('\r\n'); + + // Normalize to LF for matching + const normalize = (str) => str.replace(/\r\n/g, '\n'); + const normalizedContent = normalize(content); + const normalizedOld = normalize(oldText); + const normalizedNew = normalize(newText); + + let newContent = normalizedContent; + let status = 'not found'; + + if (newContent.includes(normalizedOld)) { + newContent = newContent.replace(normalizedOld, normalizedNew); + status = 'replaced'; + } + + // Restore original line ending + if (hasCRLF) { + newContent = newContent.replace(/\n/g, '\r\n'); + } + + return { + content: newContent, + modified: content !== newContent, + status, + message: status === 'replaced' ? 'Text replaced successfully' : 'oldText not found in file' + }; +} + +/** + * Mode: line - Line-based operations + * Operations: insert_before, insert_after, replace, delete + */ +function executeLineMode(content, params) { + const { operation, line, text, end_line } = params; + + if (!operation) throw new Error('Parameter "operation" is required for line mode'); + if (line === undefined) throw new Error('Parameter "line" is required for line mode'); + + const lines = content.split('\n'); + const lineIndex = line - 1; // Convert to 0-based + + if (lineIndex < 0 || lineIndex >= lines.length) { + throw new Error(`Line ${line} out of range (1-${lines.length})`); + } + + let newLines = [...lines]; + let message = ''; + + switch (operation) { + case 'insert_before': + if (text === undefined) throw new Error('Parameter "text" is required for insert_before'); + newLines.splice(lineIndex, 0, text); + message = `Inserted before line ${line}`; + break; + + case 'insert_after': + if (text === undefined) throw new Error('Parameter "text" is required for insert_after'); + newLines.splice(lineIndex + 1, 0, text); + message = `Inserted after line ${line}`; + break; + + case 'replace': + if (text === undefined) throw new Error('Parameter "text" is required for replace'); + const endIdx = end_line ? end_line - 1 : lineIndex; + if (endIdx < lineIndex || endIdx >= lines.length) { + throw new Error(`end_line ${end_line} is invalid`); + } + const deleteCount = endIdx - lineIndex + 1; + newLines.splice(lineIndex, deleteCount, text); + message = end_line ? `Replaced lines ${line}-${end_line}` : `Replaced line ${line}`; + break; + + case 'delete': + const endDelete = end_line ? end_line - 1 : lineIndex; + if (endDelete < lineIndex || endDelete >= lines.length) { + throw new Error(`end_line ${end_line} is invalid`); + } + const count = endDelete - lineIndex + 1; + newLines.splice(lineIndex, count); + message = end_line ? `Deleted lines ${line}-${end_line}` : `Deleted line ${line}`; + break; + + default: + throw new Error(`Unknown operation: ${operation}. Valid: insert_before, insert_after, replace, delete`); + } + + const newContent = newLines.join('\n'); + + return { + content: newContent, + modified: content !== newContent, + operation, + line, + end_line, + message + }; +} + +/** + * Main execute function - routes to appropriate mode + */ +async function execute(params) { + const { path: filePath, mode = 'update' } = params; + + if (!filePath) throw new Error('Parameter "path" is required'); + + const { resolvedPath, content } = readFile(filePath); + + let result; + switch (mode) { + case 'update': + result = executeUpdateMode(content, params); + break; + case 'line': + result = executeLineMode(content, params); + break; + default: + throw new Error(`Unknown mode: ${mode}. Valid modes: update, line`); + } + + // Write if modified + if (result.modified) { + writeFile(resolvedPath, result.content); + } + + // Remove content from result (don't return file content) + const { content: _, ...output } = result; + return output; +} + +/** + * Edit File Tool Definition + */ +export const editFileTool = { + name: 'edit_file', + description: `Update file with two modes: +- update: Replace oldText with newText (default) +- line: Position-driven line operations`, + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Path to the file to modify' + }, + mode: { + type: 'string', + enum: ['update', 'line'], + description: 'Edit mode (default: update)', + default: 'update' + }, + // Update mode params + oldText: { + type: 'string', + description: '[update mode] Text to find and replace' + }, + newText: { + type: 'string', + description: '[update mode] Replacement text' + }, + // Line mode params + operation: { + type: 'string', + enum: ['insert_before', 'insert_after', 'replace', 'delete'], + description: '[line mode] Line operation type' + }, + line: { + type: 'number', + description: '[line mode] Line number (1-based)' + }, + end_line: { + type: 'number', + description: '[line mode] End line for range operations' + }, + text: { + type: 'string', + description: '[line mode] Text for insert/replace operations' + } + }, + required: ['path'] + }, + execute +}; diff --git a/ccw/src/tools/generate-module-docs.js b/ccw/src/tools/generate-module-docs.js new file mode 100644 index 00000000..84f7bf52 --- /dev/null +++ b/ccw/src/tools/generate-module-docs.js @@ -0,0 +1,368 @@ +/** + * Generate Module Docs Tool + * Generate documentation for modules and projects with multiple strategies + */ + +import { readdirSync, statSync, existsSync, readFileSync, mkdirSync } from 'fs'; +import { join, resolve, basename, extname, relative } from 'path'; +import { execSync } from 'child_process'; + +// Directories to exclude +const EXCLUDE_DIRS = [ + '.git', '__pycache__', 'node_modules', '.venv', 'venv', 'env', + 'dist', 'build', '.cache', '.pytest_cache', '.mypy_cache', + 'coverage', '.nyc_output', 'logs', 'tmp', 'temp', '.workflow' +]; + +// Code file extensions +const CODE_EXTENSIONS = [ + '.ts', '.tsx', '.js', '.jsx', '.py', '.sh', '.go', '.rs' +]; + +// Default models for each tool +const DEFAULT_MODELS = { + gemini: 'gemini-2.5-flash', + qwen: 'coder-model', + codex: 'gpt5-codex' +}; + +// Template paths +const TEMPLATE_BASE = '.claude/workflows/cli-templates/prompts/documentation'; + +/** + * Detect folder type (code vs navigation) + */ +function detectFolderType(dirPath) { + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + const codeFiles = entries.filter(e => { + if (!e.isFile()) return false; + const ext = extname(e.name).toLowerCase(); + return CODE_EXTENSIONS.includes(ext); + }); + return codeFiles.length > 0 ? 'code' : 'navigation'; + } catch (e) { + return 'navigation'; + } +} + +/** + * Count files in directory + */ +function countFiles(dirPath) { + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + return entries.filter(e => e.isFile() && !e.name.startsWith('.')).length; + } catch (e) { + return 0; + } +} + +/** + * Calculate output path + */ +function calculateOutputPath(sourcePath, projectName, projectRoot) { + const absSource = resolve(sourcePath); + const normRoot = resolve(projectRoot); + let relPath = relative(normRoot, absSource); + relPath = relPath.replace(/\\/g, '/'); + + return join('.workflow', 'docs', projectName, relPath); +} + +/** + * Load template content + */ +function loadTemplate(templateName) { + const homePath = process.env.HOME || process.env.USERPROFILE; + const templatePath = join(homePath, TEMPLATE_BASE, `${templateName}.txt`); + + if (existsSync(templatePath)) { + return readFileSync(templatePath, 'utf8'); + } + + // Fallback templates + const fallbacks = { + 'api': 'Generate API documentation with function signatures, parameters, return values, and usage examples.', + 'module-readme': 'Generate README documentation with purpose, usage, configuration, and examples.', + 'folder-navigation': 'Generate navigation README with overview of subdirectories and their purposes.', + 'project-readme': 'Generate project README with overview, installation, usage, and configuration.', + 'project-architecture': 'Generate ARCHITECTURE.md with system design, components, and data flow.' + }; + + return fallbacks[templateName] || 'Generate comprehensive documentation.'; +} + +/** + * Build CLI command + */ +function buildCliCommand(tool, prompt, model) { + const escapedPrompt = prompt.replace(/"/g, '\\"'); + + switch (tool) { + case 'qwen': + return model === 'coder-model' + ? `qwen -p "${escapedPrompt}" --yolo` + : `qwen -p "${escapedPrompt}" -m "${model}" --yolo`; + case 'codex': + return `codex --full-auto exec "${escapedPrompt}" -m "${model}" --skip-git-repo-check -s danger-full-access`; + case 'gemini': + default: + return `gemini -p "${escapedPrompt}" -m "${model}" --yolo`; + } +} + +/** + * Scan directory structure + */ +function scanDirectoryStructure(targetPath, strategy) { + const lines = []; + const dirName = basename(targetPath); + + let totalFiles = 0; + let totalDirs = 0; + + function countRecursive(dir) { + try { + const entries = readdirSync(dir, { withFileTypes: true }); + entries.forEach(e => { + if (e.name.startsWith('.') || EXCLUDE_DIRS.includes(e.name)) return; + if (e.isFile()) totalFiles++; + else if (e.isDirectory()) { + totalDirs++; + countRecursive(join(dir, e.name)); + } + }); + } catch (e) { + // Ignore + } + } + + countRecursive(targetPath); + const folderType = detectFolderType(targetPath); + + lines.push(`Directory: ${dirName}`); + lines.push(`Total files: ${totalFiles}`); + lines.push(`Total directories: ${totalDirs}`); + lines.push(`Folder type: ${folderType}`); + + return { + info: lines.join('\n'), + folderType + }; +} + +/** + * Main execute function + */ +async function execute(params) { + const { strategy, sourcePath, projectName, tool = 'gemini', model } = params; + + // Validate parameters + const validStrategies = ['full', 'single', 'project-readme', 'project-architecture', 'http-api']; + + if (!strategy) { + throw new Error(`Parameter "strategy" is required. Valid: ${validStrategies.join(', ')}`); + } + + if (!validStrategies.includes(strategy)) { + throw new Error(`Invalid strategy '${strategy}'. Valid: ${validStrategies.join(', ')}`); + } + + if (!sourcePath) { + throw new Error('Parameter "sourcePath" is required'); + } + + if (!projectName) { + throw new Error('Parameter "projectName" is required'); + } + + const targetPath = resolve(process.cwd(), sourcePath); + + if (!existsSync(targetPath)) { + throw new Error(`Directory not found: ${targetPath}`); + } + + if (!statSync(targetPath).isDirectory()) { + throw new Error(`Not a directory: ${targetPath}`); + } + + // Set model + const actualModel = model || DEFAULT_MODELS[tool] || DEFAULT_MODELS.gemini; + + // Scan directory + const { info: structureInfo, folderType } = scanDirectoryStructure(targetPath, strategy); + + // Calculate output path + const outputPath = calculateOutputPath(targetPath, projectName, process.cwd()); + + // Ensure output directory exists + mkdirSync(outputPath, { recursive: true }); + + // Build prompt based on strategy + let prompt; + let templateContent; + + switch (strategy) { + case 'full': + case 'single': + if (folderType === 'code') { + templateContent = loadTemplate('api'); + prompt = `Directory Structure Analysis: +${structureInfo} + +Read: ${strategy === 'full' ? '@**/*' : '@*.ts @*.tsx @*.js @*.jsx @*.py @*.sh @*.md @*.json'} + +Generate documentation files: +- API.md: Code API documentation +- README.md: Module overview and usage + +Output directory: ${outputPath} + +Template Guidelines: +${templateContent}`; + } else { + templateContent = loadTemplate('folder-navigation'); + prompt = `Directory Structure Analysis: +${structureInfo} + +Read: @*/API.md @*/README.md + +Generate documentation file: +- README.md: Navigation overview of subdirectories + +Output directory: ${outputPath} + +Template Guidelines: +${templateContent}`; + } + break; + + case 'project-readme': + templateContent = loadTemplate('project-readme'); + prompt = `Read all module documentation: +@.workflow/docs/${projectName}/**/API.md +@.workflow/docs/${projectName}/**/README.md + +Generate project-level documentation: +- README.md in .workflow/docs/${projectName}/ + +Template Guidelines: +${templateContent}`; + break; + + case 'project-architecture': + templateContent = loadTemplate('project-architecture'); + prompt = `Read project documentation: +@.workflow/docs/${projectName}/README.md +@.workflow/docs/${projectName}/**/API.md + +Generate: +- ARCHITECTURE.md: System design documentation +- EXAMPLES.md: Usage examples + +Output directory: .workflow/docs/${projectName}/ + +Template Guidelines: +${templateContent}`; + break; + + case 'http-api': + prompt = `Read API route files: +@**/routes/**/*.ts @**/routes/**/*.js +@**/api/**/*.ts @**/api/**/*.js + +Generate HTTP API documentation: +- api/README.md: REST API endpoints documentation + +Output directory: .workflow/docs/${projectName}/api/`; + break; + } + + // Build and execute command + const command = buildCliCommand(tool, prompt, actualModel); + + try { + const startTime = Date.now(); + + execSync(command, { + cwd: targetPath, + encoding: 'utf8', + stdio: 'inherit', + timeout: 600000 // 10 minutes + }); + + const duration = Math.round((Date.now() - startTime) / 1000); + + return { + success: true, + strategy, + source_path: sourcePath, + project_name: projectName, + output_path: outputPath, + folder_type: folderType, + tool, + model: actualModel, + duration_seconds: duration, + message: `Documentation generated successfully in ${duration}s` + }; + } catch (error) { + return { + success: false, + strategy, + source_path: sourcePath, + project_name: projectName, + tool, + error: error.message + }; + } +} + +/** + * Tool Definition + */ +export const generateModuleDocsTool = { + name: 'generate_module_docs', + description: `Generate documentation for modules and projects. + +Module-Level Strategies: +- full: Full documentation (API.md + README.md for all directories) +- single: Single-layer documentation (current directory only) + +Project-Level Strategies: +- project-readme: Project overview from module docs +- project-architecture: System design documentation +- http-api: HTTP API documentation + +Output: .workflow/docs/{projectName}/...`, + parameters: { + type: 'object', + properties: { + strategy: { + type: 'string', + enum: ['full', 'single', 'project-readme', 'project-architecture', 'http-api'], + description: 'Documentation strategy' + }, + sourcePath: { + type: 'string', + description: 'Source module directory path' + }, + projectName: { + type: 'string', + description: 'Project name for output path' + }, + tool: { + type: 'string', + enum: ['gemini', 'qwen', 'codex'], + description: 'CLI tool to use (default: gemini)', + default: 'gemini' + }, + model: { + type: 'string', + description: 'Model name (optional, uses tool defaults)' + } + }, + required: ['strategy', 'sourcePath', 'projectName'] + }, + execute +}; diff --git a/ccw/src/tools/get-modules-by-depth.js b/ccw/src/tools/get-modules-by-depth.js new file mode 100644 index 00000000..4be35219 --- /dev/null +++ b/ccw/src/tools/get-modules-by-depth.js @@ -0,0 +1,308 @@ +/** + * Get Modules by Depth Tool + * Scan project structure and organize modules by directory depth (deepest first) + */ + +import { readdirSync, statSync, existsSync, readFileSync } from 'fs'; +import { join, resolve, relative, extname } from 'path'; + +// System/cache directories to always exclude +const SYSTEM_EXCLUDES = [ + // Version control and IDE + '.git', '.gitignore', '.gitmodules', '.gitattributes', + '.svn', '.hg', '.bzr', + '.history', '.vscode', '.idea', '.vs', '.vscode-test', + '.sublime-text', '.atom', + // Python + '__pycache__', '.pytest_cache', '.mypy_cache', '.tox', + '.coverage', 'htmlcov', '.nox', '.venv', 'venv', 'env', + '.egg-info', '.eggs', '.wheel', + 'site-packages', '.python-version', + // Node.js/JavaScript + 'node_modules', '.npm', '.yarn', '.pnpm', 'yarn-error.log', + '.nyc_output', 'coverage', '.next', '.nuxt', + '.cache', '.parcel-cache', '.vite', 'dist', 'build', + '.turbo', '.vercel', '.netlify', + // Build/compile outputs + 'out', 'output', '_site', 'public', + '.output', '.generated', 'generated', 'gen', + 'bin', 'obj', 'Debug', 'Release', + // Testing + 'test-results', 'junit.xml', 'test_results', + 'cypress', 'playwright-report', '.playwright', + // Logs and temp files + 'logs', 'log', 'tmp', 'temp', '.tmp', '.temp', + // Documentation build outputs + '_book', 'docs/_build', 'site', 'gh-pages', + '.docusaurus', '.vuepress', '.gitbook', + // Cloud and deployment + '.serverless', '.terraform', + '.aws', '.azure', '.gcp', + // Mobile development + '.gradle', '.expo', '.metro', + 'DerivedData', + // Game development + 'Library', 'Temp', 'ProjectSettings', + 'MemoryCaptures', 'UserSettings' +]; + +/** + * Parse .gitignore file and return patterns + */ +function parseGitignore(basePath) { + const gitignorePath = join(basePath, '.gitignore'); + const patterns = []; + + if (existsSync(gitignorePath)) { + const content = readFileSync(gitignorePath, 'utf8'); + content.split('\n').forEach(line => { + line = line.trim(); + // Skip empty lines and comments + if (!line || line.startsWith('#')) return; + // Remove trailing slash + line = line.replace(/\/$/, ''); + patterns.push(line); + }); + } + + return patterns; +} + +/** + * Check if a path should be excluded + */ +function shouldExclude(name, gitignorePatterns) { + // Check system excludes + if (SYSTEM_EXCLUDES.includes(name)) return true; + + // Check gitignore patterns (simple matching) + for (const pattern of gitignorePatterns) { + if (name === pattern) return true; + // Simple wildcard matching + if (pattern.includes('*')) { + const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); + if (regex.test(name)) return true; + } + } + + return false; +} + +/** + * Get file types in a directory + */ +function getFileTypes(dirPath) { + const types = new Set(); + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + entries.forEach(entry => { + if (entry.isFile()) { + const ext = extname(entry.name).slice(1); + if (ext) types.add(ext); + } + }); + } catch (e) { + // Ignore errors + } + return Array.from(types); +} + +/** + * Count files in a directory (non-recursive) + */ +function countFiles(dirPath) { + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + return entries.filter(e => e.isFile()).length; + } catch (e) { + return 0; + } +} + +/** + * Recursively scan directories and collect info + */ +function scanDirectories(basePath, currentPath, depth, gitignorePatterns, results) { + try { + const entries = readdirSync(currentPath, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (shouldExclude(entry.name, gitignorePatterns)) continue; + + const fullPath = join(currentPath, entry.name); + const relPath = './' + relative(basePath, fullPath).replace(/\\/g, '/'); + const fileCount = countFiles(fullPath); + + // Only include directories with files + if (fileCount > 0) { + const types = getFileTypes(fullPath); + const hasClaude = existsSync(join(fullPath, 'CLAUDE.md')); + + results.push({ + depth: depth + 1, + path: relPath, + files: fileCount, + types, + has_claude: hasClaude + }); + } + + // Recurse into subdirectories + scanDirectories(basePath, fullPath, depth + 1, gitignorePatterns, results); + } + } catch (e) { + // Ignore permission errors, etc. + } +} + +/** + * Format output as list (default) + */ +function formatList(results) { + // Sort by depth descending (deepest first) + results.sort((a, b) => b.depth - a.depth); + + return results.map(r => + `depth:${r.depth}|path:${r.path}|files:${r.files}|types:[${r.types.join(',')}]|has_claude:${r.has_claude ? 'yes' : 'no'}` + ).join('\n'); +} + +/** + * Format output as grouped + */ +function formatGrouped(results) { + // Sort by depth descending + results.sort((a, b) => b.depth - a.depth); + + const maxDepth = results.length > 0 ? Math.max(...results.map(r => r.depth)) : 0; + const lines = ['Modules by depth (deepest first):']; + + for (let d = maxDepth; d >= 0; d--) { + const atDepth = results.filter(r => r.depth === d); + if (atDepth.length > 0) { + lines.push(` Depth ${d}:`); + atDepth.forEach(r => { + const claudeIndicator = r.has_claude ? ' [OK]' : ''; + lines.push(` - ${r.path}${claudeIndicator}`); + }); + } + } + + return lines.join('\n'); +} + +/** + * Format output as JSON + */ +function formatJson(results) { + // Sort by depth descending + results.sort((a, b) => b.depth - a.depth); + + const maxDepth = results.length > 0 ? Math.max(...results.map(r => r.depth)) : 0; + const modules = {}; + + for (let d = maxDepth; d >= 0; d--) { + const atDepth = results.filter(r => r.depth === d); + if (atDepth.length > 0) { + modules[d] = atDepth.map(r => ({ + path: r.path, + has_claude: r.has_claude + })); + } + } + + return JSON.stringify({ + max_depth: maxDepth, + modules + }, null, 2); +} + +/** + * Main execute function + */ +async function execute(params) { + const { format = 'list', path: targetPath = '.' } = params; + + const basePath = resolve(process.cwd(), targetPath); + + if (!existsSync(basePath)) { + throw new Error(`Directory not found: ${basePath}`); + } + + const stat = statSync(basePath); + if (!stat.isDirectory()) { + throw new Error(`Not a directory: ${basePath}`); + } + + // Parse gitignore + const gitignorePatterns = parseGitignore(basePath); + + // Collect results + const results = []; + + // Check root directory + const rootFileCount = countFiles(basePath); + if (rootFileCount > 0) { + results.push({ + depth: 0, + path: '.', + files: rootFileCount, + types: getFileTypes(basePath), + has_claude: existsSync(join(basePath, 'CLAUDE.md')) + }); + } + + // Scan subdirectories + scanDirectories(basePath, basePath, 0, gitignorePatterns, results); + + // Format output + let output; + switch (format) { + case 'grouped': + output = formatGrouped(results); + break; + case 'json': + output = formatJson(results); + break; + case 'list': + default: + output = formatList(results); + break; + } + + return { + format, + total_modules: results.length, + max_depth: results.length > 0 ? Math.max(...results.map(r => r.depth)) : 0, + output + }; +} + +/** + * Tool Definition + */ +export const getModulesByDepthTool = { + name: 'get_modules_by_depth', + description: `Scan project structure and organize modules by directory depth (deepest first). +Respects .gitignore patterns and excludes common system directories. +Output formats: list (pipe-delimited), grouped (human-readable), json.`, + parameters: { + type: 'object', + properties: { + format: { + type: 'string', + enum: ['list', 'grouped', 'json'], + description: 'Output format (default: list)', + default: 'list' + }, + path: { + type: 'string', + description: 'Target directory path (default: current directory)', + default: '.' + } + }, + required: [] + }, + execute +}; diff --git a/ccw/src/tools/index.js b/ccw/src/tools/index.js new file mode 100644 index 00000000..7da1807f --- /dev/null +++ b/ccw/src/tools/index.js @@ -0,0 +1,176 @@ +/** + * Tool Registry - MCP-like tool system for CCW + * Provides tool discovery, validation, and execution + */ + +import { editFileTool } from './edit-file.js'; +import { getModulesByDepthTool } from './get-modules-by-depth.js'; +import { classifyFoldersTool } from './classify-folders.js'; +import { detectChangedModulesTool } from './detect-changed-modules.js'; +import { discoverDesignFilesTool } from './discover-design-files.js'; +import { generateModuleDocsTool } from './generate-module-docs.js'; +import { uiGeneratePreviewTool } from './ui-generate-preview.js'; +import { uiInstantiatePrototypesTool } from './ui-instantiate-prototypes.js'; +import { updateModuleClaudeTool } from './update-module-claude.js'; +import { convertTokensToCssTool } from './convert-tokens-to-css.js'; + +// Tool registry - add new tools here +const tools = new Map(); + +/** + * Register a tool in the registry + * @param {Object} tool - Tool definition + */ +function registerTool(tool) { + if (!tool.name || !tool.execute) { + throw new Error('Tool must have name and execute function'); + } + tools.set(tool.name, tool); +} + +/** + * Get all registered tools + * @returns {Array} - Array of tool definitions (without execute function) + */ +export function listTools() { + return Array.from(tools.values()).map(tool => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters + })); +} + +/** + * Get a specific tool by name + * @param {string} name - Tool name + * @returns {Object|null} - Tool definition or null + */ +export function getTool(name) { + return tools.get(name) || null; +} + +/** + * Validate parameters against tool schema + * @param {Object} tool - Tool definition + * @param {Object} params - Parameters to validate + * @returns {{valid: boolean, errors: string[]}} + */ +function validateParams(tool, params) { + const errors = []; + const schema = tool.parameters; + + if (!schema || !schema.properties) { + return { valid: true, errors: [] }; + } + + // Check required parameters + const required = schema.required || []; + for (const req of required) { + if (params[req] === undefined || params[req] === null) { + errors.push(`Missing required parameter: ${req}`); + } + } + + // Type validation + for (const [key, value] of Object.entries(params)) { + const propSchema = schema.properties[key]; + if (!propSchema) { + continue; // Allow extra params + } + + if (propSchema.type === 'string' && typeof value !== 'string') { + errors.push(`Parameter '${key}' must be a string`); + } + if (propSchema.type === 'boolean' && typeof value !== 'boolean') { + errors.push(`Parameter '${key}' must be a boolean`); + } + if (propSchema.type === 'number' && typeof value !== 'number') { + errors.push(`Parameter '${key}' must be a number`); + } + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Execute a tool with given parameters + * @param {string} name - Tool name + * @param {Object} params - Tool parameters + * @returns {Promise<{success: boolean, result?: any, error?: string}>} + */ +export async function executeTool(name, params = {}) { + const tool = tools.get(name); + + if (!tool) { + return { + success: false, + error: `Tool not found: ${name}` + }; + } + + // Validate parameters + const validation = validateParams(tool, params); + if (!validation.valid) { + return { + success: false, + error: `Parameter validation failed: ${validation.errors.join(', ')}` + }; + } + + // Execute tool + try { + const result = await tool.execute(params); + return { + success: true, + result + }; + } catch (error) { + return { + success: false, + error: error.message || 'Tool execution failed' + }; + } +} + +/** + * Get tool schema in MCP-compatible format + * @param {string} name - Tool name + * @returns {Object|null} - Tool schema or null + */ +export function getToolSchema(name) { + const tool = tools.get(name); + if (!tool) return null; + + return { + name: tool.name, + description: tool.description, + inputSchema: { + type: 'object', + properties: tool.parameters?.properties || {}, + required: tool.parameters?.required || [] + } + }; +} + +/** + * Get all tool schemas in MCP-compatible format + * @returns {Array} - Array of tool schemas + */ +export function getAllToolSchemas() { + return Array.from(tools.keys()).map(name => getToolSchema(name)); +} + +// Register built-in tools +registerTool(editFileTool); +registerTool(getModulesByDepthTool); +registerTool(classifyFoldersTool); +registerTool(detectChangedModulesTool); +registerTool(discoverDesignFilesTool); +registerTool(generateModuleDocsTool); +registerTool(uiGeneratePreviewTool); +registerTool(uiInstantiatePrototypesTool); +registerTool(updateModuleClaudeTool); +registerTool(convertTokensToCssTool); + +// Export for external tool registration +export { registerTool }; diff --git a/ccw/src/tools/ui-generate-preview.js b/ccw/src/tools/ui-generate-preview.js new file mode 100644 index 00000000..cfffdffe --- /dev/null +++ b/ccw/src/tools/ui-generate-preview.js @@ -0,0 +1,327 @@ +/** + * UI Generate Preview Tool + * Generate compare.html and index.html for UI prototypes + */ + +import { readdirSync, existsSync, readFileSync, writeFileSync } from 'fs'; +import { resolve, basename } from 'path'; + +/** + * Auto-detect matrix dimensions from file patterns + * Pattern: {target}-style-{s}-layout-{l}.html + */ +function detectMatrixDimensions(prototypesDir) { + const files = readdirSync(prototypesDir).filter(f => f.match(/.*-style-\d+-layout-\d+\.html$/)); + + const styles = new Set(); + const layouts = new Set(); + const targets = new Set(); + + files.forEach(file => { + const styleMatch = file.match(/-style-(\d+)-/); + const layoutMatch = file.match(/-layout-(\d+)\.html/); + const targetMatch = file.match(/^(.+)-style-/); + + if (styleMatch) styles.add(parseInt(styleMatch[1])); + if (layoutMatch) layouts.add(parseInt(layoutMatch[1])); + if (targetMatch) targets.add(targetMatch[1]); + }); + + return { + styles: Math.max(...Array.from(styles)), + layouts: Math.max(...Array.from(layouts)), + targets: Array.from(targets).sort() + }; +} + +/** + * Load template from file + */ +function loadTemplate(templatePath) { + const defaultPath = resolve( + process.env.HOME || process.env.USERPROFILE, + '.claude/workflows/_template-compare-matrix.html' + ); + + const path = templatePath || defaultPath; + + if (!existsSync(path)) { + // Return minimal fallback template + return ` + +UI Prototypes Comparison + +

UI Prototypes Matrix

+

Styles: {{style_variants}} | Layouts: {{layout_variants}}

+

Pages: {{pages_json}}

+

Generated: {{timestamp}}

+ +`; + } + + return readFileSync(path, 'utf8'); +} + +/** + * Generate compare.html from template + */ +function generateCompareHtml(template, metadata) { + const { runId, sessionId, timestamp, styles, layouts, targets } = metadata; + + const pagesJson = JSON.stringify(targets); + + return template + .replace(/\{\{run_id\}\}/g, runId) + .replace(/\{\{session_id\}\}/g, sessionId) + .replace(/\{\{timestamp\}\}/g, timestamp) + .replace(/\{\{style_variants\}\}/g, styles.toString()) + .replace(/\{\{layout_variants\}\}/g, layouts.toString()) + .replace(/\{\{pages_json\}\}/g, pagesJson); +} + +/** + * Generate index.html + */ +function generateIndexHtml(metadata) { + const { styles, layouts, targets } = metadata; + const total = styles * layouts * targets.length; + + return ` + + + + + UI Prototypes Index + + + +

UI Prototypes

+

Interactive design exploration matrix

+ +
+

📊 Interactive Comparison

+

View all prototypes side-by-side with synchronized scrolling

+ Open Comparison Matrix → +
+ +
+
+
Style Variants
+
${styles}
+
+
+
Layout Variants
+
${layouts}
+
+
+
Pages/Components
+
${targets.length}
+
+
+
Total Prototypes
+
${total}
+
+
+ +
+

Individual Prototypes

+
    +${targets.map(target => { + const items = []; + for (let s = 1; s <= styles; s++) { + for (let l = 1; l <= layouts; l++) { + const filename = `${target}-style-${s}-layout-${l}.html`; + items.push(`
  • ${filename}
  • `); + } + } + return items.join('\n'); +}).join('\n')} +
+
+ +`; +} + +/** + * Generate PREVIEW.md + */ +function generatePreviewMd(metadata) { + const { styles, layouts, targets } = metadata; + + return `# UI Prototypes Preview + +## Matrix Dimensions + +- **Style Variants**: ${styles} +- **Layout Variants**: ${layouts} +- **Pages/Components**: ${targets.join(', ')} +- **Total Prototypes**: ${styles * layouts * targets.length} + +## Quick Start + +1. **Interactive Comparison**: Open \`compare.html\` for side-by-side view with synchronized scrolling +2. **Browse Index**: Open \`index.html\` for a navigable list of all prototypes +3. **Individual Files**: Access specific prototypes directly (e.g., \`${targets[0]}-style-1-layout-1.html\`) + +## File Naming Convention + +\`\`\` +{page}-style-{s}-layout-{l}.html +\`\`\` + +- **page**: Component/page name (${targets.join(', ')}) +- **s**: Style variant number (1-${styles}) +- **l**: Layout variant number (1-${layouts}) + +## Tips + +- Use compare.html for quick visual comparison across all variants +- Synchronized scrolling helps identify consistency issues +- Check responsive behavior across different layout variants +`; +} + +/** + * Main execute function + */ +async function execute(params) { + const { prototypesDir = '.', template: templatePath } = params; + + const targetPath = resolve(process.cwd(), prototypesDir); + + if (!existsSync(targetPath)) { + throw new Error(`Directory not found: ${targetPath}`); + } + + // Auto-detect matrix dimensions + const { styles, layouts, targets } = detectMatrixDimensions(targetPath); + + if (styles === 0 || layouts === 0 || targets.length === 0) { + throw new Error('No prototype files found matching pattern {target}-style-{s}-layout-{l}.html'); + } + + // Generate metadata + const metadata = { + runId: `run-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)}`, + sessionId: 'standalone', + timestamp: new Date().toISOString(), + styles, + layouts, + targets + }; + + // Load template + const template = loadTemplate(templatePath); + + // Generate files + const compareHtml = generateCompareHtml(template, metadata); + const indexHtml = generateIndexHtml(metadata); + const previewMd = generatePreviewMd(metadata); + + // Write files + writeFileSync(resolve(targetPath, 'compare.html'), compareHtml, 'utf8'); + writeFileSync(resolve(targetPath, 'index.html'), indexHtml, 'utf8'); + writeFileSync(resolve(targetPath, 'PREVIEW.md'), previewMd, 'utf8'); + + return { + success: true, + prototypes_dir: prototypesDir, + styles, + layouts, + targets, + total_prototypes: styles * layouts * targets.length, + files_generated: ['compare.html', 'index.html', 'PREVIEW.md'] + }; +} + +/** + * Tool Definition + */ +export const uiGeneratePreviewTool = { + name: 'ui_generate_preview', + description: `Generate interactive preview files for UI prototypes. +Generates: +- compare.html: Interactive matrix view with synchronized scrolling +- index.html: Navigation and statistics +- PREVIEW.md: Usage guide + +Auto-detects matrix dimensions from file pattern: {target}-style-{s}-layout-{l}.html`, + parameters: { + type: 'object', + properties: { + prototypesDir: { + type: 'string', + description: 'Prototypes directory path (default: current directory)', + default: '.' + }, + template: { + type: 'string', + description: 'Optional path to compare.html template' + } + }, + required: [] + }, + execute +}; diff --git a/ccw/src/tools/ui-instantiate-prototypes.js b/ccw/src/tools/ui-instantiate-prototypes.js new file mode 100644 index 00000000..a4fe12d2 --- /dev/null +++ b/ccw/src/tools/ui-instantiate-prototypes.js @@ -0,0 +1,301 @@ +/** + * UI Instantiate Prototypes Tool + * Create final UI prototypes from templates (Style × Layout × Page matrix) + */ + +import { readdirSync, existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'fs'; +import { resolve, join, basename } from 'path'; + +/** + * Auto-detect pages from templates directory + */ +function autoDetectPages(templatesDir) { + if (!existsSync(templatesDir)) { + return []; + } + + const files = readdirSync(templatesDir).filter(f => f.match(/.*-layout-\d+\.html$/)); + const pages = new Set(); + + files.forEach(file => { + const match = file.match(/^(.+)-layout-\d+\.html$/); + if (match) pages.add(match[1]); + }); + + return Array.from(pages).sort(); +} + +/** + * Auto-detect style variants count + */ +function autoDetectStyleVariants(basePath) { + const styleDir = resolve(basePath, '..', 'style-extraction'); + + if (!existsSync(styleDir)) { + return 3; // Default + } + + const dirs = readdirSync(styleDir, { withFileTypes: true }) + .filter(d => d.isDirectory() && d.name.startsWith('style-')); + + return dirs.length > 0 ? dirs.length : 3; +} + +/** + * Auto-detect layout variants count + */ +function autoDetectLayoutVariants(templatesDir) { + if (!existsSync(templatesDir)) { + return 3; // Default + } + + const files = readdirSync(templatesDir); + const firstPage = files.find(f => f.endsWith('-layout-1.html')); + + if (!firstPage) return 3; + + const pageName = firstPage.replace(/-layout-1\.html$/, ''); + const layoutFiles = files.filter(f => f.match(new RegExp(`^${pageName}-layout-\\d+\\.html$`))); + + return layoutFiles.length > 0 ? layoutFiles.length : 3; +} + +/** + * Load CSS tokens file + */ +function loadTokensCss(styleDir, styleNum) { + const tokenPath = join(styleDir, `style-${styleNum}`, 'tokens.css'); + + if (existsSync(tokenPath)) { + return readFileSync(tokenPath, 'utf8'); + } + + return '/* No tokens.css found */'; +} + +/** + * Replace CSS placeholder in template + */ +function replaceCssPlaceholder(html, tokensCss) { + // Replace {{tokens.css}} placeholder + return html.replace(/\{\{tokens\.css\}\}/g, tokensCss); +} + +/** + * Generate prototype from template + */ +function generatePrototype(templatePath, styleDir, styleNum, outputPath) { + const templateHtml = readFileSync(templatePath, 'utf8'); + const tokensCss = loadTokensCss(styleDir, styleNum); + const finalHtml = replaceCssPlaceholder(templateHtml, tokensCss); + + writeFileSync(outputPath, finalHtml, 'utf8'); +} + +/** + * Generate implementation notes + */ +function generateImplementationNotes(page, styleNum, layoutNum) { + return `# Implementation Notes: ${page}-style-${styleNum}-layout-${layoutNum} + +## Overview +Prototype combining: +- **Page/Component**: ${page} +- **Style Variant**: ${styleNum} +- **Layout Variant**: ${layoutNum} + +## Implementation Checklist + +### 1. Style Integration +- [ ] Verify all CSS custom properties are applied correctly +- [ ] Check color palette consistency +- [ ] Validate typography settings +- [ ] Test spacing and border radius values + +### 2. Layout Verification +- [ ] Confirm component structure matches layout variant +- [ ] Test responsive behavior +- [ ] Verify flex/grid layouts +- [ ] Check alignment and spacing + +### 3. Accessibility +- [ ] Color contrast ratios (WCAG AA minimum) +- [ ] Keyboard navigation +- [ ] Screen reader compatibility +- [ ] Focus indicators + +### 4. Browser Testing +- [ ] Chrome/Edge +- [ ] Firefox +- [ ] Safari +- [ ] Mobile browsers + +## Next Steps +1. Review prototype in browser +2. Compare with design specifications +3. Implement in production codebase +4. Add interactive functionality +5. Write tests +`; +} + +/** + * Main execute function + */ +async function execute(params) { + const { + prototypesDir, + pages: pagesParam, + styleVariants: styleVariantsParam, + layoutVariants: layoutVariantsParam, + runId: runIdParam, + sessionId = 'standalone', + generatePreview = true + } = params; + + if (!prototypesDir) { + throw new Error('Parameter "prototypesDir" is required'); + } + + const basePath = resolve(process.cwd(), prototypesDir); + + if (!existsSync(basePath)) { + throw new Error(`Directory not found: ${basePath}`); + } + + const templatesDir = join(basePath, '_templates'); + const styleDir = resolve(basePath, '..', 'style-extraction'); + + // Auto-detect or use provided parameters + let pages, styleVariants, layoutVariants; + + if (pagesParam && styleVariantsParam && layoutVariantsParam) { + // Manual mode + pages = Array.isArray(pagesParam) ? pagesParam : pagesParam.split(',').map(p => p.trim()); + styleVariants = parseInt(styleVariantsParam); + layoutVariants = parseInt(layoutVariantsParam); + } else { + // Auto-detect mode + pages = autoDetectPages(templatesDir); + styleVariants = autoDetectStyleVariants(basePath); + layoutVariants = autoDetectLayoutVariants(templatesDir); + } + + if (pages.length === 0) { + throw new Error('No pages detected. Ensure _templates directory contains layout files.'); + } + + // Generate run ID + const runId = runIdParam || `run-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)}`; + + // Phase 1: Copy templates and replace CSS placeholders + const generatedFiles = []; + + for (const page of pages) { + for (let s = 1; s <= styleVariants; s++) { + for (let l = 1; l <= layoutVariants; l++) { + const templateFile = `${page}-layout-${l}.html`; + const templatePath = join(templatesDir, templateFile); + + if (!existsSync(templatePath)) { + console.warn(`Template not found: ${templateFile}, skipping...`); + continue; + } + + const outputFile = `${page}-style-${s}-layout-${l}.html`; + const outputPath = join(basePath, outputFile); + + // Generate prototype + generatePrototype(templatePath, styleDir, s, outputPath); + + // Generate implementation notes + const notesFile = `${page}-style-${s}-layout-${l}-notes.md`; + const notesPath = join(basePath, notesFile); + const notes = generateImplementationNotes(page, s, l); + writeFileSync(notesPath, notes, 'utf8'); + + generatedFiles.push(outputFile); + } + } + } + + // Phase 2: Generate preview files (optional) + const previewFiles = []; + if (generatePreview) { + // Import and execute ui_generate_preview tool + const { uiGeneratePreviewTool } = await import('./ui-generate-preview.js'); + const previewResult = await uiGeneratePreviewTool.execute({ prototypesDir: basePath }); + + if (previewResult.success) { + previewFiles.push(...previewResult.files_generated); + } + } + + return { + success: true, + run_id: runId, + session_id: sessionId, + prototypes_dir: basePath, + pages, + style_variants: styleVariants, + layout_variants: layoutVariants, + total_prototypes: generatedFiles.length, + files_generated: generatedFiles, + preview_files: previewFiles, + message: `Generated ${generatedFiles.length} prototypes (${styleVariants} styles × ${layoutVariants} layouts × ${pages.length} pages)` + }; +} + +/** + * Tool Definition + */ +export const uiInstantiatePrototypesTool = { + name: 'ui_instantiate_prototypes', + description: `Create final UI prototypes from templates (Style × Layout × Page matrix). + +Two Modes: +1. Auto-detect (recommended): Only specify prototypesDir +2. Manual: Specify prototypesDir, pages, styleVariants, layoutVariants + +Features: +- Copies templates and replaces CSS placeholders with tokens.css +- Generates implementation notes for each prototype +- Optionally generates preview files (compare.html, index.html, PREVIEW.md)`, + parameters: { + type: 'object', + properties: { + prototypesDir: { + type: 'string', + description: 'Prototypes directory path' + }, + pages: { + type: 'string', + description: 'Comma-separated list of pages (auto-detected if not provided)' + }, + styleVariants: { + type: 'number', + description: 'Number of style variants (auto-detected if not provided)' + }, + layoutVariants: { + type: 'number', + description: 'Number of layout variants (auto-detected if not provided)' + }, + runId: { + type: 'string', + description: 'Run ID (auto-generated if not provided)' + }, + sessionId: { + type: 'string', + description: 'Session ID (default: standalone)', + default: 'standalone' + }, + generatePreview: { + type: 'boolean', + description: 'Generate preview files (default: true)', + default: true + } + }, + required: ['prototypesDir'] + }, + execute +}; diff --git a/ccw/src/tools/update-module-claude.js b/ccw/src/tools/update-module-claude.js new file mode 100644 index 00000000..bb9c07ec --- /dev/null +++ b/ccw/src/tools/update-module-claude.js @@ -0,0 +1,328 @@ +/** + * Update Module CLAUDE.md Tool + * Generate/update CLAUDE.md module documentation using CLI tools + */ + +import { readdirSync, statSync, existsSync, readFileSync } from 'fs'; +import { join, resolve, basename, extname } from 'path'; +import { execSync } from 'child_process'; + +// Directories to exclude +const EXCLUDE_DIRS = [ + '.git', '__pycache__', 'node_modules', '.venv', 'venv', 'env', + 'dist', 'build', '.cache', '.pytest_cache', '.mypy_cache', + 'coverage', '.nyc_output', 'logs', 'tmp', 'temp' +]; + +// Default models for each tool +const DEFAULT_MODELS = { + gemini: 'gemini-2.5-flash', + qwen: 'coder-model', + codex: 'gpt5-codex' +}; + +/** + * Count files in directory + */ +function countFiles(dirPath) { + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + return entries.filter(e => e.isFile() && !e.name.startsWith('.')).length; + } catch (e) { + return 0; + } +} + +/** + * Scan directory structure + */ +function scanDirectoryStructure(targetPath, strategy) { + const lines = []; + const dirName = basename(targetPath); + + let totalFiles = 0; + let totalDirs = 0; + + function countRecursive(dir) { + try { + const entries = readdirSync(dir, { withFileTypes: true }); + entries.forEach(e => { + if (e.name.startsWith('.') || EXCLUDE_DIRS.includes(e.name)) return; + if (e.isFile()) totalFiles++; + else if (e.isDirectory()) { + totalDirs++; + countRecursive(join(dir, e.name)); + } + }); + } catch (e) { + // Ignore + } + } + + countRecursive(targetPath); + + lines.push(`Directory: ${dirName}`); + lines.push(`Total files: ${totalFiles}`); + lines.push(`Total directories: ${totalDirs}`); + lines.push(''); + + if (strategy === 'multi-layer') { + lines.push('Subdirectories with files:'); + // List subdirectories with file counts + function listSubdirs(dir, prefix = '') { + try { + const entries = readdirSync(dir, { withFileTypes: true }); + entries.forEach(e => { + if (!e.isDirectory() || e.name.startsWith('.') || EXCLUDE_DIRS.includes(e.name)) return; + const subPath = join(dir, e.name); + const fileCount = countFiles(subPath); + if (fileCount > 0) { + const relPath = subPath.replace(targetPath, '').replace(/^[/\\]/, ''); + lines.push(` - ${relPath}/ (${fileCount} files)`); + } + listSubdirs(subPath, prefix + ' '); + }); + } catch (e) { + // Ignore + } + } + listSubdirs(targetPath); + } else { + lines.push('Direct subdirectories:'); + try { + const entries = readdirSync(targetPath, { withFileTypes: true }); + entries.forEach(e => { + if (!e.isDirectory() || e.name.startsWith('.') || EXCLUDE_DIRS.includes(e.name)) return; + const subPath = join(targetPath, e.name); + const fileCount = countFiles(subPath); + const hasClaude = existsSync(join(subPath, 'CLAUDE.md')) ? ' [has CLAUDE.md]' : ''; + lines.push(` - ${e.name}/ (${fileCount} files)${hasClaude}`); + }); + } catch (e) { + // Ignore + } + } + + // Count file types in current directory + lines.push(''); + lines.push('Current directory files:'); + try { + const entries = readdirSync(targetPath, { withFileTypes: true }); + const codeExts = ['.ts', '.tsx', '.js', '.jsx', '.py', '.sh']; + const configExts = ['.json', '.yaml', '.yml', '.toml']; + + let codeCount = 0, configCount = 0, docCount = 0; + entries.forEach(e => { + if (!e.isFile()) return; + const ext = extname(e.name).toLowerCase(); + if (codeExts.includes(ext)) codeCount++; + else if (configExts.includes(ext)) configCount++; + else if (ext === '.md') docCount++; + }); + + lines.push(` - Code files: ${codeCount}`); + lines.push(` - Config files: ${configCount}`); + lines.push(` - Documentation: ${docCount}`); + } catch (e) { + // Ignore + } + + return lines.join('\n'); +} + +/** + * Load template content + */ +function loadTemplate() { + const templatePath = join( + process.env.HOME || process.env.USERPROFILE, + '.claude/workflows/cli-templates/prompts/memory/02-document-module-structure.txt' + ); + + if (existsSync(templatePath)) { + return readFileSync(templatePath, 'utf8'); + } + + return 'Create comprehensive CLAUDE.md documentation following standard structure with Purpose, Structure, Components, Dependencies, Integration, and Implementation sections.'; +} + +/** + * Build CLI command + */ +function buildCliCommand(tool, prompt, model) { + const escapedPrompt = prompt.replace(/"/g, '\\"'); + + switch (tool) { + case 'qwen': + return model === 'coder-model' + ? `qwen -p "${escapedPrompt}" --yolo` + : `qwen -p "${escapedPrompt}" -m "${model}" --yolo`; + case 'codex': + return `codex --full-auto exec "${escapedPrompt}" -m "${model}" --skip-git-repo-check -s danger-full-access`; + case 'gemini': + default: + return `gemini -p "${escapedPrompt}" -m "${model}" --yolo`; + } +} + +/** + * Main execute function + */ +async function execute(params) { + const { strategy, path: modulePath, tool = 'gemini', model } = params; + + // Validate parameters + if (!strategy) { + throw new Error('Parameter "strategy" is required. Valid: single-layer, multi-layer'); + } + + if (!['single-layer', 'multi-layer'].includes(strategy)) { + throw new Error(`Invalid strategy '${strategy}'. Valid: single-layer, multi-layer`); + } + + if (!modulePath) { + throw new Error('Parameter "path" is required'); + } + + const targetPath = resolve(process.cwd(), modulePath); + + if (!existsSync(targetPath)) { + throw new Error(`Directory not found: ${targetPath}`); + } + + if (!statSync(targetPath).isDirectory()) { + throw new Error(`Not a directory: ${targetPath}`); + } + + // Check if directory has files + const fileCount = countFiles(targetPath); + if (fileCount === 0) { + return { + success: false, + message: `Skipping '${modulePath}' - no files found`, + skipped: true + }; + } + + // Set model + const actualModel = model || DEFAULT_MODELS[tool] || DEFAULT_MODELS.gemini; + + // Load template + const templateContent = loadTemplate(); + + // Scan directory structure + const structureInfo = scanDirectoryStructure(targetPath, strategy); + + // Build prompt based on strategy + let prompt; + if (strategy === 'multi-layer') { + prompt = `Directory Structure Analysis: +${structureInfo} + +Read: @**/* + +Generate CLAUDE.md files: +- Primary: ./CLAUDE.md (current directory) +- Additional: CLAUDE.md in each subdirectory containing files + +Template Guidelines: +${templateContent} + +Instructions: +- Work bottom-up: deepest directories first +- Parent directories reference children +- Each CLAUDE.md file must be in its respective directory +- Follow the template guidelines above for consistent structure`; + } else { + prompt = `Directory Structure Analysis: +${structureInfo} + +Read: @*/CLAUDE.md @*.ts @*.tsx @*.js @*.jsx @*.py @*.sh @*.md @*.json @*.yaml @*.yml + +Generate single file: ./CLAUDE.md + +Template Guidelines: +${templateContent} + +Instructions: +- Create exactly one CLAUDE.md file in the current directory +- Reference child CLAUDE.md files, do not duplicate their content +- Follow the template guidelines above for consistent structure`; + } + + // Build and execute command + const command = buildCliCommand(tool, prompt, actualModel); + + try { + const startTime = Date.now(); + + execSync(command, { + cwd: targetPath, + encoding: 'utf8', + stdio: 'inherit', + timeout: 300000 // 5 minutes + }); + + const duration = Math.round((Date.now() - startTime) / 1000); + + return { + success: true, + strategy, + path: modulePath, + tool, + model: actualModel, + file_count: fileCount, + duration_seconds: duration, + message: `CLAUDE.md updated successfully in ${duration}s` + }; + } catch (error) { + return { + success: false, + strategy, + path: modulePath, + tool, + model: actualModel, + error: error.message + }; + } +} + +/** + * Tool Definition + */ +export const updateModuleClaudeTool = { + name: 'update_module_claude', + description: `Generate/update CLAUDE.md module documentation using CLI tools. + +Strategies: +- single-layer: Read current dir code + child CLAUDE.md, generate ./CLAUDE.md +- multi-layer: Read all files, generate CLAUDE.md for each directory + +Tools: gemini (default), qwen, codex`, + parameters: { + type: 'object', + properties: { + strategy: { + type: 'string', + enum: ['single-layer', 'multi-layer'], + description: 'Generation strategy' + }, + path: { + type: 'string', + description: 'Module directory path' + }, + tool: { + type: 'string', + enum: ['gemini', 'qwen', 'codex'], + description: 'CLI tool to use (default: gemini)', + default: 'gemini' + }, + model: { + type: 'string', + description: 'Model name (optional, uses tool defaults)' + } + }, + required: ['strategy', 'path'] + }, + execute +};