mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-09 02:24:11 +08:00
feat(cli): 添加 --rule 选项支持模板自动发现
重构 ccw cli 模板系统: - 新增 template-discovery.ts 模块,支持扁平化模板自动发现 - 添加 --rule <template> 选项,自动加载 protocol 和 template - 模板目录从嵌套结构 (prompts/category/file.txt) 迁移到扁平结构 (prompts/category-function.txt) - 更新所有 agent/command 文件,使用 $PROTO $TMPL 环境变量替代 $(cat ...) 模式 - 支持模糊匹配:--rule 02-review-architecture 可匹配 analysis-review-architecture.txt 其他更新: - Dashboard: 添加 Claude Manager 和 Issue Manager 页面 - Codex-lens: 增强 chain_search 和 clustering 模块 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -364,6 +364,16 @@ const ParamsSchema = z.object({
|
||||
parentExecutionId: z.string().optional(), // Parent execution ID for fork/retry scenarios
|
||||
stream: z.boolean().default(false), // false = cache full output (default), true = stream output via callback
|
||||
outputFormat: z.enum(['text', 'json-lines']).optional().default('json-lines'), // Output parsing format (default: json-lines for type badges)
|
||||
// Codex review options
|
||||
uncommitted: z.boolean().optional(), // Review uncommitted changes (default for review mode)
|
||||
base: z.string().optional(), // Review changes against base branch
|
||||
commit: z.string().optional(), // Review changes from specific commit
|
||||
title: z.string().optional(), // Optional title for review summary
|
||||
// Rules env vars (PROTO, TMPL) - will be passed to subprocess environment
|
||||
rulesEnv: z.object({
|
||||
PROTO: z.string().optional(),
|
||||
TMPL: z.string().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof ParamsSchema>;
|
||||
@@ -388,7 +398,7 @@ async function executeCliTool(
|
||||
throw new Error(`Invalid params: ${parsed.error.message}`);
|
||||
}
|
||||
|
||||
const { tool, prompt, mode, format, model, cd, includeDirs, resume, id: customId, noNative, category, parentExecutionId, outputFormat } = parsed.data;
|
||||
const { tool, prompt, mode, format, model, cd, includeDirs, resume, id: customId, noNative, category, parentExecutionId, outputFormat, uncommitted, base, commit, title, rulesEnv } = parsed.data;
|
||||
|
||||
// Validate and determine working directory early (needed for conversation lookup)
|
||||
let workingDir: string;
|
||||
@@ -786,7 +796,8 @@ async function executeCliTool(
|
||||
model: effectiveModel,
|
||||
dir: cd,
|
||||
include: includeDirs,
|
||||
nativeResume: nativeResumeConfig
|
||||
nativeResume: nativeResumeConfig,
|
||||
reviewOptions: mode === 'review' ? { uncommitted, base, commit, title } : undefined
|
||||
});
|
||||
|
||||
// Create output parser and IR storage
|
||||
@@ -823,9 +834,11 @@ async function executeCliTool(
|
||||
}
|
||||
|
||||
// Merge custom env with process.env (custom env takes precedence)
|
||||
// Also include rulesEnv for $PROTO and $TMPL template variables
|
||||
const spawnEnv = {
|
||||
...process.env,
|
||||
...customEnv
|
||||
...customEnv,
|
||||
...(rulesEnv || {})
|
||||
};
|
||||
|
||||
debugLog('SPAWN', `Spawning process`, {
|
||||
|
||||
@@ -159,8 +159,15 @@ export function buildCommand(params: {
|
||||
nativeResume?: NativeResumeConfig;
|
||||
/** Claude CLI settings file path (for --settings parameter) */
|
||||
settingsFile?: string;
|
||||
/** Codex review options */
|
||||
reviewOptions?: {
|
||||
uncommitted?: boolean;
|
||||
base?: string;
|
||||
commit?: string;
|
||||
title?: string;
|
||||
};
|
||||
}): { command: string; args: string[]; useStdin: boolean } {
|
||||
const { tool, prompt, mode = 'analysis', model, dir, include, nativeResume, settingsFile } = params;
|
||||
const { tool, prompt, mode = 'analysis', model, dir, include, nativeResume, settingsFile, reviewOptions } = params;
|
||||
|
||||
debugLog('BUILD_CMD', `Building command for tool: ${tool}`, {
|
||||
mode,
|
||||
@@ -227,10 +234,25 @@ export function buildCommand(params: {
|
||||
// codex review mode: non-interactive code review
|
||||
// Format: codex review [OPTIONS] [PROMPT]
|
||||
args.push('review');
|
||||
// Default to --uncommitted if no specific review target in prompt
|
||||
args.push('--uncommitted');
|
||||
|
||||
// Review target: --uncommitted (default), --base <branch>, or --commit <sha>
|
||||
if (reviewOptions?.base) {
|
||||
args.push('--base', reviewOptions.base);
|
||||
} else if (reviewOptions?.commit) {
|
||||
args.push('--commit', reviewOptions.commit);
|
||||
} else {
|
||||
// Default to --uncommitted if no specific target
|
||||
args.push('--uncommitted');
|
||||
}
|
||||
|
||||
// Optional title for review summary
|
||||
if (reviewOptions?.title) {
|
||||
args.push('--title', reviewOptions.title);
|
||||
}
|
||||
|
||||
if (model) {
|
||||
args.push('-m', model);
|
||||
// codex review uses -c key=value for config override, not -m
|
||||
args.push('-c', `model=${model}`);
|
||||
}
|
||||
// codex review uses positional prompt argument, not stdin
|
||||
useStdin = false;
|
||||
|
||||
303
ccw/src/tools/template-discovery.ts
Normal file
303
ccw/src/tools/template-discovery.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Template Discovery Module
|
||||
*
|
||||
* Provides auto-discovery and loading of CLI templates from
|
||||
* ~/.claude/workflows/cli-templates/
|
||||
*
|
||||
* Features:
|
||||
* - Scan prompts/ directory (flat structure with category-function.txt naming)
|
||||
* - Match template names (e.g., "analysis-review-architecture" or just "review-architecture")
|
||||
* - Load protocol files based on mode (analysis/write)
|
||||
* - Cache template content for performance
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, existsSync } from 'fs';
|
||||
import { join, basename, extname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface TemplateMeta {
|
||||
name: string; // Full filename without extension (e.g., "analysis-review-architecture")
|
||||
path: string; // Full absolute path
|
||||
category: string; // Category from filename (e.g., "analysis")
|
||||
shortName: string; // Name without category prefix (e.g., "review-architecture")
|
||||
}
|
||||
|
||||
export interface TemplateIndex {
|
||||
templates: Map<string, TemplateMeta>; // name -> meta (full name match)
|
||||
byShortName: Map<string, TemplateMeta>; // shortName -> meta (for fuzzy match)
|
||||
categories: Map<string, string[]>; // category -> template names
|
||||
lastScan: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const TEMPLATES_BASE_DIR = join(homedir(), '.claude', 'workflows', 'cli-templates');
|
||||
const PROMPTS_DIR = join(TEMPLATES_BASE_DIR, 'prompts');
|
||||
const PROTOCOLS_DIR = join(TEMPLATES_BASE_DIR, 'protocols');
|
||||
|
||||
const PROTOCOL_FILES: Record<string, string> = {
|
||||
analysis: 'analysis-protocol.md',
|
||||
write: 'write-protocol.md',
|
||||
};
|
||||
|
||||
// Cache
|
||||
let templateIndex: TemplateIndex | null = null;
|
||||
const contentCache: Map<string, string> = new Map();
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the base templates directory path
|
||||
*/
|
||||
export function getTemplatesDir(): string {
|
||||
return TEMPLATES_BASE_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the prompts directory path
|
||||
*/
|
||||
export function getPromptsDir(): string {
|
||||
return PROMPTS_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the protocols directory path
|
||||
*/
|
||||
export function getProtocolsDir(): string {
|
||||
return PROTOCOLS_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan templates directory and build index
|
||||
* Flat structure: prompts/category-function.txt
|
||||
* Results are cached for performance
|
||||
*/
|
||||
export function scanTemplates(forceRescan = false): TemplateIndex {
|
||||
if (templateIndex && !forceRescan) {
|
||||
return templateIndex;
|
||||
}
|
||||
|
||||
const templates = new Map<string, TemplateMeta>();
|
||||
const byShortName = new Map<string, TemplateMeta>();
|
||||
const categories = new Map<string, string[]>();
|
||||
|
||||
if (!existsSync(PROMPTS_DIR)) {
|
||||
console.warn(`[template-discovery] Prompts directory not found: ${PROMPTS_DIR}`);
|
||||
templateIndex = { templates, byShortName, categories, lastScan: Date.now() };
|
||||
return templateIndex;
|
||||
}
|
||||
|
||||
// Scan all files directly in prompts/ (flat structure)
|
||||
const files = readdirSync(PROMPTS_DIR).filter(file => {
|
||||
const ext = extname(file).toLowerCase();
|
||||
return ext === '.txt' || ext === '.md';
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
const name = basename(file, extname(file)); // e.g., "analysis-review-architecture"
|
||||
const fullPath = join(PROMPTS_DIR, file);
|
||||
|
||||
// Extract category from filename (first segment before -)
|
||||
const dashIndex = name.indexOf('-');
|
||||
const category = dashIndex > 0 ? name.substring(0, dashIndex) : 'other';
|
||||
const shortName = dashIndex > 0 ? name.substring(dashIndex + 1) : name;
|
||||
|
||||
const meta: TemplateMeta = {
|
||||
name,
|
||||
path: fullPath,
|
||||
category,
|
||||
shortName,
|
||||
};
|
||||
|
||||
// Index by full name
|
||||
templates.set(name, meta);
|
||||
|
||||
// Index by short name (for fuzzy match)
|
||||
// If duplicate shortName exists, prefer keeping first one
|
||||
if (!byShortName.has(shortName)) {
|
||||
byShortName.set(shortName, meta);
|
||||
}
|
||||
|
||||
// Group by category
|
||||
if (!categories.has(category)) {
|
||||
categories.set(category, []);
|
||||
}
|
||||
categories.get(category)!.push(name);
|
||||
}
|
||||
|
||||
templateIndex = { templates, byShortName, categories, lastScan: Date.now() };
|
||||
return templateIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a template by name
|
||||
*
|
||||
* @param nameOrShort - Full template name (e.g., "analysis-review-architecture")
|
||||
* or short name (e.g., "review-architecture")
|
||||
* @returns Full path to template file, or null if not found
|
||||
*/
|
||||
export function findTemplate(nameOrShort: string): string | null {
|
||||
const index = scanTemplates();
|
||||
|
||||
// Try exact full name match first
|
||||
if (index.templates.has(nameOrShort)) {
|
||||
return index.templates.get(nameOrShort)!.path;
|
||||
}
|
||||
|
||||
// Try with .txt extension removed
|
||||
const nameWithoutExt = nameOrShort.replace(/\.(txt|md)$/i, '');
|
||||
if (index.templates.has(nameWithoutExt)) {
|
||||
return index.templates.get(nameWithoutExt)!.path;
|
||||
}
|
||||
|
||||
// Try short name match (without category prefix)
|
||||
if (index.byShortName.has(nameOrShort)) {
|
||||
return index.byShortName.get(nameOrShort)!.path;
|
||||
}
|
||||
|
||||
// Try short name without extension
|
||||
if (index.byShortName.has(nameWithoutExt)) {
|
||||
return index.byShortName.get(nameWithoutExt)!.path;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load protocol content based on mode
|
||||
*
|
||||
* @param mode - Execution mode: "analysis" or "write"
|
||||
* @returns Protocol file content, or empty string if not found
|
||||
*/
|
||||
export function loadProtocol(mode: string): string {
|
||||
const protocolFile = PROTOCOL_FILES[mode];
|
||||
if (!protocolFile) {
|
||||
console.warn(`[template-discovery] No protocol defined for mode: ${mode}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
const protocolPath = join(PROTOCOLS_DIR, protocolFile);
|
||||
|
||||
// Check cache
|
||||
if (contentCache.has(protocolPath)) {
|
||||
return contentCache.get(protocolPath)!;
|
||||
}
|
||||
|
||||
if (!existsSync(protocolPath)) {
|
||||
console.warn(`[template-discovery] Protocol file not found: ${protocolPath}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(protocolPath, 'utf8');
|
||||
contentCache.set(protocolPath, content);
|
||||
return content;
|
||||
} catch (error) {
|
||||
console.error(`[template-discovery] Failed to read protocol: ${protocolPath}`, error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load template content by name or path
|
||||
*
|
||||
* @param nameOrPath - Template name or relative path
|
||||
* @returns Template file content
|
||||
* @throws Error if template not found
|
||||
*/
|
||||
export function loadTemplate(nameOrPath: string): string {
|
||||
const templatePath = findTemplate(nameOrPath);
|
||||
|
||||
if (!templatePath) {
|
||||
// List available templates for helpful error message
|
||||
const index = scanTemplates();
|
||||
const available = Array.from(index.templates.keys()).slice(0, 10).join(', ');
|
||||
throw new Error(
|
||||
`Template not found: "${nameOrPath}"\n` +
|
||||
`Available templates (first 10): ${available}...\n` +
|
||||
`Use 'ccw cli templates' to list all available templates.`
|
||||
);
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if (contentCache.has(templatePath)) {
|
||||
return contentCache.get(templatePath)!;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(templatePath, 'utf8');
|
||||
contentCache.set(templatePath, content);
|
||||
return content;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read template: ${templatePath}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build rules content from protocol and template
|
||||
*
|
||||
* @param mode - Execution mode for protocol selection
|
||||
* @param templateName - Template name or path (optional)
|
||||
* @param includeProtocol - Whether to include protocol (default: true)
|
||||
* @returns Combined rules content
|
||||
*/
|
||||
export function buildRulesContent(
|
||||
mode: string,
|
||||
templateName?: string,
|
||||
includeProtocol = true
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Load protocol if requested
|
||||
if (includeProtocol) {
|
||||
const protocol = loadProtocol(mode);
|
||||
if (protocol) {
|
||||
parts.push(protocol);
|
||||
}
|
||||
}
|
||||
|
||||
// Load template if specified
|
||||
if (templateName) {
|
||||
const template = loadTemplate(templateName);
|
||||
if (template) {
|
||||
parts.push(template);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available templates
|
||||
*
|
||||
* @returns Object with categories and their templates
|
||||
*/
|
||||
export function listTemplates(): Record<string, TemplateMeta[]> {
|
||||
const index = scanTemplates();
|
||||
const result: Record<string, TemplateMeta[]> = {};
|
||||
|
||||
for (const [category, names] of index.categories) {
|
||||
result[category] = names.map(name => {
|
||||
const meta = index.templates.get(name);
|
||||
return meta || { name, path: '', category, shortName: '' };
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear template cache (useful for testing or after template updates)
|
||||
*/
|
||||
export function clearCache(): void {
|
||||
templateIndex = null;
|
||||
contentCache.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user