mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37:45 +08:00
528 lines
15 KiB
TypeScript
528 lines
15 KiB
TypeScript
// @ts-nocheck
|
|
/**
|
|
* Rules Routes Module
|
|
* Handles all Rules-related API endpoints
|
|
*/
|
|
import type { IncomingMessage, ServerResponse } from 'http';
|
|
import { readFileSync, existsSync, readdirSync, unlinkSync, promises as fsPromises } from 'fs';
|
|
import { join } from 'path';
|
|
import { homedir } from 'os';
|
|
import { executeCliTool } from '../../tools/cli-executor.js';
|
|
|
|
export interface RouteContext {
|
|
pathname: string;
|
|
url: URL;
|
|
req: IncomingMessage;
|
|
res: ServerResponse;
|
|
initialPath: string;
|
|
handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise<any>) => void;
|
|
broadcastToClients: (data: unknown) => void;
|
|
}
|
|
|
|
/**
|
|
* Parse rule frontmatter
|
|
* @param {string} content
|
|
* @returns {Object}
|
|
*/
|
|
function parseRuleFrontmatter(content) {
|
|
const result = {
|
|
paths: [],
|
|
content: content
|
|
};
|
|
|
|
// Check for YAML frontmatter
|
|
if (content.startsWith('---')) {
|
|
const endIndex = content.indexOf('---', 3);
|
|
if (endIndex > 0) {
|
|
const frontmatter = content.substring(3, endIndex).trim();
|
|
result.content = content.substring(endIndex + 3).trim();
|
|
|
|
// Parse frontmatter lines
|
|
const lines = frontmatter.split('\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();
|
|
|
|
if (key === 'paths') {
|
|
// Parse as comma-separated or YAML array
|
|
result.paths = value.replace(/^\[|\]$/g, '').split(',').map(t => t.trim()).filter(Boolean);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Recursively scan rules directory for .md files
|
|
* @param {string} dirPath
|
|
* @param {string} location
|
|
* @param {string} subdirectory
|
|
* @returns {Object[]}
|
|
*/
|
|
function scanRulesDirectory(dirPath, location, subdirectory) {
|
|
const rules = [];
|
|
|
|
try {
|
|
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = join(dirPath, entry.name);
|
|
|
|
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
const content = readFileSync(fullPath, 'utf8');
|
|
const parsed = parseRuleFrontmatter(content);
|
|
|
|
rules.push({
|
|
name: entry.name,
|
|
paths: parsed.paths,
|
|
content: parsed.content,
|
|
location,
|
|
path: fullPath,
|
|
subdirectory: subdirectory || null
|
|
});
|
|
} else if (entry.isDirectory()) {
|
|
// Recursively scan subdirectories
|
|
const subRules = scanRulesDirectory(fullPath, location, subdirectory ? `${subdirectory}/${entry.name}` : entry.name);
|
|
rules.push(...subRules);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Ignore errors
|
|
}
|
|
|
|
return rules;
|
|
}
|
|
|
|
/**
|
|
* Get rules configuration from project and user directories
|
|
* @param {string} projectPath
|
|
* @returns {Object}
|
|
*/
|
|
function getRulesConfig(projectPath) {
|
|
const result = {
|
|
projectRules: [],
|
|
userRules: []
|
|
};
|
|
|
|
try {
|
|
// Project rules: .claude/rules/
|
|
const projectRulesDir = join(projectPath, '.claude', 'rules');
|
|
if (existsSync(projectRulesDir)) {
|
|
const rules = scanRulesDirectory(projectRulesDir, 'project', '');
|
|
result.projectRules = rules;
|
|
}
|
|
|
|
// User rules: ~/.claude/rules/
|
|
const userRulesDir = join(homedir(), '.claude', 'rules');
|
|
if (existsSync(userRulesDir)) {
|
|
const rules = scanRulesDirectory(userRulesDir, 'user', '');
|
|
result.userRules = rules;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error reading rules config:', error);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Find rule file in directory (including subdirectories)
|
|
* @param {string} baseDir
|
|
* @param {string} ruleName
|
|
* @returns {string|null}
|
|
*/
|
|
function findRuleFile(baseDir, ruleName) {
|
|
try {
|
|
// Direct path
|
|
const directPath = join(baseDir, ruleName);
|
|
if (existsSync(directPath)) {
|
|
return directPath;
|
|
}
|
|
|
|
// Search in subdirectories
|
|
const entries = readdirSync(baseDir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
const subPath = findRuleFile(join(baseDir, entry.name), ruleName);
|
|
if (subPath) return subPath;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Ignore errors
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get single rule detail
|
|
* @param {string} ruleName
|
|
* @param {string} location - 'project' or 'user'
|
|
* @param {string} projectPath
|
|
* @returns {Object}
|
|
*/
|
|
function getRuleDetail(ruleName, location, projectPath) {
|
|
try {
|
|
const baseDir = location === 'project'
|
|
? join(projectPath, '.claude', 'rules')
|
|
: join(homedir(), '.claude', 'rules');
|
|
|
|
// Find the rule file (could be in subdirectory)
|
|
const rulePath = findRuleFile(baseDir, ruleName);
|
|
|
|
if (!rulePath) {
|
|
return { error: 'Rule not found' };
|
|
}
|
|
|
|
const content = readFileSync(rulePath, 'utf8');
|
|
const parsed = parseRuleFrontmatter(content);
|
|
|
|
return {
|
|
rule: {
|
|
name: ruleName,
|
|
paths: parsed.paths,
|
|
content: parsed.content,
|
|
location,
|
|
path: rulePath
|
|
}
|
|
};
|
|
} catch (error) {
|
|
return { error: (error as Error).message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a rule
|
|
* @param {string} ruleName
|
|
* @param {string} location
|
|
* @param {string} projectPath
|
|
* @returns {Object}
|
|
*/
|
|
function deleteRule(ruleName, location, projectPath) {
|
|
try {
|
|
const baseDir = location === 'project'
|
|
? join(projectPath, '.claude', 'rules')
|
|
: join(homedir(), '.claude', 'rules');
|
|
|
|
const rulePath = findRuleFile(baseDir, ruleName);
|
|
|
|
if (!rulePath) {
|
|
return { error: 'Rule not found' };
|
|
}
|
|
|
|
unlinkSync(rulePath);
|
|
|
|
return { success: true, ruleName, location };
|
|
} catch (error) {
|
|
return { error: (error as Error).message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate rule content via CLI tool
|
|
* @param {Object} params
|
|
* @param {string} params.generationType - 'description' | 'template' | 'extract'
|
|
* @param {string} params.description - Rule description (for 'description' mode)
|
|
* @param {string} params.templateType - Template type (for 'template' mode)
|
|
* @param {string} params.extractScope - Scope pattern (for 'extract' mode)
|
|
* @param {string} params.extractFocus - Focus areas (for 'extract' mode)
|
|
* @param {string} params.fileName - Target file name
|
|
* @param {string} params.location - 'project' or 'user'
|
|
* @param {string} params.subdirectory - Optional subdirectory
|
|
* @param {string} params.projectPath - Project root path
|
|
* @returns {Object}
|
|
*/
|
|
async function generateRuleViaCLI(params) {
|
|
try {
|
|
const {
|
|
generationType,
|
|
description,
|
|
templateType,
|
|
extractScope,
|
|
extractFocus,
|
|
fileName,
|
|
location,
|
|
subdirectory,
|
|
projectPath
|
|
} = params;
|
|
|
|
let prompt = '';
|
|
let mode = 'analysis';
|
|
let workingDir = projectPath;
|
|
|
|
// Build prompt based on generation type
|
|
if (generationType === 'description') {
|
|
mode = 'write';
|
|
prompt = `PURPOSE: Generate Claude Code memory rule from description to guide Claude's behavior
|
|
TASK: • Analyze rule requirements • Generate markdown content with clear instructions
|
|
MODE: write
|
|
EXPECTED: Complete rule content in markdown format
|
|
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt) | Follow Claude Code rule format | Use frontmatter for conditional rules if paths specified | write=CREATE
|
|
|
|
RULE DESCRIPTION:
|
|
${description}
|
|
|
|
FILE NAME: ${fileName}`;
|
|
} else if (generationType === 'template') {
|
|
mode = 'write';
|
|
prompt = `PURPOSE: Generate Claude Code rule from template type
|
|
TASK: • Create rule based on ${templateType} template • Generate structured markdown content
|
|
MODE: write
|
|
EXPECTED: Complete rule content in markdown format following template structure
|
|
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt) | Follow Claude Code rule format | Use ${templateType} template patterns | write=CREATE
|
|
|
|
TEMPLATE TYPE: ${templateType}
|
|
FILE NAME: ${fileName}`;
|
|
} else if (generationType === 'extract') {
|
|
mode = 'analysis';
|
|
prompt = `PURPOSE: Extract coding rules from existing codebase to document patterns and conventions
|
|
TASK: • Analyze code patterns in specified scope • Extract common conventions • Identify best practices
|
|
MODE: analysis
|
|
CONTEXT: @${extractScope || '**/*'}
|
|
EXPECTED: Rule content based on codebase analysis with examples
|
|
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/analysis/02-analyze-code-patterns.txt) | Focus on actual patterns found | Include code examples | analysis=READ-ONLY
|
|
|
|
ANALYSIS SCOPE: ${extractScope || '**/*'}
|
|
FOCUS AREAS: ${extractFocus || 'naming conventions, error handling, code structure'}`;
|
|
} else {
|
|
return { error: `Unknown generation type: ${generationType}` };
|
|
}
|
|
|
|
// Execute CLI tool (Claude) with at least 10 minutes timeout
|
|
const result = await executeCliTool({
|
|
tool: 'claude',
|
|
prompt,
|
|
mode,
|
|
cd: workingDir,
|
|
timeout: 600000, // 10 minutes
|
|
category: 'internal'
|
|
});
|
|
|
|
if (!result.success) {
|
|
return {
|
|
error: `CLI execution failed: ${result.stderr || 'Unknown error'}`,
|
|
stderr: result.stderr
|
|
};
|
|
}
|
|
|
|
// Extract generated content from stdout
|
|
const generatedContent = result.stdout.trim();
|
|
|
|
if (!generatedContent) {
|
|
return {
|
|
error: 'CLI execution returned empty content',
|
|
stdout: result.stdout,
|
|
stderr: result.stderr
|
|
};
|
|
}
|
|
|
|
// Create the rule using the generated content
|
|
const createResult = await createRule({
|
|
fileName,
|
|
content: generatedContent,
|
|
paths: [],
|
|
location,
|
|
subdirectory,
|
|
projectPath
|
|
});
|
|
|
|
return {
|
|
success: createResult.success || false,
|
|
...createResult,
|
|
generatedContent,
|
|
executionId: result.conversation?.id
|
|
};
|
|
} catch (error) {
|
|
return { error: (error as Error).message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new rule
|
|
* @param {Object} params
|
|
* @param {string} params.fileName - Rule file name (must end with .md)
|
|
* @param {string} params.content - Rule content (markdown)
|
|
* @param {string[]} params.paths - Optional paths for conditional rule
|
|
* @param {string} params.location - 'project' or 'user'
|
|
* @param {string} params.subdirectory - Optional subdirectory path
|
|
* @param {string} params.projectPath - Project root path
|
|
* @returns {Object}
|
|
*/
|
|
async function createRule(params) {
|
|
try {
|
|
const { fileName, content, paths, location, subdirectory, projectPath } = params;
|
|
|
|
// Validate file name
|
|
if (!fileName || !fileName.endsWith('.md')) {
|
|
return { error: 'File name must end with .md' };
|
|
}
|
|
|
|
// Build base directory
|
|
const baseDir = location === 'project'
|
|
? join(projectPath, '.claude', 'rules')
|
|
: join(homedir(), '.claude', 'rules');
|
|
|
|
// Build target directory (with optional subdirectory)
|
|
const targetDir = subdirectory
|
|
? join(baseDir, subdirectory)
|
|
: baseDir;
|
|
|
|
// Ensure target directory exists
|
|
await fsPromises.mkdir(targetDir, { recursive: true });
|
|
|
|
// Build complete file path
|
|
const filePath = join(targetDir, fileName);
|
|
|
|
// Check if file already exists
|
|
if (existsSync(filePath)) {
|
|
return { error: `Rule '${fileName}' already exists in ${location} location` };
|
|
}
|
|
|
|
// Build complete content with frontmatter if paths provided
|
|
let completeContent = content;
|
|
if (paths && paths.length > 0) {
|
|
const frontmatter = `---
|
|
paths: [${paths.join(', ')}]
|
|
---
|
|
|
|
`;
|
|
completeContent = frontmatter + content;
|
|
}
|
|
|
|
// Write rule file
|
|
await fsPromises.writeFile(filePath, completeContent, 'utf8');
|
|
|
|
return {
|
|
success: true,
|
|
fileName,
|
|
location,
|
|
path: filePath,
|
|
subdirectory: subdirectory || null
|
|
};
|
|
} catch (error) {
|
|
return { error: (error as Error).message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle Rules routes
|
|
* @returns true if route was handled, false otherwise
|
|
*/
|
|
export async function handleRulesRoutes(ctx: RouteContext): Promise<boolean> {
|
|
const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
|
|
|
|
// API: Get all rules
|
|
if (pathname === '/api/rules') {
|
|
const projectPathParam = url.searchParams.get('path') || initialPath;
|
|
const rulesData = getRulesConfig(projectPathParam);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(rulesData));
|
|
return true;
|
|
}
|
|
|
|
// API: Get single rule detail
|
|
if (pathname.startsWith('/api/rules/') && req.method === 'GET' && !pathname.endsWith('/rules/')) {
|
|
const ruleName = decodeURIComponent(pathname.replace('/api/rules/', ''));
|
|
const location = url.searchParams.get('location') || 'project';
|
|
const projectPathParam = url.searchParams.get('path') || initialPath;
|
|
const ruleDetail = getRuleDetail(ruleName, location, projectPathParam);
|
|
if (ruleDetail.error) {
|
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(ruleDetail));
|
|
} else {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(ruleDetail));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// API: Delete rule
|
|
if (pathname.startsWith('/api/rules/') && req.method === 'DELETE') {
|
|
const ruleName = decodeURIComponent(pathname.replace('/api/rules/', ''));
|
|
handlePostRequest(req, res, async (body) => {
|
|
const { location, projectPath: projectPathParam } = body;
|
|
return deleteRule(ruleName, location, projectPathParam || initialPath);
|
|
});
|
|
return true;
|
|
}
|
|
|
|
// API: Create rule
|
|
if (pathname === '/api/rules/create' && req.method === 'POST') {
|
|
handlePostRequest(req, res, async (body) => {
|
|
const {
|
|
mode,
|
|
fileName,
|
|
content,
|
|
paths,
|
|
location,
|
|
subdirectory,
|
|
projectPath: projectPathParam,
|
|
// CLI generation parameters
|
|
generationType,
|
|
description,
|
|
templateType,
|
|
extractScope,
|
|
extractFocus
|
|
} = body;
|
|
|
|
if (!fileName) {
|
|
return { error: 'File name is required' };
|
|
}
|
|
|
|
if (!location) {
|
|
return { error: 'Location is required (project or user)' };
|
|
}
|
|
|
|
const projectPath = projectPathParam || initialPath;
|
|
|
|
// CLI generation mode
|
|
if (mode === 'cli-generate') {
|
|
if (!generationType) {
|
|
return { error: 'generationType is required for CLI generation mode' };
|
|
}
|
|
|
|
// Validate based on generation type
|
|
if (generationType === 'description' && !description) {
|
|
return { error: 'description is required for description-based generation' };
|
|
}
|
|
|
|
if (generationType === 'template' && !templateType) {
|
|
return { error: 'templateType is required for template-based generation' };
|
|
}
|
|
|
|
return await generateRuleViaCLI({
|
|
generationType,
|
|
description,
|
|
templateType,
|
|
extractScope,
|
|
extractFocus,
|
|
fileName,
|
|
location,
|
|
subdirectory: subdirectory || '',
|
|
projectPath
|
|
});
|
|
}
|
|
|
|
// Manual creation mode
|
|
if (!content) {
|
|
return { error: 'Content is required for manual creation' };
|
|
}
|
|
|
|
return await createRule({
|
|
fileName,
|
|
content,
|
|
paths: paths || [],
|
|
location,
|
|
subdirectory: subdirectory || '',
|
|
projectPath
|
|
});
|
|
});
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|