feat(ccw): 添加 ccw tool exec 工具系统

新增工具:
- 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 <name> '{}'  # 执行工具

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
catlog22
2025-12-08 21:10:31 +08:00
parent 813bfa8f97
commit 91e4792aa9
18 changed files with 3332 additions and 73 deletions

View File

@@ -6,6 +6,7 @@ import { installCommand } from './commands/install.js';
import { uninstallCommand } from './commands/uninstall.js'; import { uninstallCommand } from './commands/uninstall.js';
import { upgradeCommand } from './commands/upgrade.js'; import { upgradeCommand } from './commands/upgrade.js';
import { listCommand } from './commands/list.js'; import { listCommand } from './commands/list.js';
import { toolCommand } from './commands/tool.js';
import { readFileSync, existsSync } from 'fs'; import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
@@ -105,5 +106,11 @@ export function run(argv) {
.description('List all installed Claude Code Workflow instances') .description('List all installed Claude Code Workflow instances')
.action(listCommand); .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); program.parse(argv);
} }

181
ccw/src/commands/tool.js Normal file
View File

@@ -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 <tool-name> \'{"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 <name> 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"}\''));
}
}

View File

@@ -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 * @param {string} sessionPath - Session directory path
* @returns {Object|null} - Plan data or null * @returns {Object|null} - Plan data or null
*/ */
function loadPlanJson(sessionPath) { 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'); const planPath = join(sessionPath, 'plan.json');
if (!existsSync(planPath)) return null;
try { // Try fix-plan.json first
const content = readFileSync(planPath, 'utf8'); if (existsSync(fixPlanPath)) {
return JSON.parse(content); try {
} catch { const content = readFileSync(fixPlanPath, 'utf8');
return null; 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('IMPL-') ||
f.startsWith('TASK-') || f.startsWith('TASK-') ||
f.startsWith('task-') || f.startsWith('task-') ||
f.startsWith('diagnosis-') ||
/^T\d+\.json$/i.test(f) /^T\d+\.json$/i.test(f)
)) ))
.map(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) { 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'); const planPath = join(sessionPath, 'plan.json');
if (existsSync(planPath)) {
const planFile = existsSync(fixPlanPath) ? fixPlanPath :
existsSync(planPath) ? planPath : null;
if (planFile) {
try { try {
const plan = JSON.parse(readFileSync(planPath, 'utf8')); const plan = JSON.parse(readFileSync(planFile, 'utf8'));
if (Array.isArray(plan.tasks)) { if (Array.isArray(plan.tasks)) {
tasks = plan.tasks.map(t => normalizeTask(t)); 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) { if (tasks.length === 0) {
try { try {
const rootTasks = readdirSync(sessionPath) const rootTasks = readdirSync(sessionPath)
.filter(f => f.endsWith('.json') && ( .filter(f => f.endsWith('.json') && (
f.startsWith('task-') || f.startsWith('task-') ||
f.startsWith('TASK-') || f.startsWith('TASK-') ||
f.startsWith('diagnosis-') ||
/^T\d+\.json$/i.test(f) /^T\d+\.json$/i.test(f)
)) ))
.map(f => { .map(f => {

View File

@@ -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 = join(CLAUDE_SETTINGS_DIR, 'settings.json');
const CLAUDE_GLOBAL_SETTINGS_LOCAL = join(CLAUDE_SETTINGS_DIR, 'settings.local.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 // WebSocket clients for real-time notifications
const wsClients = new Set(); 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 * @param {string} filePath
* @returns {Object} mcpServers object or empty object * @returns {Object} mcpServers object or empty object
*/ */
function getMcpServersFromSettings(filePath) { function getMcpServersFromFile(filePath) {
const config = safeReadJson(filePath); const config = safeReadJson(filePath);
if (!config) return {}; if (!config) return {};
return config.mcpServers || {}; return config.mcpServers || {};
} }
/** /**
* Get MCP configuration from multiple sources: * Get MCP configuration from multiple sources (per official Claude Code docs):
* 1. ~/.claude.json (project-level MCP servers) *
* 2. ~/.claude/settings.json and settings.local.json (global MCP servers) * Priority (highest to lowest):
* 3. Each workspace's .claude/settings.json and settings.local.json * 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} * @returns {Object}
*/ */
function getMcpConfig() { function getMcpConfig() {
try { 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) // 1. Read Enterprise managed MCP servers (highest priority)
if (existsSync(CLAUDE_CONFIG_PATH)) { const enterprisePath = getEnterpriseMcpPath();
const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); if (existsSync(enterprisePath)) {
const config = JSON.parse(content); const enterpriseConfig = safeReadJson(enterprisePath);
result.projects = config.projects || {}; if (enterpriseConfig?.mcpServers) {
} result.enterpriseServers = enterpriseConfig.mcpServers;
result.configSources.push({ type: 'enterprise', path: enterprisePath, count: Object.keys(enterpriseConfig.mcpServers).length });
// 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
}
};
} }
} }
// 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; return result;
} catch (error) { } catch (error) {
console.error('Error reading MCP config:', error); console.error('Error reading MCP config:', error);
return { projects: {}, globalServers: {}, error: error.message }; return { projects: {}, globalServers: {}, userServers: {}, enterpriseServers: {}, configSources: [], error: error.message };
} }
} }

View File

@@ -1,11 +1,18 @@
// MCP Manager Component // 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 ========== // ========== MCP State ==========
let mcpConfig = null; let mcpConfig = null;
let mcpAllProjects = {}; let mcpAllProjects = {};
let mcpGlobalServers = {}; let mcpGlobalServers = {};
let mcpUserServers = {};
let mcpEnterpriseServers = {};
let mcpCurrentProjectServers = {}; let mcpCurrentProjectServers = {};
let mcpConfigSources = [];
let mcpCreateMode = 'form'; // 'form' or 'json' let mcpCreateMode = 'form'; // 'form' or 'json'
// ========== Initialization ========== // ========== Initialization ==========
@@ -33,6 +40,9 @@ async function loadMcpConfig() {
mcpConfig = data; mcpConfig = data;
mcpAllProjects = data.projects || {}; mcpAllProjects = data.projects || {};
mcpGlobalServers = data.globalServers || {}; mcpGlobalServers = data.globalServers || {};
mcpUserServers = data.userServers || {};
mcpEnterpriseServers = data.enterpriseServers || {};
mcpConfigSources = data.configSources || [];
// Get current project servers // Get current project servers
const currentPath = projectPath.replace(/\//g, '\\'); const currentPath = projectPath.replace(/\//g, '\\');

View File

@@ -157,17 +157,19 @@ async function refreshWorkspace() {
// Reload data from server // Reload data from server
const data = await loadDashboardData(projectPath); const data = await loadDashboardData(projectPath);
if (data) { if (data) {
// Update stores // Clear and repopulate stores
sessionDataStore = {}; Object.keys(sessionDataStore).forEach(k => delete sessionDataStore[k]);
liteTaskDataStore = {}; Object.keys(liteTaskDataStore).forEach(k => delete liteTaskDataStore[k]);
// Populate stores // Populate stores
[...(data.activeSessions || []), ...(data.archivedSessions || [])].forEach(s => { [...(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 => { [...(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 // Update global data

View File

@@ -27,9 +27,11 @@ async function renderMcpManager() {
// Separate current project servers and available servers // Separate current project servers and available servers
const currentProjectServerNames = Object.keys(projectServers); const currentProjectServerNames = Object.keys(projectServers);
// Separate global servers and project servers that are not in current project // Separate enterprise, user, and other project servers
const globalServerEntries = Object.entries(mcpGlobalServers) const enterpriseServerEntries = Object.entries(mcpEnterpriseServers || {})
.filter(([name]) => !currentProjectServerNames.includes(name)); .filter(([name]) => !currentProjectServerNames.includes(name));
const userServerEntries = Object.entries(mcpUserServers || {})
.filter(([name]) => !currentProjectServerNames.includes(name) && !(mcpEnterpriseServers || {})[name]);
const otherProjectServers = Object.entries(allAvailableServers) const otherProjectServers = Object.entries(allAvailableServers)
.filter(([name, info]) => !currentProjectServerNames.includes(name) && !info.isGlobal); .filter(([name, info]) => !currentProjectServerNames.includes(name) && !info.isGlobal);
@@ -65,20 +67,40 @@ async function renderMcpManager() {
`} `}
</div> </div>
<!-- Global MCP Servers --> <!-- Enterprise MCP Servers (Managed) -->
${globalServerEntries.length > 0 ? ` ${enterpriseServerEntries.length > 0 ? `
<div class="mcp-section mb-6"> <div class="mcp-section mb-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-lg">🌐</span> <span class="text-lg">🏢</span>
<h3 class="text-lg font-semibold text-foreground">Global MCP Servers</h3> <h3 class="text-lg font-semibold text-foreground">Enterprise MCP Servers</h3>
<span class="text-xs px-2 py-0.5 bg-warning/20 text-warning rounded-full">Managed</span>
</div> </div>
<span class="text-sm text-muted-foreground">${globalServerEntries.length} servers from ~/.claude/settings</span> <span class="text-sm text-muted-foreground">${enterpriseServerEntries.length} servers (read-only)</span>
</div> </div>
<div class="mcp-server-grid grid gap-3"> <div class="mcp-server-grid grid gap-3">
${globalServerEntries.map(([serverName, serverConfig]) => { ${enterpriseServerEntries.map(([serverName, serverConfig]) => {
return renderGlobalServerCard(serverName, serverConfig); return renderEnterpriseServerCard(serverName, serverConfig);
}).join('')}
</div>
</div>
` : ''}
<!-- User MCP Servers -->
${userServerEntries.length > 0 ? `
<div class="mcp-section mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<span class="text-lg">👤</span>
<h3 class="text-lg font-semibold text-foreground">User MCP Servers</h3>
</div>
<span class="text-sm text-muted-foreground">${userServerEntries.length} servers from ~/.claude.json</span>
</div>
<div class="mcp-server-grid grid gap-3">
${userServerEntries.map(([serverName, serverConfig]) => {
return renderGlobalServerCard(serverName, serverConfig, 'user');
}).join('')} }).join('')}
</div> </div>
</div> </div>
@@ -263,18 +285,19 @@ function renderAvailableServerCard(serverName, serverInfo) {
`; `;
} }
function renderGlobalServerCard(serverName, serverConfig) { function renderGlobalServerCard(serverName, serverConfig, source = 'user') {
const command = serverConfig.command || 'N/A'; const command = serverConfig.command || serverConfig.url || 'N/A';
const args = serverConfig.args || []; const args = serverConfig.args || [];
const hasEnv = serverConfig.env && Object.keys(serverConfig.env).length > 0; const hasEnv = serverConfig.env && Object.keys(serverConfig.env).length > 0;
const serverType = serverConfig.type || 'stdio';
return ` return `
<div class="mcp-server-card mcp-server-global bg-card border border-primary/30 rounded-lg p-4 hover:shadow-md transition-all"> <div class="mcp-server-card mcp-server-global bg-card border border-primary/30 rounded-lg p-4 hover:shadow-md transition-all">
<div class="flex items-start justify-between mb-3"> <div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-xl">🌐</span> <span class="text-xl">👤</span>
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4> <h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
<span class="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full">Global</span> <span class="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full">User</span>
</div> </div>
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity" <button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
data-server-name="${escapeHtml(serverName)}" data-server-name="${escapeHtml(serverName)}"
@@ -286,7 +309,7 @@ function renderGlobalServerCard(serverName, serverConfig) {
<div class="mcp-server-details text-sm space-y-1"> <div class="mcp-server-details text-sm space-y-1">
<div class="flex items-center gap-2 text-muted-foreground"> <div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">cmd</span> <span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${serverType === 'stdio' ? 'cmd' : 'url'}</span>
<span class="truncate" title="${escapeHtml(command)}">${escapeHtml(command)}</span> <span class="truncate" title="${escapeHtml(command)}">${escapeHtml(command)}</span>
</div> </div>
${args.length > 0 ? ` ${args.length > 0 ? `
@@ -302,7 +325,52 @@ function renderGlobalServerCard(serverName, serverConfig) {
</div> </div>
` : ''} ` : ''}
<div class="flex items-center gap-2 text-muted-foreground mt-1"> <div class="flex items-center gap-2 text-muted-foreground mt-1">
<span class="text-xs italic">Available to all projects from ~/.claude/settings</span> <span class="text-xs italic">Available to all projects from ~/.claude.json</span>
</div>
</div>
</div>
`;
}
function renderEnterpriseServerCard(serverName, serverConfig) {
const command = serverConfig.command || serverConfig.url || 'N/A';
const args = serverConfig.args || [];
const hasEnv = serverConfig.env && Object.keys(serverConfig.env).length > 0;
const serverType = serverConfig.type || 'stdio';
return `
<div class="mcp-server-card mcp-server-enterprise bg-card border border-warning/30 rounded-lg p-4 hover:shadow-md transition-all">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span class="text-xl">🏢</span>
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
<span class="text-xs px-2 py-0.5 bg-warning/20 text-warning rounded-full">Enterprise</span>
<span class="text-xs text-muted-foreground">🔒</span>
</div>
<span class="px-3 py-1 text-xs bg-muted text-muted-foreground rounded cursor-not-allowed">
Read-only
</span>
</div>
<div class="mcp-server-details text-sm space-y-1">
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${serverType === 'stdio' ? 'cmd' : 'url'}</span>
<span class="truncate" title="${escapeHtml(command)}">${escapeHtml(command)}</span>
</div>
${args.length > 0 ? `
<div class="flex items-start gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">args</span>
<span class="text-xs font-mono truncate" title="${escapeHtml(args.join(' '))}">${escapeHtml(args.slice(0, 3).join(' '))}${args.length > 3 ? '...' : ''}</span>
</div>
` : ''}
${hasEnv ? `
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">env</span>
<span class="text-xs">${Object.keys(serverConfig.env).length} variables</span>
</div>
` : ''}
<div class="flex items-center gap-2 text-muted-foreground mt-1">
<span class="text-xs italic">Managed by organization (highest priority)</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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
};

238
ccw/src/tools/edit-file.js Normal file
View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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
};

176
ccw/src/tools/index.js Normal file
View File

@@ -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<Object>} - 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<Object>} - 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 };

View File

@@ -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 `<!DOCTYPE html>
<html>
<head><title>UI Prototypes Comparison</title></head>
<body>
<h1>UI Prototypes Matrix</h1>
<p>Styles: {{style_variants}} | Layouts: {{layout_variants}}</p>
<p>Pages: {{pages_json}}</p>
<p>Generated: {{timestamp}}</p>
</body>
</html>`;
}
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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UI Prototypes Index</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px;
background: #f5f5f5;
}
h1 { margin-bottom: 10px; color: #333; }
.subtitle { color: #666; margin-bottom: 30px; }
.cta {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.cta h2 { margin-bottom: 10px; }
.cta a {
display: inline-block;
background: white;
color: #667eea;
padding: 10px 20px;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
margin-top: 10px;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.stat {
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-label { font-size: 0.85em; color: #666; margin-bottom: 5px; }
.stat-value { font-size: 1.5em; font-weight: bold; color: #333; }
.files {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.files h2 { margin-bottom: 15px; color: #333; }
.file-list { list-style: none; }
.file-list li {
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.file-list li:last-child { border-bottom: none; }
.file-list a {
color: #667eea;
text-decoration: none;
}
.file-list a:hover { text-decoration: underline; }
</style>
</head>
<body>
<h1>UI Prototypes</h1>
<p class="subtitle">Interactive design exploration matrix</p>
<div class="cta">
<h2>📊 Interactive Comparison</h2>
<p>View all prototypes side-by-side with synchronized scrolling</p>
<a href="compare.html">Open Comparison Matrix →</a>
</div>
<div class="stats">
<div class="stat">
<div class="stat-label">Style Variants</div>
<div class="stat-value">${styles}</div>
</div>
<div class="stat">
<div class="stat-label">Layout Variants</div>
<div class="stat-value">${layouts}</div>
</div>
<div class="stat">
<div class="stat-label">Pages/Components</div>
<div class="stat-value">${targets.length}</div>
</div>
<div class="stat">
<div class="stat-label">Total Prototypes</div>
<div class="stat-value">${total}</div>
</div>
</div>
<div class="files">
<h2>Individual Prototypes</h2>
<ul class="file-list">
${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(` <li><a href="${filename}">${filename}</a></li>`);
}
}
return items.join('\n');
}).join('\n')}
</ul>
</div>
</body>
</html>`;
}
/**
* 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
};

View File

@@ -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
};

View File

@@ -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
};