mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 15:03:57 +08:00
feat: Add context_cache MCP tool with simplified CLI options
Add context_cache MCP tool for caching files by @patterns: - pattern-parser.ts: Parse @expressions using glob - context-cache-store.ts: In-memory cache with TTL/LRU - context-cache.ts: MCP tool with pack/read/status/release/cleanup Simplify CLI cache options: - --cache now uses comma-separated format instead of JSON - Items starting with @ are patterns, others are text content - Add --inject-mode option (none/full/progressive) - Default: codex=full, gemini/qwen=none 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
329
ccw/src/tools/pattern-parser.ts
Normal file
329
ccw/src/tools/pattern-parser.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Pattern Parser - Parse @expression patterns to file lists
|
||||
* Supports glob patterns like @src/**.ts, @CLAUDE.md, @../shared/**
|
||||
*/
|
||||
|
||||
import { glob } from 'glob';
|
||||
import { resolve, isAbsolute, normalize } from 'path';
|
||||
import { existsSync, statSync, readFileSync } from 'fs';
|
||||
|
||||
/** Result of parsing @patterns */
|
||||
export interface PatternParseResult {
|
||||
files: string[]; // Matched file paths (absolute)
|
||||
patterns: string[]; // Original patterns
|
||||
errors: string[]; // Parse errors
|
||||
stats: {
|
||||
total_files: number;
|
||||
total_patterns: number;
|
||||
matched_patterns: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Options for pattern parsing */
|
||||
export interface PatternParseOptions {
|
||||
cwd?: string; // Working directory
|
||||
includeDirs?: string[]; // Additional directories to include
|
||||
ignore?: string[]; // Ignore patterns
|
||||
maxFiles?: number; // Max files to return (default: 1000)
|
||||
followSymlinks?: boolean; // Follow symlinks (default: false)
|
||||
}
|
||||
|
||||
/** Default ignore patterns */
|
||||
const DEFAULT_IGNORE = [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/.next/**',
|
||||
'**/__pycache__/**',
|
||||
'**/*.pyc',
|
||||
'**/venv/**',
|
||||
'**/.venv/**',
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract pattern from @expression
|
||||
* Example: "@src/**.ts" -> "src/**.ts"
|
||||
*/
|
||||
function extractPattern(expression: string): string | null {
|
||||
const trimmed = expression.trim();
|
||||
if (!trimmed.startsWith('@')) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a pattern is a glob pattern or exact file
|
||||
*/
|
||||
function isGlobPattern(pattern: string): boolean {
|
||||
return pattern.includes('*') || pattern.includes('?') || pattern.includes('{') || pattern.includes('[');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a path is within allowed directories
|
||||
*/
|
||||
function isPathAllowed(filePath: string, allowedDirs: string[]): boolean {
|
||||
const normalized = normalize(filePath);
|
||||
return allowedDirs.some(dir => normalized.startsWith(normalize(dir)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build allowed directories list from options
|
||||
*/
|
||||
function buildAllowedDirs(cwd: string, includeDirs?: string[]): string[] {
|
||||
const allowed = [cwd];
|
||||
|
||||
if (includeDirs) {
|
||||
for (const dir of includeDirs) {
|
||||
const absDir = isAbsolute(dir) ? dir : resolve(cwd, dir);
|
||||
if (existsSync(absDir) && statSync(absDir).isDirectory()) {
|
||||
allowed.push(absDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allowed.map(d => normalize(d));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse @expressions and return matched files
|
||||
*/
|
||||
export async function parsePatterns(
|
||||
patterns: string[],
|
||||
options: PatternParseOptions = {}
|
||||
): Promise<PatternParseResult> {
|
||||
const {
|
||||
cwd = process.cwd(),
|
||||
includeDirs = [],
|
||||
ignore = [],
|
||||
maxFiles = 1000,
|
||||
followSymlinks = false,
|
||||
} = options;
|
||||
|
||||
const result: PatternParseResult = {
|
||||
files: [],
|
||||
patterns: [],
|
||||
errors: [],
|
||||
stats: {
|
||||
total_files: 0,
|
||||
total_patterns: patterns.length,
|
||||
matched_patterns: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Build allowed directories
|
||||
const allowedDirs = buildAllowedDirs(cwd, includeDirs);
|
||||
|
||||
// Merge ignore patterns
|
||||
const allIgnore = [...DEFAULT_IGNORE, ...ignore];
|
||||
|
||||
// Track unique files
|
||||
const fileSet = new Set<string>();
|
||||
|
||||
for (const expr of patterns) {
|
||||
const pattern = extractPattern(expr);
|
||||
|
||||
if (!pattern) {
|
||||
result.errors.push(`Invalid pattern: ${expr} (must start with @)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
result.patterns.push(pattern);
|
||||
|
||||
try {
|
||||
if (isGlobPattern(pattern)) {
|
||||
// Glob pattern - use glob package
|
||||
// Determine base directory for pattern
|
||||
let baseDir = cwd;
|
||||
let globPattern = pattern;
|
||||
|
||||
// Handle relative paths like ../shared/**
|
||||
if (pattern.startsWith('../') || pattern.startsWith('./')) {
|
||||
const parts = pattern.split('/');
|
||||
const pathParts: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
// Extract path prefix
|
||||
while (i < parts.length && (parts[i] === '..' || parts[i] === '.')) {
|
||||
pathParts.push(parts[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
// Keep non-glob path parts
|
||||
while (i < parts.length && !isGlobPattern(parts[i])) {
|
||||
pathParts.push(parts[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
// Resolve base directory
|
||||
if (pathParts.length > 0) {
|
||||
baseDir = resolve(cwd, pathParts.join('/'));
|
||||
globPattern = parts.slice(i).join('/') || '**/*';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if base directory is allowed
|
||||
if (!isPathAllowed(baseDir, allowedDirs)) {
|
||||
result.errors.push(`Pattern ${expr}: base directory not in allowed paths`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Execute glob using the glob package
|
||||
const matches = await glob(globPattern, {
|
||||
cwd: baseDir,
|
||||
absolute: true,
|
||||
nodir: true,
|
||||
follow: followSymlinks,
|
||||
ignore: allIgnore,
|
||||
dot: false,
|
||||
});
|
||||
|
||||
let matchCount = 0;
|
||||
for (const file of matches) {
|
||||
// Validate each file is in allowed directories
|
||||
if (isPathAllowed(file, allowedDirs)) {
|
||||
fileSet.add(file);
|
||||
matchCount++;
|
||||
if (fileSet.size >= maxFiles) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchCount > 0) {
|
||||
result.stats.matched_patterns++;
|
||||
}
|
||||
} else {
|
||||
// Exact file path
|
||||
const absPath = isAbsolute(pattern) ? pattern : resolve(cwd, pattern);
|
||||
|
||||
// Validate path is allowed
|
||||
if (!isPathAllowed(absPath, allowedDirs)) {
|
||||
result.errors.push(`Pattern ${expr}: path not in allowed directories`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check file exists
|
||||
if (existsSync(absPath) && statSync(absPath).isFile()) {
|
||||
fileSet.add(absPath);
|
||||
result.stats.matched_patterns++;
|
||||
} else {
|
||||
result.errors.push(`Pattern ${expr}: file not found`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Pattern ${expr}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// Check max files limit
|
||||
if (fileSet.size >= maxFiles) {
|
||||
result.errors.push(`Max files limit (${maxFiles}) reached`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
result.files = Array.from(fileSet);
|
||||
result.stats.total_files = result.files.length;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack files into a single content string with metadata headers
|
||||
*/
|
||||
export async function packFiles(
|
||||
files: string[],
|
||||
options: {
|
||||
includeMetadata?: boolean;
|
||||
separator?: string;
|
||||
maxFileSize?: number; // Max size per file in bytes (default: 1MB)
|
||||
} = {}
|
||||
): Promise<{
|
||||
content: string;
|
||||
packedFiles: string[];
|
||||
skippedFiles: string[];
|
||||
totalBytes: number;
|
||||
}> {
|
||||
const {
|
||||
includeMetadata = true,
|
||||
separator = '\n\n',
|
||||
maxFileSize = 1024 * 1024, // 1MB default
|
||||
} = options;
|
||||
|
||||
const parts: string[] = [];
|
||||
const packedFiles: string[] = [];
|
||||
const skippedFiles: string[] = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const stats = statSync(file);
|
||||
|
||||
// Skip files that are too large
|
||||
if (stats.size > maxFileSize) {
|
||||
skippedFiles.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
|
||||
if (includeMetadata) {
|
||||
// Add file header with metadata
|
||||
const header = [
|
||||
`=== FILE: ${file} ===`,
|
||||
`Size: ${stats.size} bytes`,
|
||||
`Modified: ${stats.mtime.toISOString()}`,
|
||||
'---',
|
||||
].join('\n');
|
||||
parts.push(header + '\n' + content);
|
||||
} else {
|
||||
parts.push(content);
|
||||
}
|
||||
|
||||
packedFiles.push(file);
|
||||
totalBytes += content.length;
|
||||
} catch {
|
||||
skippedFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: parts.join(separator),
|
||||
packedFiles,
|
||||
skippedFiles,
|
||||
totalBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse patterns and pack files in one call
|
||||
*/
|
||||
export async function parseAndPack(
|
||||
patterns: string[],
|
||||
options: PatternParseOptions & {
|
||||
includeMetadata?: boolean;
|
||||
separator?: string;
|
||||
maxFileSize?: number;
|
||||
} = {}
|
||||
): Promise<{
|
||||
content: string;
|
||||
parseResult: PatternParseResult;
|
||||
packedFiles: string[];
|
||||
skippedFiles: string[];
|
||||
totalBytes: number;
|
||||
}> {
|
||||
const parseResult = await parsePatterns(patterns, options);
|
||||
|
||||
const packResult = await packFiles(parseResult.files, {
|
||||
includeMetadata: options.includeMetadata,
|
||||
separator: options.separator,
|
||||
maxFileSize: options.maxFileSize,
|
||||
});
|
||||
|
||||
return {
|
||||
content: packResult.content,
|
||||
parseResult,
|
||||
packedFiles: packResult.packedFiles,
|
||||
skippedFiles: packResult.skippedFiles,
|
||||
totalBytes: packResult.totalBytes,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user