Files
Claude-Code-Workflow/ccw/src/commands/session-path-resolver.ts
catlog22 b22839c99f fix: Resolve MCP installation issues and enhance path resolution
- Fixed API endpoint mismatches in mcp-manager.js to ensure global install/update buttons function correctly.
- Corrected undefined function references in mcp-manager.js for project installation.
- Refactored event handling to eliminate global scope pollution in mcp-manager.js.
- Added comprehensive debugging guide for MCP installation issues.
- Implemented a session path resolver to infer content types from filenames and paths, improving usability.
- Introduced tests for embeddings improvements in init and status commands to verify functionality.
2025-12-17 22:05:16 +08:00

373 lines
11 KiB
TypeScript

/**
* Session Path Resolver - Smart file path to content_type resolution
* Eliminates need for --type parameter by inferring from filenames and paths
*/
import { basename, dirname, join } from 'path';
// Supported content types (from session-manager.ts)
type ContentType =
| 'session' | 'plan' | 'task' | 'summary' | 'process' | 'chat' | 'brainstorm'
| 'review-dim' | 'review-iter' | 'review-fix' | 'todo' | 'context'
| 'lite-plan' | 'lite-fix-plan' | 'exploration' | 'explorations-manifest'
| 'diagnosis' | 'diagnoses-manifest' | 'clarifications' | 'execution-context' | 'session-metadata';
export interface ResolverResult {
contentType: ContentType;
pathParams?: Record<string, string>;
resolvedPath: string; // Relative path within session directory
}
export interface ResolverContext {
sessionPath: string;
sessionLocation: 'active' | 'archived' | 'lite-plan' | 'lite-fix';
}
export class PathResolutionError extends Error {
code: 'NOT_FOUND' | 'INVALID_PATH';
suggestions: string[];
constructor(code: 'NOT_FOUND' | 'INVALID_PATH', message: string, suggestions: string[] = []) {
super(message);
this.name = 'PathResolutionError';
this.code = code;
this.suggestions = suggestions;
}
}
/**
* Task ID patterns (IMPL-*, TEST-*, DOC-*, REFACTOR-*, TASK-*)
*/
const TASK_ID_PATTERNS = [
/^(IMPL|TEST|DOC|REFACTOR|TASK)-\d+\.json$/i,
];
/**
* Summary filename pattern (*-summary.md)
*/
const SUMMARY_PATTERN = /^(.+)-summary\.md$/i;
/**
* Path prefix to content_type mapping
*/
const PATH_PREFIX_TO_CONTENT_TYPE: Record<string, ContentType> = {
'.task/': 'task',
'.summaries/': 'summary',
'.process/': 'process',
'.chat/': 'chat',
'.brainstorming/': 'brainstorm',
'.review/dimensions/': 'review-dim',
'.review/iterations/': 'review-iter',
'.review/fixes/': 'review-fix',
};
/**
* Exact filename to content_type mapping
*/
const EXACT_FILENAME_TO_CONTENT_TYPE: Record<string, ContentType> = {
'IMPL_PLAN.md': 'plan',
'TODO_LIST.md': 'todo',
'workflow-session.json': 'session',
'context-package.json': 'context',
'plan.json': 'lite-plan', // Will be overridden by session location
'fix-plan.json': 'lite-fix-plan', // Specific to lite-fix sessions
'session-metadata.json': 'session-metadata',
'clarifications.json': 'clarifications',
'execution-context.json': 'execution-context',
'explorations-manifest.json': 'explorations-manifest',
'diagnoses-manifest.json': 'diagnoses-manifest',
};
/**
* Extract task ID from filename
* Examples: IMPL-001.json → IMPL-001
*/
function extractTaskId(filename: string): string | null {
const match = filename.match(/^([A-Z]+-\d+)\.json$/i);
return match ? match[1] : null;
}
/**
* Extract summary task ID from filename
* Examples: IMPL-001-summary.md → IMPL-001
*/
function extractSummaryTaskId(filename: string): string | null {
const match = filename.match(SUMMARY_PATTERN);
return match ? match[1] : null;
}
/**
* Extract review dimension from path
* Examples: .review/dimensions/security.json → security
*/
function extractReviewDimension(filepath: string): string | null {
const match = filepath.match(/\.review\/dimensions\/(.+)\.json$/);
return match ? match[1] : null;
}
/**
* Extract review iteration from path
* Examples: .review/iterations/1.json → 1
*/
function extractReviewIteration(filepath: string): string | null {
const match = filepath.match(/\.review\/iterations\/(\d+)\.json$/);
return match ? match[1] : null;
}
/**
* Extract filename from relative path within review fixes
* Examples: .review/fixes/finding-001.json → finding-001.json
*/
function extractReviewFixFilename(filepath: string): string | null {
const match = filepath.match(/\.review\/fixes\/(.+)$/);
return match ? match[1] : null;
}
/**
* Extract filename from process/chat/brainstorm paths
* Examples: .process/archiving.json → archiving.json
*/
function extractProcessFilename(filepath: string, prefix: string): string | null {
const pattern = new RegExp(`^${prefix.replace(/\//g, '\\/')}(.+)$`);
const match = filepath.match(pattern);
return match ? match[1] : null;
}
/**
* Resolve relative path (contains '/') to content_type
*/
function resolveRelativePath(filepath: string): ResolverResult | null {
// Security: Reject path traversal attempts
if (filepath.includes('..') || filepath.startsWith('/')) {
throw new PathResolutionError(
'INVALID_PATH',
'Path traversal and absolute paths are not allowed',
['Use relative paths within the session directory']
);
}
// Check path prefix matches
for (const [prefix, contentType] of Object.entries(PATH_PREFIX_TO_CONTENT_TYPE)) {
if (filepath.startsWith(prefix)) {
const pathParams: Record<string, string> = {};
// Extract specific parameters based on content type
if (contentType === 'task') {
const taskId = extractTaskId(basename(filepath));
if (taskId) {
pathParams.task_id = taskId;
}
} else if (contentType === 'summary') {
const taskId = extractSummaryTaskId(basename(filepath));
if (taskId) {
pathParams.task_id = taskId;
}
} else if (contentType === 'review-dim') {
const dimension = extractReviewDimension(filepath);
if (dimension) {
pathParams.dimension = dimension;
}
} else if (contentType === 'review-iter') {
const iteration = extractReviewIteration(filepath);
if (iteration) {
pathParams.iteration = iteration;
}
} else if (contentType === 'review-fix') {
const filename = extractReviewFixFilename(filepath);
if (filename) {
pathParams.filename = filename;
}
} else if (contentType === 'process' || contentType === 'chat' || contentType === 'brainstorm') {
const filename = extractProcessFilename(filepath, prefix);
if (filename) {
pathParams.filename = filename;
}
}
return {
contentType,
pathParams: Object.keys(pathParams).length > 0 ? pathParams : undefined,
resolvedPath: filepath,
};
}
}
return null;
}
/**
* Resolve simple filename (no '/') to content_type
*/
function resolveFilename(
filename: string,
context: ResolverContext
): ResolverResult | null {
// Check exact filename matches first
if (EXACT_FILENAME_TO_CONTENT_TYPE[filename]) {
let contentType = EXACT_FILENAME_TO_CONTENT_TYPE[filename];
// Override plan.json based on session location
if (filename === 'plan.json') {
if (context.sessionLocation === 'lite-plan') {
contentType = 'lite-plan';
} else if (context.sessionLocation === 'lite-fix') {
contentType = 'lite-fix-plan';
}
}
return {
contentType,
resolvedPath: filename,
};
}
// Check task ID patterns (IMPL-001.json, TEST-042.json, etc.)
for (const pattern of TASK_ID_PATTERNS) {
if (pattern.test(filename)) {
const taskId = extractTaskId(filename);
if (taskId) {
return {
contentType: 'task',
pathParams: { task_id: taskId },
resolvedPath: `.task/${filename}`,
};
}
}
}
// Check summary pattern (*-summary.md)
const summaryTaskId = extractSummaryTaskId(filename);
if (summaryTaskId) {
return {
contentType: 'summary',
pathParams: { task_id: summaryTaskId },
resolvedPath: `.summaries/${filename}`,
};
}
// Check exploration pattern (exploration-{angle}.json)
const explorationMatch = filename.match(/^exploration-(.+)\.json$/);
if (explorationMatch) {
return {
contentType: 'exploration',
pathParams: { angle: explorationMatch[1] },
resolvedPath: filename,
};
}
// Check diagnosis pattern (diagnosis-{angle}.json)
const diagnosisMatch = filename.match(/^diagnosis-(.+)\.json$/);
if (diagnosisMatch) {
return {
contentType: 'diagnosis',
pathParams: { angle: diagnosisMatch[1] },
resolvedPath: filename,
};
}
return null;
}
/**
* Main resolver function - resolves filename/path to content_type
*
* @param filename - Filename or relative path
* @param context - Session context (path and location)
* @returns Resolver result with content type, path params, and resolved path
* @throws PathResolutionError if path is invalid or cannot be resolved
*
* @example
* // Filename examples
* resolveFilePath('IMPL-001.json', context)
* // → { contentType: 'task', pathParams: {task_id: 'IMPL-001'}, resolvedPath: '.task/IMPL-001.json' }
*
* resolveFilePath('IMPL_PLAN.md', context)
* // → { contentType: 'plan', resolvedPath: 'IMPL_PLAN.md' }
*
* // Relative path examples
* resolveFilePath('.task/IMPL-001.json', context)
* // → { contentType: 'task', pathParams: {task_id: 'IMPL-001'}, resolvedPath: '.task/IMPL-001.json' }
*
* resolveFilePath('.review/dimensions/security.json', context)
* // → { contentType: 'review-dim', pathParams: {dimension: 'security'}, resolvedPath: '...' }
*/
export function resolveFilePath(
filename: string,
context: ResolverContext
): ResolverResult {
if (!filename) {
throw new PathResolutionError(
'INVALID_PATH',
'Filename is required',
['Usage: ccw session <session-id> read <filename|path>']
);
}
// Normalize path separators (handle Windows paths)
const normalizedFilename = filename.replace(/\\/g, '/');
// Security: Validate filename
if (normalizedFilename.includes('\0') || normalizedFilename.trim() === '') {
throw new PathResolutionError(
'INVALID_PATH',
'Invalid filename or path',
['Use valid filename or relative path']
);
}
// RULE 1: Relative path (contains '/')
if (normalizedFilename.includes('/')) {
const result = resolveRelativePath(normalizedFilename);
if (result) {
return result;
}
throw new PathResolutionError(
'NOT_FOUND',
`Unknown path pattern: ${normalizedFilename}`,
[
'Supported paths:',
' .task/*.json (tasks)',
' .summaries/*.md (summaries)',
' .process/* (process files)',
' .chat/* (chat files)',
' .brainstorming/* (brainstorm files)',
' .review/dimensions/*.json (review dimensions)',
' .review/iterations/*.json (review iterations)',
' .review/fixes/* (review fixes)',
]
);
}
// RULE 2: Simple filename
const result = resolveFilename(normalizedFilename, context);
if (result) {
return result;
}
// Resolution failed
throw new PathResolutionError(
'NOT_FOUND',
`Unknown file: ${normalizedFilename}`,
[
'Supported filenames:',
' IMPL-*.json, TEST-*.json, DOC-*.json, REFACTOR-*.json, TASK-*.json (tasks)',
' IMPL_PLAN.md (plan)',
' TODO_LIST.md (todo)',
' workflow-session.json (session metadata)',
' context-package.json (context)',
' session-metadata.json (session metadata for lite sessions)',
' plan.json (lite-plan or lite-fix-plan, depending on session location)',
' fix-plan.json (lite-fix-plan)',
' *-summary.md (summaries)',
' exploration-{angle}.json (explorations)',
' diagnosis-{angle}.json (diagnoses)',
' explorations-manifest.json (exploration manifest)',
' diagnoses-manifest.json (diagnosis manifest)',
' clarifications.json (clarifications)',
' execution-context.json (execution context)',
'Or use a relative path: .task/IMPL-001.json',
]
);
}