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:
catlog22
2026-01-17 19:20:24 +08:00
parent 1fae35c05d
commit f14418603a
137 changed files with 13125 additions and 301 deletions

View File

@@ -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`, {

View File

@@ -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;

View 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();
}