mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37:45 +08:00
- Updated Phase 1 and Phase 2 documentation to include next phase links and data flow details. - Expanded Phase 5 documentation to include comprehensive validation and README generation steps, along with validation report structure. - Added purpose and usage context sections to various action and script templates (e.g., autonomous-action, llm-action, script-bash). - Improved commands management by simplifying the command scanning logic and enabling/disabling commands through renaming files. - Enhanced dashboard command manager to format group names and display nested groups with appropriate icons and colors. - Updated LiteLLM executor to allow model overrides during execution. - Added action reference guide and template reference sections to the skill-tuning SKILL.md for better navigation and understanding.
621 lines
19 KiB
TypeScript
621 lines
19 KiB
TypeScript
/**
|
|
* 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, renameSync } 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;
|
|
}
|
|
|
|
interface GroupDefinition {
|
|
name: string;
|
|
icon?: string;
|
|
color?: string;
|
|
}
|
|
|
|
interface CommandGroupsConfig {
|
|
groups: Record<string, GroupDefinition>; // Custom group definitions
|
|
assignments: Record<string, string>; // commandName -> groupId mapping
|
|
}
|
|
|
|
// ========== 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');
|
|
}
|
|
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Get command groups config file path
|
|
*/
|
|
function getGroupsConfigPath(location: CommandLocation, projectPath: string): string {
|
|
const baseDir = location === 'project'
|
|
? join(projectPath, '.claude')
|
|
: join(homedir(), '.claude');
|
|
return join(baseDir, 'command-groups.json');
|
|
}
|
|
|
|
/**
|
|
* Load command groups configuration
|
|
*/
|
|
function loadGroupsConfig(location: CommandLocation, projectPath: string): CommandGroupsConfig {
|
|
const configPath = getGroupsConfigPath(location, projectPath);
|
|
|
|
const defaultConfig: CommandGroupsConfig = {
|
|
groups: {},
|
|
assignments: {}
|
|
};
|
|
|
|
if (!existsSync(configPath)) {
|
|
return defaultConfig;
|
|
}
|
|
|
|
try {
|
|
const content = readFileSync(configPath, 'utf8');
|
|
const parsed = JSON.parse(content);
|
|
|
|
return {
|
|
groups: isRecord(parsed.groups) ? parsed.groups as Record<string, GroupDefinition> : {},
|
|
assignments: isRecord(parsed.assignments) ? parsed.assignments as Record<string, string> : {}
|
|
};
|
|
} catch (err) {
|
|
console.error(`[Commands] Failed to load groups config from ${configPath}:`, err);
|
|
return defaultConfig;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save command groups configuration
|
|
*/
|
|
function saveGroupsConfig(location: CommandLocation, projectPath: string, config: CommandGroupsConfig): void {
|
|
const configPath = getGroupsConfigPath(location, projectPath);
|
|
const configDir = dirname(configPath);
|
|
|
|
if (!existsSync(configDir)) {
|
|
mkdirSync(configDir, { recursive: true });
|
|
}
|
|
|
|
try {
|
|
const content = JSON.stringify(config, null, 2);
|
|
require('fs').writeFileSync(configPath, content, 'utf8');
|
|
} catch (err) {
|
|
console.error(`[Commands] Failed to save groups config to ${configPath}:`, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get group for a command (from config or inferred from path)
|
|
*/
|
|
function getCommandGroup(commandName: string, relativePath: string, location: CommandLocation, projectPath: string): string {
|
|
// First check custom assignments
|
|
const config = loadGroupsConfig(location, projectPath);
|
|
if (config.assignments[commandName]) {
|
|
return config.assignments[commandName];
|
|
}
|
|
|
|
// Fallback to path-based inference - use full directory path as group
|
|
const parts = relativePath.split(/[/\\]/);
|
|
if (parts.length > 1) {
|
|
// Use full directory path (excluding filename) as group
|
|
// e.g., 'workflow/review/code-review.md' -> 'workflow/review'
|
|
return parts.slice(0, -1).join('/');
|
|
}
|
|
|
|
return 'other';
|
|
}
|
|
|
|
/**
|
|
* Recursively scan directory for command files
|
|
*/
|
|
function scanCommandsRecursive(
|
|
baseDir: string,
|
|
currentDir: string,
|
|
location: CommandLocation,
|
|
projectPath: string
|
|
): 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);
|
|
let relativePath = relative(baseDir, fullPath);
|
|
|
|
if (entry.isDirectory()) {
|
|
// Recursively scan subdirectories
|
|
results.push(...scanCommandsRecursive(baseDir, fullPath, location, projectPath));
|
|
} else if (entry.isFile()) {
|
|
// Check for .md or .md.disabled files
|
|
const isEnabled = entry.name.endsWith('.md') && !entry.name.endsWith('.md.disabled');
|
|
const isDisabled = entry.name.endsWith('.md.disabled');
|
|
|
|
if (isEnabled || isDisabled) {
|
|
try {
|
|
const content = readFileSync(fullPath, 'utf8');
|
|
const metadata = parseCommandFrontmatter(content);
|
|
|
|
// For disabled files, remove .disabled from relativePath for consistency
|
|
if (isDisabled) {
|
|
relativePath = relativePath.replace(/\.disabled$/, '');
|
|
}
|
|
|
|
const commandName = metadata.name || basename(relativePath, '.md');
|
|
|
|
// Get group from external config (not from frontmatter)
|
|
const group = getCommandGroup(commandName, relativePath, location, projectPath);
|
|
|
|
results.push({
|
|
name: commandName,
|
|
description: metadata.description,
|
|
group,
|
|
enabled: isEnabled,
|
|
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 (includes both .md and .md.disabled)
|
|
const projectDir = getCommandsDir('project', projectPath);
|
|
result.projectCommands = scanCommandsRecursive(projectDir, projectDir, 'project', projectPath);
|
|
|
|
// Scan user commands (includes both .md and .md.disabled)
|
|
const userDir = getCommandsDir('user', projectPath);
|
|
result.userCommands = scanCommandsRecursive(userDir, userDir, 'user', projectPath);
|
|
|
|
// 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;
|
|
}
|
|
|
|
|
|
/**
|
|
* 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);
|
|
// relativePath already includes .md extension (e.g., 'workflow/plan.md')
|
|
const commandPath = join(commandsDir, command.relativePath);
|
|
const disabledPath = commandPath + '.disabled';
|
|
|
|
if (command.enabled) {
|
|
// Disable: rename .md to .md.disabled
|
|
if (!existsSync(commandPath)) {
|
|
return { success: false, message: 'Command file not found', status: 404 };
|
|
}
|
|
if (existsSync(disabledPath)) {
|
|
return { success: false, message: 'Command already disabled', status: 409 };
|
|
}
|
|
|
|
renameSync(commandPath, disabledPath);
|
|
return {
|
|
success: true,
|
|
message: 'Command disabled',
|
|
commandName: command.name,
|
|
location
|
|
};
|
|
} else {
|
|
// Enable: rename .md.disabled back to .md
|
|
if (!existsSync(disabledPath)) {
|
|
return { success: false, message: 'Disabled command not found', status: 404 };
|
|
}
|
|
if (existsSync(commandPath)) {
|
|
return { success: false, message: 'Command already enabled', status: 409 };
|
|
}
|
|
|
|
renameSync(disabledPath, commandPath);
|
|
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);
|
|
|
|
// Include groups config from both project and user
|
|
const projectGroupsConfig = loadGroupsConfig('project', validatedProjectPath);
|
|
const userGroupsConfig = loadGroupsConfig('user', validatedProjectPath);
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
...config,
|
|
projectGroupsConfig,
|
|
userGroupsConfig
|
|
}));
|
|
} 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;
|
|
}
|
|
|
|
// GET /api/commands/groups - Get groups configuration
|
|
if (pathname === '/api/commands/groups' && req.method === 'GET') {
|
|
const projectPathParam = url.searchParams.get('path') || initialPath;
|
|
const location = url.searchParams.get('location') || 'project';
|
|
|
|
try {
|
|
const validatedProjectPath = await validateAllowedPath(projectPathParam, {
|
|
mustExist: true,
|
|
allowedDirectories: [initialPath]
|
|
});
|
|
|
|
if (location !== 'project' && location !== 'user') {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Invalid location' }));
|
|
return true;
|
|
}
|
|
|
|
const groupsConfig = loadGroupsConfig(location as CommandLocation, validatedProjectPath);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(groupsConfig));
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const status = message.includes('Access denied') ? 403 : 400;
|
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: message }));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// PUT /api/commands/groups - Update groups configuration
|
|
if (pathname === '/api/commands/groups' && req.method === 'PUT') {
|
|
const projectPathParam = url.searchParams.get('path') || initialPath;
|
|
const location = url.searchParams.get('location') || 'project';
|
|
|
|
handlePostRequest(req, res, async (body) => {
|
|
try {
|
|
const validatedProjectPath = await validateAllowedPath(projectPathParam, {
|
|
mustExist: true,
|
|
allowedDirectories: [initialPath]
|
|
});
|
|
|
|
if (location !== 'project' && location !== 'user') {
|
|
return { error: 'Invalid location', status: 400 };
|
|
}
|
|
|
|
if (!isRecord(body)) {
|
|
return { error: 'Invalid request body', status: 400 };
|
|
}
|
|
|
|
// Validate and save groups config
|
|
const config: CommandGroupsConfig = {
|
|
groups: isRecord(body.groups) ? body.groups as Record<string, GroupDefinition> : {},
|
|
assignments: isRecord(body.assignments) ? body.assignments as Record<string, string> : {}
|
|
};
|
|
|
|
saveGroupsConfig(location as CommandLocation, validatedProjectPath, config);
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Groups configuration updated',
|
|
data: config,
|
|
status: 200
|
|
};
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const status = message.includes('Access denied') ? 403 : 400;
|
|
console.error(`[Commands] Failed to update groups config: ${message}`);
|
|
return { error: message, status };
|
|
}
|
|
});
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|