mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
517
ccw/src/core/routes/commands-routes.ts
Normal file
517
ccw/src/core/routes/commands-routes.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<string>();
|
||||
|
||||
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<CommandOperationResult> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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<boolean>
|
||||
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<boolean>
|
||||
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 },
|
||||
|
||||
@@ -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<http.Ser
|
||||
if (await handleSkillsRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Commands routes (/api/commands*)
|
||||
if (pathname.startsWith('/api/commands')) {
|
||||
if (await handleCommandsRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Queue routes (/api/queue*) - top-level queue API
|
||||
if (pathname.startsWith('/api/queue')) {
|
||||
if (await handleIssueRoutes(routeContext)) return;
|
||||
|
||||
193
ccw/src/templates/dashboard-css/37-commands.css
Normal file
193
ccw/src/templates/dashboard-css/37-commands.css
Normal file
@@ -0,0 +1,193 @@
|
||||
/* ==========================================
|
||||
COMMANDS MANAGER STYLES
|
||||
========================================== */
|
||||
|
||||
/* Commands Manager */
|
||||
.commands-manager {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.commands-manager.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Commands Header */
|
||||
.commands-header {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Commands Stats */
|
||||
.commands-stats {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Accordion Groups */
|
||||
.commands-accordion {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.accordion-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.accordion-header:active {
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
animation: expandAccordion 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes expandAccordion {
|
||||
from {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
max-height: 2000px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Commands Grid */
|
||||
.commands-grid {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Command Card */
|
||||
.command-card {
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.command-card:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.command-toggle-switch {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.command-toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.command-toggle-slider {
|
||||
position: relative;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.command-toggle-slider::before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background-color: white;
|
||||
transition: 0.2s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.command-toggle-switch input:checked + .command-toggle-slider::before {
|
||||
transform: translate(20px, -50%);
|
||||
}
|
||||
|
||||
.command-toggle-switch input:disabled + .command-toggle-slider {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Disabled Command State */
|
||||
.command-card.opacity-60 {
|
||||
opacity: 0.6;
|
||||
filter: grayscale(0.3);
|
||||
}
|
||||
|
||||
.command-card.opacity-60:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Line clamp utility for card descriptions */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.commands-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.commands-stats .grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Badge styles for groups */
|
||||
.command-card .badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Hover effects */
|
||||
.accordion-header:hover {
|
||||
background-color: hsl(var(--hover));
|
||||
}
|
||||
|
||||
/* Active state for toggle button */
|
||||
.commands-header button.bg-primary {
|
||||
background-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.commands-header button.bg-muted {
|
||||
background-color: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
.command-toggle-slider,
|
||||
.command-toggle-slider::before {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Icon animations */
|
||||
.accordion-header i[data-lucide="chevron-down"],
|
||||
.accordion-header i[data-lucide="chevron-right"] {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* Focus states for accessibility */
|
||||
.command-toggle-switch input:focus + .command-toggle-slider {
|
||||
box-shadow: 0 0 0 2px hsl(var(--ring));
|
||||
}
|
||||
|
||||
/* Tooltip for disabled date */
|
||||
.command-card .text-muted-foreground\/70 {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -143,6 +143,8 @@ function initNavigation() {
|
||||
renderSkillsManager();
|
||||
} else if (currentView === 'rules-manager') {
|
||||
renderRulesManager();
|
||||
} else if (currentView === 'commands-manager') {
|
||||
renderCommandsManager();
|
||||
} else if (currentView === 'claude-manager') {
|
||||
renderClaudeManager();
|
||||
// Register destroy function for claude-manager view
|
||||
@@ -223,6 +225,8 @@ function updateContentTitle() {
|
||||
titleEl.textContent = t('title.skillsManager');
|
||||
} else if (currentView === 'rules-manager') {
|
||||
titleEl.textContent = t('title.rulesManager');
|
||||
} else if (currentView === 'commands-manager') {
|
||||
titleEl.textContent = t('title.commandsManager') || 'Commands Manager';
|
||||
} else if (currentView === 'claude-manager') {
|
||||
titleEl.textContent = t('title.claudeManager');
|
||||
} else if (currentView === 'graph-explorer') {
|
||||
|
||||
362
ccw/src/templates/dashboard-js/views/commands-manager.js
Normal file
362
ccw/src/templates/dashboard-js/views/commands-manager.js
Normal file
@@ -0,0 +1,362 @@
|
||||
// Commands Manager View
|
||||
// Manages Claude Code commands (.claude/commands/)
|
||||
|
||||
// ========== Commands State ==========
|
||||
var commandsData = {
|
||||
groups: {}, // Organized by group name: { cli: [...], workflow: [...], memory: [...], task: [...], issue: [...] }
|
||||
allCommands: []
|
||||
};
|
||||
var expandedGroups = {
|
||||
cli: true,
|
||||
workflow: true,
|
||||
memory: true,
|
||||
task: true,
|
||||
issue: true
|
||||
};
|
||||
var showDisabledCommands = false;
|
||||
var commandsLoading = false;
|
||||
|
||||
// ========== Main Render Function ==========
|
||||
async function renderCommandsManager() {
|
||||
const container = document.getElementById('mainContent');
|
||||
if (!container) return;
|
||||
|
||||
// Hide stats grid and search
|
||||
const statsGrid = document.getElementById('statsGrid');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (statsGrid) statsGrid.style.display = 'none';
|
||||
if (searchInput) searchInput.parentElement.style.display = 'none';
|
||||
|
||||
// Show loading state
|
||||
container.innerHTML = '<div class="commands-manager loading">' +
|
||||
'<div class="loading-spinner"><i data-lucide="loader-2" class="w-8 h-8 animate-spin"></i></div>' +
|
||||
'<p>' + t('common.loading') + '</p>' +
|
||||
'</div>';
|
||||
|
||||
// Load 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 = `
|
||||
<div class="commands-manager">
|
||||
<!-- Header -->
|
||||
<div class="commands-header mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="terminal" class="w-5 h-5 text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-foreground">${t('commands.title') || 'Commands Manager'}</h2>
|
||||
<p class="text-sm text-muted-foreground">${t('commands.description') || 'Enable/disable CCW commands'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="px-4 py-2 text-sm ${showDisabledCommands ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'} rounded-lg hover:opacity-90 transition-opacity flex items-center gap-2"
|
||||
onclick="toggleShowDisabledCommands()">
|
||||
<i data-lucide="${showDisabledCommands ? 'eye' : 'eye-off'}" class="w-4 h-4"></i>
|
||||
${showDisabledCommands ? (t('commands.hideDisabled') || 'Hide Disabled') : (t('commands.showDisabled') || 'Show Disabled')} (${totalDisabled})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="commands-stats mb-6">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="bg-card border border-border rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-foreground">${commandsData.allCommands.length}</div>
|
||||
<div class="text-sm text-muted-foreground">${t('commands.totalCommands') || 'Total Commands'}</div>
|
||||
</div>
|
||||
<div class="bg-card border border-border rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-success">${totalEnabled}</div>
|
||||
<div class="text-sm text-muted-foreground">${t('commands.enabledCommands') || 'Enabled'}</div>
|
||||
</div>
|
||||
<div class="bg-card border border-border rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-muted-foreground">${totalDisabled}</div>
|
||||
<div class="text-sm text-muted-foreground">${t('commands.disabledCommands') || 'Disabled'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Accordion Groups -->
|
||||
<div class="commands-accordion">
|
||||
${groupNames.map(groupName => {
|
||||
const commands = groups[groupName] || [];
|
||||
if (commands.length === 0) return '';
|
||||
return renderAccordionGroup(groupName, commands);
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 `
|
||||
<div class="accordion-group mb-4">
|
||||
<!-- Group Header -->
|
||||
<div class="accordion-header flex items-center justify-between px-4 py-3 bg-card border border-border rounded-lg cursor-pointer hover:bg-hover transition-colors"
|
||||
onclick="toggleAccordionGroup('${groupName}')">
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="${isExpanded ? 'chevron-down' : 'chevron-right'}" class="w-5 h-5 text-muted-foreground transition-transform"></i>
|
||||
<div class="w-8 h-8 ${colorClass} rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="${icon}" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-foreground capitalize">${groupName}</h3>
|
||||
<p class="text-xs text-muted-foreground">${enabledCommands.length}/${commands.length} enabled</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-1 bg-muted rounded-full text-muted-foreground">${commands.length}</span>
|
||||
</div>
|
||||
|
||||
<!-- Group Content (Cards Grid) -->
|
||||
${isExpanded ? `
|
||||
<div class="accordion-content mt-3">
|
||||
<div class="commands-grid grid gap-3" style="grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));">
|
||||
${visibleCommands.map(cmd => renderCommandCard(cmd)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCommandCard(command) {
|
||||
const isDisabled = !command.enabled;
|
||||
const cardOpacity = isDisabled ? 'opacity-60' : '';
|
||||
|
||||
return `
|
||||
<div class="command-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all ${cardOpacity}">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-semibold text-foreground truncate">${escapeHtml(command.name)}</h4>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full ${getGroupBadgeClass(command.group)} inline-block mt-1">
|
||||
${command.group || 'other'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-2 flex-shrink-0">
|
||||
<label class="command-toggle-switch relative inline-block w-11 h-6 cursor-pointer">
|
||||
<input type="checkbox"
|
||||
class="sr-only"
|
||||
${command.enabled ? 'checked' : ''}
|
||||
onchange="toggleCommandEnabled('${escapeHtml(command.name)}', ${command.enabled})"
|
||||
data-command-toggle="${escapeHtml(command.name)}">
|
||||
<span class="command-toggle-slider absolute inset-0 rounded-full transition-all duration-200 ${command.enabled ? 'bg-success' : 'bg-muted'}"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted-foreground mb-3 line-clamp-2">${escapeHtml(command.description || t('commands.noDescription') || 'No description available')}</p>
|
||||
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center gap-1">
|
||||
<i data-lucide="folder" class="w-3 h-3"></i>
|
||||
${command.scope || 'project'}
|
||||
</span>
|
||||
${command.triggers && command.triggers.length > 0 ? `
|
||||
<span class="flex items-center gap-1">
|
||||
<i data-lucide="zap" class="w-3 h-3"></i>
|
||||
${command.triggers.length} trigger${command.triggers.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
${isDisabled && command.disabledAt ? `
|
||||
<span class="text-xs text-muted-foreground/70">
|
||||
${t('commands.disabledAt') || 'Disabled'}: ${formatDisabledDate(command.disabledAt)}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -672,6 +672,11 @@
|
||||
<span class="nav-text flex-1" data-i18n="nav.rules">Rules</span>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeRules">0</span>
|
||||
</li>
|
||||
<li class="nav-item flex items-center gap-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="commands-manager" data-tooltip="Commands Management">
|
||||
<i data-lucide="terminal" class="nav-icon"></i>
|
||||
<span class="nav-text flex-1" data-i18n="nav.commands">Commands</span>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeCommands">0</span>
|
||||
</li>
|
||||
<li class="nav-item flex items-center gap-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="claude-manager" data-tooltip="CLAUDE.md Manager">
|
||||
<i data-lucide="file-code" class="nav-icon"></i>
|
||||
<span class="nav-text flex-1" data-i18n="nav.claudeManager">CLAUDE.md</span>
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface CommandMetadata {
|
||||
argumentHint: string;
|
||||
allowedTools: string[];
|
||||
filePath: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export interface CommandSummary {
|
||||
@@ -103,6 +104,7 @@ export class CommandRegistry {
|
||||
.join(','); // Keep as comma-separated for now, will convert in getCommand
|
||||
}
|
||||
|
||||
// Note: 'group' field is automatically extracted like other fields
|
||||
result[key] = cleanValue;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -159,7 +161,8 @@ export class CommandRegistry {
|
||||
description: header.description || '',
|
||||
argumentHint: header['argument-hint'] || '',
|
||||
allowedTools: allowedTools,
|
||||
filePath: filePath
|
||||
filePath: filePath,
|
||||
group: header.group || undefined
|
||||
};
|
||||
|
||||
// Cache result
|
||||
@@ -207,6 +210,9 @@ export class CommandRegistry {
|
||||
const files = readdirSync(this.commandDir);
|
||||
|
||||
for (const file of files) {
|
||||
// Skip _disabled directory
|
||||
if (file === '_disabled') continue;
|
||||
|
||||
if (!file.endsWith('.md')) continue;
|
||||
|
||||
const filePath = join(this.commandDir, file);
|
||||
@@ -282,6 +288,15 @@ export class CommandRegistry {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the command cache
|
||||
* Use this to invalidate cached commands after enable/disable operations
|
||||
* @returns void
|
||||
*/
|
||||
public clearCache(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user