From 4c78f53bcc76c25d1f6dc5ec00f5f5f75ac5c0fa Mon Sep 17 00:00:00 2001 From: catlog22 Date: Wed, 28 Jan 2026 08:26:37 +0800 Subject: [PATCH] feat: add commands management feature with API endpoints and UI integration - Implemented commands routes for listing, enabling, and disabling commands. - Created commands manager view with accordion groups for better organization. - Added loading states and confirmation dialogs for enabling/disabling commands. - Enhanced error handling and user feedback for command operations. - Introduced CSS styles for commands manager UI components. - Updated navigation to include commands manager link. - Refactored existing code for better maintainability and clarity. --- .claude/commands/cli/cli-init.md | 1 + .claude/commands/workflow/plan.md | 1 + ccw/src/core/dashboard-generator.ts | 4 +- ccw/src/core/routes/commands-routes.ts | 517 ++++++++++++++++++ ccw/src/core/routes/nav-status-routes.ts | 96 +++- ccw/src/core/server.ts | 6 + .../templates/dashboard-css/37-commands.css | 193 +++++++ .../dashboard-js/components/navigation.js | 4 + .../dashboard-js/views/commands-manager.js | 362 ++++++++++++ ccw/src/templates/dashboard.html | 5 + ccw/src/tools/command-registry.ts | 17 +- 11 files changed, 1203 insertions(+), 3 deletions(-) create mode 100644 ccw/src/core/routes/commands-routes.ts create mode 100644 ccw/src/templates/dashboard-css/37-commands.css create mode 100644 ccw/src/templates/dashboard-js/views/commands-manager.js diff --git a/.claude/commands/cli/cli-init.md b/.claude/commands/cli/cli-init.md index 3d1885d1..506b9f40 100644 --- a/.claude/commands/cli/cli-init.md +++ b/.claude/commands/cli/cli-init.md @@ -3,6 +3,7 @@ name: cli-init description: Generate .gemini/ and .qwen/ config directories with settings.json and ignore files based on workspace technology detection argument-hint: "[--tool gemini|qwen|all] [--output path] [--preview]" allowed-tools: Bash(*), Read(*), Write(*), Glob(*) +group: cli --- # CLI Initialization Command (/cli:cli-init) diff --git a/.claude/commands/workflow/plan.md b/.claude/commands/workflow/plan.md index a598c8e4..6f5ac020 100644 --- a/.claude/commands/workflow/plan.md +++ b/.claude/commands/workflow/plan.md @@ -3,6 +3,7 @@ name: plan description: 5-phase planning workflow with action-planning-agent task generation, outputs IMPL_PLAN.md and task JSONs argument-hint: "[-y|--yes] \"text description\"|file.md" allowed-tools: SlashCommand(*), TodoWrite(*), Read(*), Bash(*) +group: workflow --- ## Auto Mode diff --git a/ccw/src/core/dashboard-generator.ts b/ccw/src/core/dashboard-generator.ts index f1e08531..e3f954d9 100644 --- a/ccw/src/core/dashboard-generator.ts +++ b/ccw/src/core/dashboard-generator.ts @@ -102,7 +102,8 @@ const MODULE_CSS_FILES = [ '32-issue-manager.css', '33-cli-stream-viewer.css', '34-discovery.css', - '36-loop-monitor.css' + '36-loop-monitor.css', + '37-commands.css' ]; const MODULE_FILES = [ @@ -151,6 +152,7 @@ const MODULE_FILES = [ 'views/prompt-history.js', 'views/skills-manager.js', 'views/rules-manager.js', + 'views/commands-manager.js', 'views/claude-manager.js', 'views/api-settings.js', 'views/issue-manager.js', diff --git a/ccw/src/core/routes/commands-routes.ts b/ccw/src/core/routes/commands-routes.ts new file mode 100644 index 00000000..8d4f35c1 --- /dev/null +++ b/ccw/src/core/routes/commands-routes.ts @@ -0,0 +1,517 @@ +/** + * Commands Routes Module + * Handles all Commands-related API endpoints + * + * API Endpoints: + * - GET /api/commands - List all commands with groups + * - POST /api/commands/:name/toggle - Enable/disable single command + * - POST /api/commands/group/:groupName/toggle - Batch toggle commands by group + */ +import { existsSync, readdirSync, readFileSync, mkdirSync, cpSync, rmSync, renameSync, statSync } from 'fs'; +import { join, relative, dirname, basename } from 'path'; +import { homedir } from 'os'; +import { validatePath as validateAllowedPath } from '../../utils/path-validator.js'; +import type { RouteContext } from './types.js'; + +// ========== Types ========== + +type CommandLocation = 'project' | 'user'; + +interface CommandMetadata { + name: string; + description: string; + group: string; + argumentHint?: string; + allowedTools?: string[]; +} + +interface CommandInfo { + name: string; + description: string; + group: string; + enabled: boolean; + location: CommandLocation; + path: string; + relativePath: string; // Path relative to commands root (e.g., 'workflow/plan.md') + argumentHint?: string; + allowedTools?: string[]; +} + +interface CommandsConfig { + projectCommands: CommandInfo[]; + userCommands: CommandInfo[]; + groups: string[]; +} + +interface CommandOperationResult { + success: boolean; + message: string; + commandName?: string; + location?: CommandLocation; + status?: number; +} + +// ========== Helper Functions ========== + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Get commands directory path + */ +function getCommandsDir(location: CommandLocation, projectPath: string): string { + if (location === 'project') { + return join(projectPath, '.claude', 'commands'); + } + return join(homedir(), '.claude', 'commands'); +} + +/** + * Get disabled commands directory path + */ +function getDisabledCommandsDir(location: CommandLocation, projectPath: string): string { + if (location === 'project') { + return join(projectPath, '.claude', 'commands', '_disabled'); + } + return join(homedir(), '.claude', 'commands', '_disabled'); +} + +/** + * Parse YAML frontmatter from command file + */ +function parseCommandFrontmatter(content: string): CommandMetadata { + const result: CommandMetadata = { + name: '', + description: '', + group: 'other' // Default group + }; + + // Check for YAML frontmatter + if (content.startsWith('---')) { + const endIndex = content.indexOf('---', 3); + if (endIndex > 0) { + const frontmatter = content.substring(3, endIndex).trim(); + + // Parse frontmatter lines + const lines = frontmatter.split(/[\r\n]+/); + for (const line of lines) { + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.substring(0, colonIndex).trim().toLowerCase(); + const value = line.substring(colonIndex + 1).trim().replace(/^["']|["']$/g, ''); + + if (key === 'name') { + result.name = value; + } else if (key === 'description') { + result.description = value; + } else if (key === 'group') { + result.group = value || 'other'; + } else if (key === 'argument-hint') { + result.argumentHint = value; + } else if (key === 'allowed-tools') { + result.allowedTools = value + .replace(/^\[|\]$/g, '') + .split(',') + .map(t => t.trim()) + .filter(Boolean); + } + } + } + } + } + + return result; +} + +/** + * Infer group from command path if not specified in frontmatter + */ +function inferGroupFromPath(relativePath: string, metadata: CommandMetadata): string { + // If group is specified in frontmatter, use it + if (metadata.group && metadata.group !== 'other') { + return metadata.group; + } + + // Infer from directory structure + const parts = relativePath.split(/[/\\]/); + if (parts.length > 1) { + // Use first directory as group (e.g., 'workflow', 'issue', 'memory') + return parts[0]; + } + + return 'other'; +} + +/** + * Recursively scan directory for command files + */ +function scanCommandsRecursive( + baseDir: string, + currentDir: string, + location: CommandLocation, + enabled: boolean +): CommandInfo[] { + const results: CommandInfo[] = []; + + if (!existsSync(currentDir)) { + return results; + } + + try { + const entries = readdirSync(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(currentDir, entry.name); + const relativePath = relative(baseDir, fullPath); + + if (entry.isDirectory()) { + // Skip _disabled directory when scanning enabled commands + if (entry.name === '_disabled') continue; + + // Recursively scan subdirectories + results.push(...scanCommandsRecursive(baseDir, fullPath, location, enabled)); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + try { + const content = readFileSync(fullPath, 'utf8'); + const metadata = parseCommandFrontmatter(content); + const group = inferGroupFromPath(relativePath, metadata); + + results.push({ + name: metadata.name || basename(entry.name, '.md'), + description: metadata.description, + group, + enabled, + location, + path: fullPath, + relativePath, + argumentHint: metadata.argumentHint, + allowedTools: metadata.allowedTools + }); + } catch (err) { + // Skip files that fail to read + console.error(`[Commands] Failed to read ${fullPath}:`, err); + } + } + } + } catch (err) { + console.error(`[Commands] Failed to scan directory ${currentDir}:`, err); + } + + return results; +} + +/** + * Get all commands configuration + */ +function getCommandsConfig(projectPath: string): CommandsConfig { + const result: CommandsConfig = { + projectCommands: [], + userCommands: [], + groups: [] + }; + + const groupSet = new Set(); + + try { + // Scan project commands + const projectDir = getCommandsDir('project', projectPath); + const projectDisabledDir = getDisabledCommandsDir('project', projectPath); + + // Enabled project commands + const enabledProject = scanCommandsRecursive(projectDir, projectDir, 'project', true); + result.projectCommands.push(...enabledProject); + + // Disabled project commands + if (existsSync(projectDisabledDir)) { + const disabledProject = scanCommandsRecursive(projectDisabledDir, projectDisabledDir, 'project', false); + result.projectCommands.push(...disabledProject); + } + + // Scan user commands + const userDir = getCommandsDir('user', projectPath); + const userDisabledDir = getDisabledCommandsDir('user', projectPath); + + // Enabled user commands + const enabledUser = scanCommandsRecursive(userDir, userDir, 'user', true); + result.userCommands.push(...enabledUser); + + // Disabled user commands + if (existsSync(userDisabledDir)) { + const disabledUser = scanCommandsRecursive(userDisabledDir, userDisabledDir, 'user', false); + result.userCommands.push(...disabledUser); + } + + // Collect all groups + for (const cmd of [...result.projectCommands, ...result.userCommands]) { + groupSet.add(cmd.group); + } + + result.groups = Array.from(groupSet).sort(); + } catch (error) { + console.error('[Commands] Error reading commands config:', error); + } + + return result; +} + +/** + * Move directory with fallback to copy-delete and rollback on failure + */ +function moveDirectory(source: string, target: string): void { + try { + // Ensure target parent directory exists + const targetParent = dirname(target); + if (!existsSync(targetParent)) { + mkdirSync(targetParent, { recursive: true }); + } + + // Try atomic rename first + renameSync(source, target); + } catch (error: unknown) { + const err = error as NodeJS.ErrnoException; + // If rename fails (cross-filesystem, permission issues), fallback to copy-delete + if (err.code === 'EXDEV' || err.code === 'EPERM' || err.code === 'EBUSY') { + cpSync(source, target, { recursive: true, force: true }); + try { + rmSync(source, { recursive: true, force: true }); + } catch (rmError) { + // Rollback: remove the copied target to avoid duplicates + try { + rmSync(target, { recursive: true, force: true }); + } catch { + // Ignore rollback errors + } + throw new Error(`Failed to remove source after copy: ${(rmError as Error).message}`); + } + } else { + throw error; + } + } +} + +/** + * Find command by name in commands list + */ +function findCommand( + commands: CommandInfo[], + commandName: string +): CommandInfo | undefined { + // Try exact name match first + let cmd = commands.find(c => c.name === commandName); + if (cmd) return cmd; + + // Try matching by relative path (without extension) + cmd = commands.find(c => { + const pathWithoutExt = c.relativePath.replace(/\.md$/, ''); + return pathWithoutExt === commandName; + }); + if (cmd) return cmd; + + // Try matching by filename (without extension) + cmd = commands.find(c => { + const filename = basename(c.relativePath, '.md'); + return filename === commandName; + }); + + return cmd; +} + +/** + * Toggle a command's enabled state + */ +async function toggleCommand( + commandName: string, + location: CommandLocation, + projectPath: string, + initialPath: string +): Promise { + try { + // Validate command name + if (commandName.includes('..')) { + return { success: false, message: 'Invalid command name', status: 400 }; + } + + const config = getCommandsConfig(projectPath); + const commands = location === 'project' ? config.projectCommands : config.userCommands; + const command = findCommand(commands, commandName); + + if (!command) { + return { success: false, message: 'Command not found', status: 404 }; + } + + const commandsDir = getCommandsDir(location, projectPath); + const disabledDir = getDisabledCommandsDir(location, projectPath); + + if (command.enabled) { + // Disable: move from commands to _disabled + const targetPath = join(disabledDir, command.relativePath); + + // Check if target already exists + if (existsSync(targetPath)) { + return { success: false, message: 'Command already exists in disabled directory', status: 409 }; + } + + moveDirectory(command.path, targetPath); + return { + success: true, + message: 'Command disabled', + commandName: command.name, + location + }; + } else { + // Enable: move from _disabled back to commands + // Calculate target path in enabled directory + const targetPath = join(commandsDir, command.relativePath); + + // Check if target already exists + if (existsSync(targetPath)) { + return { success: false, message: 'Command already exists in commands directory', status: 409 }; + } + + moveDirectory(command.path, targetPath); + return { + success: true, + message: 'Command enabled', + commandName: command.name, + location + }; + } + } catch (error) { + return { + success: false, + message: (error as Error).message, + status: 500 + }; + } +} + +/** + * Toggle all commands in a group + */ +async function toggleGroup( + groupName: string, + location: CommandLocation, + enable: boolean, + projectPath: string, + initialPath: string +): Promise<{ success: boolean; results: CommandOperationResult[]; message: string }> { + const config = getCommandsConfig(projectPath); + const commands = location === 'project' ? config.projectCommands : config.userCommands; + + // Filter commands by group and current state + const targetCommands = commands.filter(cmd => + cmd.group === groupName && cmd.enabled !== enable + ); + + if (targetCommands.length === 0) { + return { + success: true, + results: [], + message: `No commands to ${enable ? 'enable' : 'disable'} in group '${groupName}'` + }; + } + + const results: CommandOperationResult[] = []; + + for (const cmd of targetCommands) { + const result = await toggleCommand(cmd.name, location, projectPath, initialPath); + results.push(result); + } + + const successCount = results.filter(r => r.success).length; + const failCount = results.filter(r => !r.success).length; + + return { + success: failCount === 0, + results, + message: `${enable ? 'Enabled' : 'Disabled'} ${successCount} commands${failCount > 0 ? `, ${failCount} failed` : ''}` + }; +} + +// ========== Route Handler ========== + +/** + * Handle Commands routes + * @returns true if route was handled, false otherwise + */ +export async function handleCommandsRoutes(ctx: RouteContext): Promise { + const { pathname, url, req, res, initialPath, handlePostRequest } = ctx; + + // GET /api/commands - List all commands + if (pathname === '/api/commands' && req.method === 'GET') { + const projectPathParam = url.searchParams.get('path') || initialPath; + + try { + const validatedProjectPath = await validateAllowedPath(projectPathParam, { + mustExist: true, + allowedDirectories: [initialPath] + }); + + const config = getCommandsConfig(validatedProjectPath); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(config)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const status = message.includes('Access denied') ? 403 : 400; + console.error(`[Commands] Project path validation failed: ${message}`); + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: status === 403 ? 'Access denied' : 'Invalid path', + projectCommands: [], + userCommands: [], + groups: [] + })); + } + return true; + } + + // POST /api/commands/:name/toggle - Toggle single command + if (pathname.match(/^\/api\/commands\/[^/]+\/toggle$/) && req.method === 'POST') { + const pathParts = pathname.split('/'); + const commandName = decodeURIComponent(pathParts[3]); + + handlePostRequest(req, res, async (body) => { + if (!isRecord(body)) { + return { error: 'Invalid request body', status: 400 }; + } + + const locationValue = body.location; + const projectPathParam = typeof body.projectPath === 'string' ? body.projectPath : undefined; + + if (locationValue !== 'project' && locationValue !== 'user') { + return { error: 'Location is required (project or user)' }; + } + + const projectPath = projectPathParam || initialPath; + return toggleCommand(commandName, locationValue, projectPath, initialPath); + }); + return true; + } + + // POST /api/commands/group/:groupName/toggle - Toggle all commands in group + if (pathname.match(/^\/api\/commands\/group\/[^/]+\/toggle$/) && req.method === 'POST') { + const pathParts = pathname.split('/'); + const groupName = decodeURIComponent(pathParts[4]); + + handlePostRequest(req, res, async (body) => { + if (!isRecord(body)) { + return { error: 'Invalid request body', status: 400 }; + } + + const locationValue = body.location; + const enable = body.enable === true; + const projectPathParam = typeof body.projectPath === 'string' ? body.projectPath : undefined; + + if (locationValue !== 'project' && locationValue !== 'user') { + return { error: 'Location is required (project or user)' }; + } + + const projectPath = projectPathParam || initialPath; + return toggleGroup(groupName, locationValue, enable, projectPath, initialPath); + }); + return true; + } + + return false; +} diff --git a/ccw/src/core/routes/nav-status-routes.ts b/ccw/src/core/routes/nav-status-routes.ts index 77f14682..c03bd369 100644 --- a/ccw/src/core/routes/nav-status-routes.ts +++ b/ccw/src/core/routes/nav-status-routes.ts @@ -51,6 +51,92 @@ function countDiscoveries(projectPath: string): number { } } +/** + * Recursively count command files in a directory + */ +function countCommandsInDir(dirPath: string): { enabled: number; disabled: number } { + let enabled = 0; + let disabled = 0; + + if (!existsSync(dirPath)) { + return { enabled, disabled }; + } + + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dirPath, entry.name); + if (entry.isDirectory()) { + if (entry.name === '_disabled') { + // Count disabled commands recursively + disabled += countAllMdFiles(fullPath); + } else { + // Recursively count enabled commands + const subCounts = countCommandsInDir(fullPath); + enabled += subCounts.enabled; + disabled += subCounts.disabled; + } + } else if (entry.isFile() && entry.name.endsWith('.md')) { + enabled++; + } + } + } catch { /* ignore */ } + + return { enabled, disabled }; +} + +/** + * Count all .md files recursively (for disabled directory) + */ +function countAllMdFiles(dirPath: string): number { + let count = 0; + if (!existsSync(dirPath)) return count; + + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dirPath, entry.name); + if (entry.isDirectory()) { + count += countAllMdFiles(fullPath); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + count++; + } + } + } catch { /* ignore */ } + + return count; +} + +/** + * Count commands from project and user directories + */ +function countCommands(projectPath: string): { + project: { enabled: number; disabled: number }; + user: { enabled: number; disabled: number }; + total: number; + enabled: number; + disabled: number; +} { + // Project commands + const projectDir = join(projectPath, '.claude', 'commands'); + const projectCounts = countCommandsInDir(projectDir); + + // User commands + const userDir = join(homedir(), '.claude', 'commands'); + const userCounts = countCommandsInDir(userDir); + + const totalEnabled = projectCounts.enabled + userCounts.enabled; + const totalDisabled = projectCounts.disabled + userCounts.disabled; + + return { + project: projectCounts, + user: userCounts, + total: totalEnabled + totalDisabled, + enabled: totalEnabled, + disabled: totalDisabled + }; +} + /** * Count skills from project and user directories */ @@ -197,10 +283,11 @@ export async function handleNavStatusRoutes(ctx: RouteContext): Promise const projectPath = url.searchParams.get('path') || initialPath; // Execute all counts (synchronous file reads wrapped in Promise.resolve for consistency) - const [issues, discoveries, skills, rules, claude, hooks] = await Promise.all([ + const [issues, discoveries, skills, commands, rules, claude, hooks] = await Promise.all([ Promise.resolve(countIssues(projectPath)), Promise.resolve(countDiscoveries(projectPath)), Promise.resolve(countSkills(projectPath)), + Promise.resolve(countCommands(projectPath)), Promise.resolve(countRules(projectPath)), Promise.resolve(countClaudeFiles(projectPath)), Promise.resolve(countHooks(projectPath)) @@ -210,6 +297,13 @@ export async function handleNavStatusRoutes(ctx: RouteContext): Promise issues: { count: issues }, discoveries: { count: discoveries }, skills: { count: skills.total, project: skills.project, user: skills.user }, + commands: { + count: commands.total, + enabled: commands.enabled, + disabled: commands.disabled, + project: commands.project, + user: commands.user + }, rules: { count: rules.total, project: rules.project, user: rules.user }, claude: { count: claude }, hooks: { count: hooks.total, global: hooks.global, project: hooks.project }, diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index f401c979..a7695d23 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -18,6 +18,7 @@ import { handleGraphRoutes } from './routes/graph-routes.js'; import { handleSystemRoutes } from './routes/system-routes.js'; import { handleFilesRoutes } from './routes/files-routes.js'; import { handleSkillsRoutes } from './routes/skills-routes.js'; +import { handleCommandsRoutes } from './routes/commands-routes.js'; import { handleIssueRoutes } from './routes/issue-routes.js'; import { handleDiscoveryRoutes } from './routes/discovery-routes.js'; import { handleRulesRoutes } from './routes/rules-routes.js'; @@ -600,6 +601,11 @@ export async function startServer(options: ServerOptions = {}): Promise' + + '

' + t('common.loading') + '

' + + ''; + + // Load commands data + await loadCommandsData(); + + // Render the main view + renderCommandsView(); +} + +async function loadCommandsData() { + commandsLoading = true; + try { + const response = await fetch('/api/commands?path=' + encodeURIComponent(projectPath)); + if (!response.ok) throw new Error('Failed to load commands'); + const data = await response.json(); + + // Organize commands by group + commandsData.groups = {}; + commandsData.allCommands = data.commands || []; + + data.commands.forEach(cmd => { + const group = cmd.group || 'other'; + if (!commandsData.groups[group]) { + commandsData.groups[group] = []; + } + commandsData.groups[group].push(cmd); + }); + + // Update badge + updateCommandsBadge(); + } catch (err) { + console.error('Failed to load commands:', err); + commandsData = { groups: {}, allCommands: [] }; + } finally { + commandsLoading = false; + } +} + +function updateCommandsBadge() { + const badge = document.getElementById('badgeCommands'); + if (badge) { + const enabledCount = commandsData.allCommands.filter(cmd => cmd.enabled).length; + badge.textContent = enabledCount; + } +} + +function renderCommandsView() { + const container = document.getElementById('mainContent'); + if (!container) return; + + const groups = commandsData.groups || {}; + const groupNames = ['cli', 'workflow', 'memory', 'task', 'issue', 'other']; + const totalEnabled = commandsData.allCommands.filter(cmd => cmd.enabled).length; + const totalDisabled = commandsData.allCommands.filter(cmd => !cmd.enabled).length; + + container.innerHTML = ` +
+ +
+
+
+
+ +
+
+

${t('commands.title') || 'Commands Manager'}

+

${t('commands.description') || 'Enable/disable CCW commands'}

+
+
+ +
+
+ + +
+
+
+
${commandsData.allCommands.length}
+
${t('commands.totalCommands') || 'Total Commands'}
+
+
+
${totalEnabled}
+
${t('commands.enabledCommands') || 'Enabled'}
+
+
+
${totalDisabled}
+
${t('commands.disabledCommands') || 'Disabled'}
+
+
+
+ + +
+ ${groupNames.map(groupName => { + const commands = groups[groupName] || []; + if (commands.length === 0) return ''; + return renderAccordionGroup(groupName, commands); + }).join('')} +
+
+ `; + + // Initialize Lucide icons + if (typeof lucide !== 'undefined') lucide.createIcons(); +} + +function renderAccordionGroup(groupName, commands) { + const isExpanded = expandedGroups[groupName]; + const enabledCommands = commands.filter(cmd => cmd.enabled); + const disabledCommands = commands.filter(cmd => !cmd.enabled); + + // Filter commands based on showDisabledCommands + const visibleCommands = showDisabledCommands + ? commands + : enabledCommands; + + // Group icons + const groupIcons = { + cli: 'terminal', + workflow: 'workflow', + memory: 'brain', + task: 'clipboard-list', + issue: 'alert-circle', + other: 'folder' + }; + + // Group colors + const groupColors = { + cli: 'text-primary bg-primary/10', + workflow: 'text-success bg-success/10', + memory: 'text-indigo bg-indigo/10', + task: 'text-warning bg-warning/10', + issue: 'text-destructive bg-destructive/10', + other: 'text-muted-foreground bg-muted' + }; + + const icon = groupIcons[groupName] || 'folder'; + const colorClass = groupColors[groupName] || 'text-muted-foreground bg-muted'; + + return ` +
+ +
+
+ +
+ +
+
+

${groupName}

+

${enabledCommands.length}/${commands.length} enabled

+
+
+ ${commands.length} +
+ + + ${isExpanded ? ` +
+
+ ${visibleCommands.map(cmd => renderCommandCard(cmd)).join('')} +
+
+ ` : ''} +
+ `; +} + +function renderCommandCard(command) { + const isDisabled = !command.enabled; + const cardOpacity = isDisabled ? 'opacity-60' : ''; + + return ` +
+
+
+

${escapeHtml(command.name)}

+ + ${command.group || 'other'} + +
+
+ +
+
+ +

${escapeHtml(command.description || t('commands.noDescription') || 'No description available')}

+ +
+
+ + + ${command.scope || 'project'} + + ${command.triggers && command.triggers.length > 0 ? ` + + + ${command.triggers.length} trigger${command.triggers.length > 1 ? 's' : ''} + + ` : ''} +
+ ${isDisabled && command.disabledAt ? ` + + ${t('commands.disabledAt') || 'Disabled'}: ${formatDisabledDate(command.disabledAt)} + + ` : ''} +
+
+ `; +} + +function getGroupBadgeClass(group) { + const classes = { + cli: 'bg-primary/10 text-primary', + workflow: 'bg-success/10 text-success', + memory: 'bg-indigo/10 text-indigo', + task: 'bg-warning/10 text-warning', + issue: 'bg-destructive/10 text-destructive', + other: 'bg-muted text-muted-foreground' + }; + return classes[group] || classes.other; +} + +function toggleAccordionGroup(groupName) { + expandedGroups[groupName] = !expandedGroups[groupName]; + renderCommandsView(); +} + +function toggleShowDisabledCommands() { + showDisabledCommands = !showDisabledCommands; + renderCommandsView(); +} + +// Track loading state for command toggle operations +var toggleLoadingCommands = {}; + +async function toggleCommandEnabled(commandName, currentlyEnabled) { + // Prevent double-click + var loadingKey = commandName; + if (toggleLoadingCommands[loadingKey]) return; + + var action = currentlyEnabled ? 'disable' : 'enable'; + var confirmMessage = currentlyEnabled + ? t('commands.disableConfirm', { name: commandName }) || `Disable command "${commandName}"?` + : t('commands.enableConfirm', { name: commandName }) || `Enable command "${commandName}"?`; + + if (!confirm(confirmMessage)) { + // Reset toggle state if user cancels + const toggleInput = document.querySelector(`[data-command-toggle="${commandName}"]`); + if (toggleInput) { + toggleInput.checked = currentlyEnabled; + } + return; + } + + // Set loading state + toggleLoadingCommands[loadingKey] = true; + var toggleInput = document.querySelector('[data-command-toggle="' + commandName + '"]'); + if (toggleInput) { + toggleInput.disabled = true; + } + + try { + var response = await fetch('/api/commands/' + encodeURIComponent(commandName) + '/' + action, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectPath: projectPath }) + }); + + if (!response.ok) { + // Robust JSON parsing with fallback + var errorMessage = 'Operation failed'; + try { + var error = await response.json(); + errorMessage = error.message || errorMessage; + } catch (jsonErr) { + errorMessage = response.statusText || errorMessage; + } + throw new Error(errorMessage); + } + + // Reload commands data + await loadCommandsData(); + renderCommandsView(); + + if (window.showToast) { + var message = currentlyEnabled + ? t('commands.disableSuccess', { name: commandName }) || `Command "${commandName}" disabled` + : t('commands.enableSuccess', { name: commandName }) || `Command "${commandName}" enabled`; + showToast(message, 'success'); + } + } catch (err) { + console.error('Failed to toggle command:', err); + if (window.showToast) { + showToast(err.message || t('commands.toggleError') || 'Failed to toggle command', 'error'); + } + // Reset toggle state on error + if (toggleInput) { + toggleInput.checked = currentlyEnabled; + } + } finally { + // Clear loading state + delete toggleLoadingCommands[loadingKey]; + if (toggleInput) { + toggleInput.disabled = false; + } + } +} + +function formatDisabledDate(isoString) { + try { + const date = new Date(isoString); + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } catch { + return isoString; + } +} diff --git a/ccw/src/templates/dashboard.html b/ccw/src/templates/dashboard.html index 2e45dc36..f03733ea 100644 --- a/ccw/src/templates/dashboard.html +++ b/ccw/src/templates/dashboard.html @@ -672,6 +672,11 @@ Rules 0 +