mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 15:03:57 +08:00
feat: add injection preview functionality and enhance specs management
- Implemented injection preview feature in InjectionControlTab with file listing and content preview. - Added new API endpoint for fetching injection preview data. - Introduced content length caching for performance optimization. - Enhanced spec loading to support category filtering. - Updated localization files for new features and terms. - Created new personal and project specs for coding style and architecture constraints. - Improved CLI options for category selection in spec commands.
This commit is contained in:
@@ -303,6 +303,7 @@ export function run(argv: string[]): void {
|
||||
.command('spec [subcommand] [args...]')
|
||||
.description('Project spec management for conventions and guidelines')
|
||||
.option('--dimension <dim>', 'Target dimension: specs, personal')
|
||||
.option('--category <cat>', 'Workflow stage: general, exploration, planning, execution')
|
||||
.option('--keywords <text>', 'Keywords for spec matching (CLI mode)')
|
||||
.option('--stdin', 'Read input from stdin (Hook mode)')
|
||||
.option('--json', 'Output as JSON')
|
||||
@@ -374,3 +375,6 @@ export function run(argv: string[]): void {
|
||||
|
||||
program.parse(argv);
|
||||
}
|
||||
|
||||
// Invoke CLI when run directly
|
||||
run(process.argv);
|
||||
|
||||
@@ -11,6 +11,7 @@ import chalk from 'chalk';
|
||||
|
||||
interface SpecOptions {
|
||||
dimension?: string;
|
||||
category?: string;
|
||||
keywords?: string;
|
||||
stdin?: boolean;
|
||||
json?: boolean;
|
||||
@@ -58,13 +59,13 @@ function getProjectPath(hookCwd?: string): string {
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Load action - load specs matching dimension/keywords.
|
||||
* Load action - load specs matching dimension/category/keywords.
|
||||
*
|
||||
* CLI mode: --dimension and --keywords options, outputs formatted markdown.
|
||||
* CLI mode: --dimension, --category, --keywords options, outputs formatted markdown.
|
||||
* Hook mode: --stdin reads JSON {session_id, cwd, user_prompt}, outputs JSON {continue, systemMessage}.
|
||||
*/
|
||||
async function loadAction(options: SpecOptions): Promise<void> {
|
||||
const { stdin, dimension, keywords: keywordsInput } = options;
|
||||
const { stdin, dimension, category, keywords: keywordsInput } = options;
|
||||
let projectPath: string;
|
||||
let stdinData: StdinData | undefined;
|
||||
|
||||
@@ -96,6 +97,7 @@ async function loadAction(options: SpecOptions): Promise<void> {
|
||||
const result = await loadSpecs({
|
||||
projectPath,
|
||||
dimension: dimension as 'specs' | 'personal' | undefined,
|
||||
category: category as 'general' | 'exploration' | 'planning' | 'execution' | undefined,
|
||||
keywords,
|
||||
outputFormat: stdin ? 'hook' : 'cli',
|
||||
stdinData,
|
||||
|
||||
@@ -151,7 +151,7 @@ export async function handleSpecRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Get spec stats (dimensions count + injection length info)
|
||||
// API: Get spec stats (optimized - uses cached contentLength)
|
||||
if (pathname === '/api/specs/stats' && req.method === 'GET') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const resolvedPath = resolvePath(projectPath);
|
||||
@@ -186,18 +186,8 @@ export async function handleSpecRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
|
||||
for (const entry of index.entries) {
|
||||
count++;
|
||||
// Calculate content length by reading the file
|
||||
const filePath = join(resolvedPath, entry.file);
|
||||
let contentLength = 0;
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
const rawContent = readFileSync(filePath, 'utf-8');
|
||||
// Strip frontmatter to get actual content length
|
||||
const matter = (await import('gray-matter')).default;
|
||||
const parsed = matter(rawContent);
|
||||
contentLength = parsed.content.length;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
// Use cached contentLength instead of re-reading file
|
||||
const contentLength = entry.contentLength || 0;
|
||||
|
||||
if (entry.readMode === 'required') {
|
||||
requiredCount++;
|
||||
@@ -228,5 +218,109 @@ export async function handleSpecRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Get injection preview (files list and content preview)
|
||||
if (pathname === '/api/specs/injection-preview' && req.method === 'GET') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const resolvedPath = resolvePath(projectPath);
|
||||
const mode = url.searchParams.get('mode') || 'required'; // required | all | keywords
|
||||
const preview = url.searchParams.get('preview') === 'true';
|
||||
|
||||
try {
|
||||
const { getDimensionIndex, SPEC_DIMENSIONS } = await import(
|
||||
'../../tools/spec-index-builder.js'
|
||||
);
|
||||
|
||||
interface InjectionFile {
|
||||
file: string;
|
||||
title: string;
|
||||
dimension: string;
|
||||
category: string;
|
||||
scope: string;
|
||||
readMode: string;
|
||||
priority: string;
|
||||
contentLength: number;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
const files: InjectionFile[] = [];
|
||||
let totalLength = 0;
|
||||
|
||||
for (const dim of SPEC_DIMENSIONS) {
|
||||
const index = await getDimensionIndex(resolvedPath, dim);
|
||||
|
||||
for (const entry of index.entries) {
|
||||
// Filter by mode
|
||||
if (mode === 'required' && entry.readMode !== 'required') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileData: InjectionFile = {
|
||||
file: entry.file,
|
||||
title: entry.title,
|
||||
dimension: entry.dimension,
|
||||
category: entry.category || 'general',
|
||||
scope: entry.scope,
|
||||
readMode: entry.readMode,
|
||||
priority: entry.priority,
|
||||
contentLength: entry.contentLength || 0
|
||||
};
|
||||
|
||||
// Include content if preview requested
|
||||
if (preview) {
|
||||
const filePath = join(resolvedPath, entry.file);
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
const rawContent = readFileSync(filePath, 'utf-8');
|
||||
const matter = (await import('gray-matter')).default;
|
||||
const parsed = matter(rawContent);
|
||||
fileData.content = parsed.content.trim();
|
||||
} catch {
|
||||
fileData.content = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
files.push(fileData);
|
||||
totalLength += fileData.contentLength;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority
|
||||
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
files.sort((a, b) =>
|
||||
(priorityOrder[a.priority as keyof typeof priorityOrder] || 2) -
|
||||
(priorityOrder[b.priority as keyof typeof priorityOrder] || 2)
|
||||
);
|
||||
|
||||
// Get maxLength for percentage calculation
|
||||
let maxLength = 8000;
|
||||
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const rawSettings = readFileSync(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(rawSettings) as {
|
||||
system?: { injectionControl?: { maxLength?: number } };
|
||||
};
|
||||
maxLength = settings?.system?.injectionControl?.maxLength || 8000;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
files,
|
||||
stats: {
|
||||
count: files.length,
|
||||
totalLength,
|
||||
maxLength,
|
||||
percentage: Math.round((totalLength / maxLength) * 100)
|
||||
}
|
||||
}));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -626,11 +626,12 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
if (await handleFilesRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// System routes (data, health, version, paths, shutdown, notify, storage, dialog, a2ui answer broker)
|
||||
// System routes (data, health, version, paths, shutdown, notify, storage, dialog, a2ui answer broker, system settings)
|
||||
if (pathname === '/api/data' || pathname === '/api/health' ||
|
||||
pathname === '/api/version-check' || pathname === '/api/shutdown' ||
|
||||
pathname === '/api/recent-paths' || pathname === '/api/switch-path' ||
|
||||
pathname === '/api/remove-recent-path' || pathname === '/api/system/notify' ||
|
||||
pathname === '/api/system/settings' || pathname === '/api/system/hooks/install-recommended' ||
|
||||
pathname === '/api/a2ui/answer' ||
|
||||
pathname.startsWith('/api/storage/') || pathname.startsWith('/api/dialog/')) {
|
||||
if (await handleSystemRoutes(routeContext)) return;
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface SpecIndexEntry {
|
||||
priority: 'critical' | 'high' | 'medium' | 'low';
|
||||
/** Scope: global (from ~/.ccw/) or project (from .ccw/) */
|
||||
scope: 'global' | 'project';
|
||||
/** Content length (body only, without frontmatter) - cached for performance */
|
||||
contentLength: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,16 +349,18 @@ function parseSpecFile(
|
||||
}
|
||||
|
||||
const data = parsed.data as Record<string, unknown>;
|
||||
// Calculate content length (body only, without frontmatter)
|
||||
const contentLength = parsed.content.length;
|
||||
|
||||
// Extract and validate frontmatter fields
|
||||
const title = extractString(data, 'title');
|
||||
if (!title) {
|
||||
// Title is required - use filename as fallback
|
||||
const fallbackTitle = basename(filePath, extname(filePath));
|
||||
return buildEntry(fallbackTitle, filePath, dimension, projectPath, data, scope);
|
||||
return buildEntry(fallbackTitle, filePath, dimension, projectPath, data, scope, contentLength);
|
||||
}
|
||||
|
||||
return buildEntry(title, filePath, dimension, projectPath, data, scope);
|
||||
return buildEntry(title, filePath, dimension, projectPath, data, scope, contentLength);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -368,7 +372,8 @@ function buildEntry(
|
||||
dimension: string,
|
||||
projectPath: string,
|
||||
data: Record<string, unknown>,
|
||||
scope: 'global' | 'project' = 'project'
|
||||
scope: 'global' | 'project' = 'project',
|
||||
contentLength: number = 0
|
||||
): SpecIndexEntry {
|
||||
// Compute relative file path from project root using path.relative
|
||||
// Normalize to forward slashes for cross-platform consistency
|
||||
@@ -398,6 +403,7 @@ function buildEntry(
|
||||
readMode,
|
||||
priority,
|
||||
scope,
|
||||
contentLength,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
SPEC_DIMENSIONS,
|
||||
SPEC_CATEGORIES,
|
||||
type SpecDimension,
|
||||
type SpecCategory,
|
||||
} from './spec-index-builder.js';
|
||||
|
||||
import {
|
||||
@@ -43,6 +44,8 @@ export interface SpecLoadOptions {
|
||||
projectPath: string;
|
||||
/** Specific dimension to load (loads all if omitted) */
|
||||
dimension?: SpecDimension;
|
||||
/** Workflow stage category filter (loads matching category specs) */
|
||||
category?: SpecCategory;
|
||||
/** Pre-extracted keywords (skips extraction if provided) */
|
||||
keywords?: string[];
|
||||
/** Output format: 'cli' for markdown, 'hook' for JSON */
|
||||
@@ -138,7 +141,7 @@ const SPEC_PRIORITY_WEIGHT: Record<string, number> = {
|
||||
* @returns SpecLoadResult with formatted content
|
||||
*/
|
||||
export async function loadSpecs(options: SpecLoadOptions): Promise<SpecLoadResult> {
|
||||
const { projectPath, outputFormat, debug } = options;
|
||||
const { projectPath, outputFormat, debug, category } = options;
|
||||
|
||||
// Get injection control settings
|
||||
const maxLength = options.maxLength ?? 8000;
|
||||
@@ -149,6 +152,9 @@ export async function loadSpecs(options: SpecLoadOptions): Promise<SpecLoadResul
|
||||
|
||||
if (debug) {
|
||||
debugLog(`Extracted ${keywords.length} keywords: [${keywords.join(', ')}]`);
|
||||
if (category) {
|
||||
debugLog(`Category filter: ${category}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Determine which dimensions to process
|
||||
@@ -164,7 +170,7 @@ export async function loadSpecs(options: SpecLoadOptions): Promise<SpecLoadResul
|
||||
const index = await getDimensionIndex(projectPath, dim);
|
||||
totalScanned += index.entries.length;
|
||||
|
||||
const { required, matched } = filterSpecs(index, keywords);
|
||||
const { required, matched } = filterSpecs(index, keywords, category);
|
||||
|
||||
if (debug) {
|
||||
debugLog(
|
||||
@@ -229,23 +235,30 @@ export async function loadSpecs(options: SpecLoadOptions): Promise<SpecLoadResul
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Filter specs by readMode and keyword match.
|
||||
* Filter specs by readMode, category, and keyword match.
|
||||
*
|
||||
* - required: all entries with readMode === 'required'
|
||||
* - matched: entries with readMode === 'optional' that have keyword intersection
|
||||
* - required: all entries with readMode === 'required' (and matching category if specified)
|
||||
* - matched: entries with readMode === 'optional' that have keyword intersection (and matching category if specified)
|
||||
*
|
||||
* @param index - The dimension index to filter
|
||||
* @param keywords - Extracted prompt keywords
|
||||
* @param category - Optional category filter for workflow stage
|
||||
* @returns Separated required and matched entries (deduplicated)
|
||||
*/
|
||||
export function filterSpecs(
|
||||
index: DimensionIndex,
|
||||
keywords: string[]
|
||||
keywords: string[],
|
||||
category?: SpecCategory
|
||||
): { required: SpecIndexEntry[]; matched: SpecIndexEntry[] } {
|
||||
const required: SpecIndexEntry[] = [];
|
||||
const matched: SpecIndexEntry[] = [];
|
||||
|
||||
for (const entry of index.entries) {
|
||||
// Category filter: skip if category specified and doesn't match
|
||||
if (category && entry.category !== category) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.readMode === 'required') {
|
||||
required.push(entry);
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user