diff --git a/src/mcp/tools/attachPackedOutputTool.ts b/src/mcp/tools/attachPackedOutputTool.ts index 66211dd68..dbd37e866 100644 --- a/src/mcp/tools/attachPackedOutputTool.ts +++ b/src/mcp/tools/attachPackedOutputTool.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; +import { defaultFilePathMap } from '../../config/configSchema.js'; import type { ProcessedFile } from '../../core/file/fileTypes.js'; import { type McpToolMetrics, @@ -17,9 +18,13 @@ import { const attachPackedOutputInputSchema = z.object({ path: z .string() - .describe('Path to a directory containing repomix-output.xml or direct path to a packed repository XML file'), + .describe( + 'Path to a directory containing repomix output file or direct path to a packed repository file (supports .xml, .md, .txt, .json formats)', + ), topFilesLength: z .number() + .int() + .min(1) .optional() .default(10) .describe('Number of largest files by size to display in the metrics summary (default: 10)'), @@ -39,27 +44,47 @@ const attachPackedOutputOutputSchema = z.object({ }); /** - * Resolves the path to a repomix output file - * @param inputPath Path to a directory containing repomix-output.xml or direct path to an XML file - * @returns The resolved path to the repomix output file - * @throws Error if the file doesn't exist or isn't a valid XML file + * Resolves the path to a repomix output file and detects its format + * @param inputPath Path to a directory containing repomix output file or direct path to a packed repository file + * @returns Object containing the resolved path and detected format + * @throws Error if the file doesn't exist or isn't a supported format */ -async function resolveOutputFilePath(inputPath: string): Promise { +async function resolveOutputFilePath(inputPath: string): Promise<{ filePath: string; format: string }> { try { const stats = await fs.stat(inputPath); if (stats.isDirectory()) { - // If it's a directory, look for repomix-output.xml inside - const outputFilePath = path.join(inputPath, 'repomix-output.xml'); - await fs.access(outputFilePath); // Will throw if file doesn't exist - return outputFilePath; + // If it's a directory, look for repomix output files in priority order + const possibleFiles = Object.values(defaultFilePathMap); + + for (const fileName of possibleFiles) { + const outputFilePath = path.join(inputPath, fileName); + try { + await fs.access(outputFilePath); + const format = getFormatFromFileName(fileName); + return { filePath: outputFilePath, format }; + } catch { + // File doesn't exist, continue to next + } + } + + throw new Error( + `No repomix output file found in directory: ${inputPath}. Looking for: ${possibleFiles.join(', ')}`, + ); } - // If it's a file, check if it's an XML file - if (!inputPath.toLowerCase().endsWith('.xml')) { - throw new Error('The provided file is not an XML file. Only XML files are supported.'); + // If it's a file, check if it's a supported format + const supportedExtensions = Object.values(defaultFilePathMap).map((file) => path.extname(file)); + const fileExtension = path.extname(inputPath).toLowerCase(); + + if (!supportedExtensions.includes(fileExtension)) { + throw new Error( + `Unsupported file format: ${fileExtension}. Supported formats: ${supportedExtensions.join(', ')}`, + ); } - return inputPath; + + const format = getFormatFromExtension(fileExtension); + return { filePath: inputPath, format }; } catch (error) { if (error instanceof Error && error.message.includes('ENOENT')) { throw new Error(`File or directory not found for path: ${inputPath}`, { cause: error }); @@ -68,22 +93,82 @@ async function resolveOutputFilePath(inputPath: string): Promise { } } +/** + * Get format from file name + */ +function getFormatFromFileName(fileName: string): string { + for (const [format, defaultFileName] of Object.entries(defaultFilePathMap)) { + if (fileName === defaultFileName) { + return format; + } + } + return 'xml'; // fallback +} + +/** + * Get format from file extension + */ +function getFormatFromExtension(extension: string): string { + switch (extension) { + case '.xml': + return 'xml'; + case '.md': + return 'markdown'; + case '.txt': + return 'plain'; + case '.json': + return 'json'; + default: + return 'xml'; // fallback + } +} + /** * Extract file paths and character counts from a repomix output XML file * @param content The content of the repomix output XML file * @returns An object containing an array of file paths and a record of file paths to character counts */ -function extractFileMetrics(content: string): { filePaths: string[]; fileCharCounts: Record } { +function extractFileMetrics( + content: string, + format: string, +): { filePaths: string[]; fileCharCounts: Record } { + switch (format) { + case 'xml': + return extractFileMetricsXml(content); + case 'markdown': + return extractFileMetricsMarkdown(content); + case 'plain': + return extractFileMetricsPlain(content); + case 'json': + return extractFileMetricsJson(content); + default: + // Fallback to XML parsing + return extractFileMetricsXml(content); + } +} + +/** + * Create processed files from file paths + * @param filePaths Array of file paths + * @param charCounts Record of file paths to character counts + * @returns Array of ProcessedFile objects + */ +function createProcessedFiles(filePaths: string[], charCounts: Record): ProcessedFile[] { + return filePaths.map((path) => ({ + path, + content: ''.padEnd(charCounts[path]), // Create a string of the appropriate length + })); +} + +/** + * Extract file metrics from XML format + */ +function extractFileMetricsXml(content: string): { filePaths: string[]; fileCharCounts: Record } { const filePaths: string[] = []; const fileCharCounts: Record = {}; const fileRegex = /([\s\S]*?)<\/file>/g; - let match: RegExpExecArray | null; - while (true) { - match = fileRegex.exec(content); - if (!match) { - break; - } + for (const match of content.matchAll(fileRegex)) { const filePath = match[1]; const fileContent = match[2]; filePaths.push(filePath); @@ -94,16 +179,67 @@ function extractFileMetrics(content: string): { filePaths: string[]; fileCharCou } /** - * Create processed files from file paths - * @param filePaths Array of file paths - * @param charCounts Record of file paths to character counts - * @returns Array of ProcessedFile objects + * Extract file metrics from Markdown format */ -function createProcessedFiles(filePaths: string[], charCounts: Record): ProcessedFile[] { - return filePaths.map((path) => ({ - path, - content: ''.padEnd(charCounts[path]), // Create a string of the appropriate length - })); +function extractFileMetricsMarkdown(content: string): { filePaths: string[]; fileCharCounts: Record } { + const filePaths: string[] = []; + const fileCharCounts: Record = {}; + + // Pattern: ## File: [path] followed by code block + const fileRegex = /## File: ([^\r\n]+)\r?\n```[^\r\n]*\r?\n([\s\S]*?)```/g; + + for (const match of content.matchAll(fileRegex)) { + const filePath = match[1]; + const fileContent = match[2]; + filePaths.push(filePath); + fileCharCounts[filePath] = fileContent.length; + } + + return { filePaths, fileCharCounts }; +} + +/** + * Extract file metrics from Plain text format + */ +function extractFileMetricsPlain(content: string): { filePaths: string[]; fileCharCounts: Record } { + const filePaths: string[] = []; + const fileCharCounts: Record = {}; + + // Pattern: separator lines with "File: [path]" followed by content + const fileRegex = /={16,}\r?\nFile: ([^\r\n]+)\r?\n={16,}\r?\n([\s\S]*?)(?=\r?\n={16,}\r?\n|$)/g; + + for (const match of content.matchAll(fileRegex)) { + const filePath = match[1]; + const fileContent = match[2].trim(); + filePaths.push(filePath); + fileCharCounts[filePath] = fileContent.length; + } + + return { filePaths, fileCharCounts }; +} + +/** + * Extract file metrics from JSON format + */ +function extractFileMetricsJson(content: string): { filePaths: string[]; fileCharCounts: Record } { + const filePaths: string[] = []; + const fileCharCounts: Record = {}; + + try { + const jsonData = JSON.parse(content); + const files = jsonData.files || {}; + + for (const [filePath, fileContent] of Object.entries(files)) { + if (typeof fileContent === 'string') { + filePaths.push(filePath); + fileCharCounts[filePath] = fileContent.length; + } + } + } catch { + // If JSON parsing fails, return empty results + } + + return { filePaths, fileCharCounts }; } /** @@ -115,7 +251,8 @@ export const registerAttachPackedOutputTool = (mcpServer: McpServer) => { { title: 'Attach Packed Output', description: `Attach an existing Repomix packed output file for AI analysis. -This tool accepts either a directory containing a repomix-output.xml file or a direct path to an XML file. +This tool accepts either a directory containing a repomix output file or a direct path to a packed repository file. +Supports multiple formats: XML (structured with tags), Markdown (human-readable with ## headers and code blocks), JSON (machine-readable with files as key-value pairs), and Plain text (simple format with separators). Calling the tool again with the same file path will refresh the content if the file has been updated. It will return in that case a new output ID and the updated content.`, inputSchema: attachPackedOutputInputSchema.shape, @@ -130,13 +267,13 @@ It will return in that case a new output ID and the updated content.`, async ({ path: inputPath, topFilesLength }): Promise => { try { // Resolve the path to the repomix output file - const outputFilePath = await resolveOutputFilePath(inputPath); + const { filePath: outputFilePath, format } = await resolveOutputFilePath(inputPath); // Read the file content const content = await fs.readFile(outputFilePath, 'utf8'); - // Extract file paths and character counts from the XML content - const { filePaths, fileCharCounts } = extractFileMetrics(content); + // Extract file paths and character counts from the content + const { filePaths, fileCharCounts } = extractFileMetrics(content, format); // Calculate metrics const totalCharacters = Object.values(fileCharCounts).reduce((sum, count) => sum + count, 0); diff --git a/src/mcp/tools/packCodebaseTool.ts b/src/mcp/tools/packCodebaseTool.ts index c388eb3e4..2fcc9126d 100644 --- a/src/mcp/tools/packCodebaseTool.ts +++ b/src/mcp/tools/packCodebaseTool.ts @@ -4,6 +4,7 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import { runCli } from '../../cli/cliRun.js'; import type { CliOptions } from '../../cli/types.js'; +import { defaultFilePathMap, repomixOutputStyleSchema } from '../../config/configSchema.js'; import { buildMcpToolErrorResponse, convertErrorToJson, @@ -33,9 +34,16 @@ const packCodebaseInputSchema = z.object({ ), topFilesLength: z .number() + .int() + .min(1) .optional() .default(10) .describe('Number of largest files by size to display in the metrics summary for codebase analysis (default: 10)'), + style: repomixOutputStyleSchema + .default('xml') + .describe( + 'Output format style: xml (structured tags, default), markdown (human-readable with code blocks), json (machine-readable key-value), or plain (simple text with separators)', + ), }); const packCodebaseOutputSchema = z.object({ @@ -54,7 +62,7 @@ export const registerPackCodebaseTool = (mcpServer: McpServer) => { { title: 'Pack Local Codebase', description: - 'Package a local code directory into a consolidated XML file for AI analysis. This tool analyzes the codebase structure, extracts relevant code content, and generates a comprehensive report including metrics, file tree, and formatted code content. Supports Tree-sitter compression for efficient token usage.', + 'Package a local code directory into a consolidated file for AI analysis. This tool analyzes the codebase structure, extracts relevant code content, and generates a comprehensive report including metrics, file tree, and formatted code content. Supports multiple output formats: XML (structured with tags), Markdown (human-readable with ## headers and code blocks), JSON (machine-readable with files as key-value pairs), and Plain text (simple format with separators). Also supports Tree-sitter compression for efficient token usage.', inputSchema: packCodebaseInputSchema.shape, outputSchema: packCodebaseOutputSchema.shape, annotations: { @@ -64,19 +72,27 @@ export const registerPackCodebaseTool = (mcpServer: McpServer) => { openWorldHint: false, }, }, - async ({ directory, compress, includePatterns, ignorePatterns, topFilesLength }): Promise => { + async ({ + directory, + compress, + includePatterns, + ignorePatterns, + topFilesLength, + style, + }): Promise => { let tempDir = ''; try { tempDir = await createToolWorkspace(); - const outputFilePath = path.join(tempDir, 'repomix-output.xml'); + const outputFileName = defaultFilePathMap[style]; + const outputFilePath = path.join(tempDir, outputFileName); const cliOptions = { compress, include: includePatterns, ignore: ignorePatterns, output: outputFilePath, - style: 'xml', + style, securityCheck: true, topFilesLen: topFilesLength, quiet: true, diff --git a/src/mcp/tools/packRemoteRepositoryTool.ts b/src/mcp/tools/packRemoteRepositoryTool.ts index fb3edcf22..e5a18321b 100644 --- a/src/mcp/tools/packRemoteRepositoryTool.ts +++ b/src/mcp/tools/packRemoteRepositoryTool.ts @@ -4,6 +4,7 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import { runCli } from '../../cli/cliRun.js'; import type { CliOptions } from '../../cli/types.js'; +import { defaultFilePathMap, repomixOutputStyleSchema } from '../../config/configSchema.js'; import { buildMcpToolErrorResponse, convertErrorToJson, @@ -37,9 +38,16 @@ const packRemoteRepositoryInputSchema = z.object({ ), topFilesLength: z .number() + .int() + .min(1) .optional() .default(10) .describe('Number of largest files by size to display in the metrics summary for codebase analysis (default: 10)'), + style: repomixOutputStyleSchema + .default('xml') + .describe( + 'Output format style: xml (structured tags, default), markdown (human-readable with code blocks), json (machine-readable key-value), or plain (simple text with separators)', + ), }); const packRemoteRepositoryOutputSchema = z.object({ @@ -58,7 +66,7 @@ export const registerPackRemoteRepositoryTool = (mcpServer: McpServer) => { { title: 'Pack Remote Repository', description: - 'Fetch, clone, and package a GitHub repository into a consolidated XML file for AI analysis. This tool automatically clones the remote repository, analyzes its structure, and generates a comprehensive report. Supports various GitHub URL formats and includes security checks to prevent exposure of sensitive information.', + 'Fetch, clone, and package a GitHub repository into a consolidated file for AI analysis. This tool automatically clones the remote repository, analyzes its structure, and generates a comprehensive report. Supports multiple output formats: XML (structured with tags), Markdown (human-readable with ## headers and code blocks), JSON (machine-readable with files as key-value pairs), and Plain text (simple format with separators). Also supports various GitHub URL formats and includes security checks to prevent exposure of sensitive information.', inputSchema: packRemoteRepositoryInputSchema.shape, outputSchema: packRemoteRepositoryOutputSchema.shape, annotations: { @@ -68,12 +76,13 @@ export const registerPackRemoteRepositoryTool = (mcpServer: McpServer) => { openWorldHint: true, }, }, - async ({ remote, compress, includePatterns, ignorePatterns, topFilesLength }): Promise => { + async ({ remote, compress, includePatterns, ignorePatterns, topFilesLength, style }): Promise => { let tempDir = ''; try { tempDir = await createToolWorkspace(); - const outputFilePath = path.join(tempDir, 'repomix-output.xml'); + const outputFileName = defaultFilePathMap[style]; + const outputFilePath = path.join(tempDir, outputFileName); const cliOptions = { remote, @@ -81,7 +90,7 @@ export const registerPackRemoteRepositoryTool = (mcpServer: McpServer) => { include: includePatterns, ignore: ignorePatterns, output: outputFilePath, - style: 'xml', + style, securityCheck: true, topFilesLen: topFilesLength, quiet: true, diff --git a/tests/mcp/tools/attachPackedOutputTool.test.ts b/tests/mcp/tools/attachPackedOutputTool.test.ts index a3fa2028d..c8175f153 100644 --- a/tests/mcp/tools/attachPackedOutputTool.test.ts +++ b/tests/mcp/tools/attachPackedOutputTool.test.ts @@ -45,6 +45,10 @@ describe('AttachPackedOutputTool', () => { vi.mocked(path.join).mockImplementation((...args) => args.join('/')); vi.mocked(path.basename).mockImplementation((p) => p.split('/').pop() || ''); vi.mocked(path.dirname).mockImplementation((p) => p.split('/').slice(0, -1).join('/') || '.'); + vi.mocked(path.extname).mockImplementation((p) => { + const parts = p.split('.'); + return parts.length > 1 ? `.${parts[parts.length - 1]}` : ''; + }); // Mock fs functions vi.mocked(fs.stat).mockResolvedValue({ @@ -142,8 +146,8 @@ describe('AttachPackedOutputTool', () => { ); }); - test('should handle non-XML file error', async () => { - const testFilePath = '/test/not-xml-file.txt'; + test('should handle non-supported file format error', async () => { + const testFilePath = '/test/not-supported-file.xyz'; const result = await toolHandler({ path: testFilePath }); @@ -222,4 +226,200 @@ describe('AttachPackedOutputTool', () => { undefined, ); }); + + test('should handle Markdown file path input', async () => { + const testFilePath = '/test/repomix-output.md'; + const markdownContent = ` +# Files + +## File: src/index.js +\`\`\`javascript +console.log('Hello'); +\`\`\` + +## File: src/utils.js +\`\`\`javascript +function helper() {} +\`\`\` + `; + + vi.mocked(fs.readFile).mockResolvedValue(markdownContent); + + await toolHandler({ path: testFilePath }); + + expect(fs.stat).toHaveBeenCalledWith(testFilePath); + expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'utf8'); + expect(formatPackToolResponse).toHaveBeenCalled(); + + const expectedFilePaths = ['src/index.js', 'src/utils.js']; + const expectedCharCounts = { + 'src/index.js': "console.log('Hello');\n".length, + 'src/utils.js': 'function helper() {}\n'.length, + }; + const totalCharacters = Object.values(expectedCharCounts).reduce((a, b) => a + b, 0); + + expect(formatPackToolResponse).toHaveBeenCalledWith( + { directory: 'test' }, + expect.objectContaining({ + totalFiles: 2, + totalCharacters: totalCharacters, + totalTokens: Math.floor(totalCharacters / 4), + safeFilePaths: expectedFilePaths, + fileCharCounts: expectedCharCounts, + }), + testFilePath, + undefined, + ); + }); + + test('should handle Plain text file path input', async () => { + const testFilePath = '/test/repomix-output.txt'; + const plainContent = ` +================ +File: src/index.js +================ +console.log('Hello'); + +================ +File: src/utils.js +================ +function helper() {} + `; + + vi.mocked(fs.readFile).mockResolvedValue(plainContent); + + await toolHandler({ path: testFilePath }); + + expect(fs.stat).toHaveBeenCalledWith(testFilePath); + expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'utf8'); + expect(formatPackToolResponse).toHaveBeenCalled(); + + const expectedFilePaths = ['src/index.js', 'src/utils.js']; + const expectedCharCounts = { + 'src/index.js': "console.log('Hello');".length, + 'src/utils.js': 'function helper() {}'.length, + }; + const totalCharacters = Object.values(expectedCharCounts).reduce((a, b) => a + b, 0); + + expect(formatPackToolResponse).toHaveBeenCalledWith( + { directory: 'test' }, + expect.objectContaining({ + totalFiles: 2, + totalCharacters: totalCharacters, + totalTokens: Math.floor(totalCharacters / 4), + safeFilePaths: expectedFilePaths, + fileCharCounts: expectedCharCounts, + }), + testFilePath, + undefined, + ); + }); + + test('should handle JSON file path input', async () => { + const testFilePath = '/test/repomix-output.json'; + const jsonContent = JSON.stringify({ + files: { + 'src/index.js': "console.log('Hello');", + 'src/utils.js': 'function helper() {}', + 'package.json': '{"name":"test"}', + }, + }); + + vi.mocked(fs.readFile).mockResolvedValue(jsonContent); + + await toolHandler({ path: testFilePath }); + + expect(fs.stat).toHaveBeenCalledWith(testFilePath); + expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'utf8'); + expect(formatPackToolResponse).toHaveBeenCalled(); + + const expectedFilePaths = ['src/index.js', 'src/utils.js', 'package.json']; + const expectedCharCounts = { + 'src/index.js': "console.log('Hello');".length, + 'src/utils.js': 'function helper() {}'.length, + 'package.json': '{"name":"test"}'.length, + }; + const totalCharacters = Object.values(expectedCharCounts).reduce((a, b) => a + b, 0); + + expect(formatPackToolResponse).toHaveBeenCalledWith( + { directory: 'test' }, + expect.objectContaining({ + totalFiles: 3, + totalCharacters: totalCharacters, + totalTokens: Math.floor(totalCharacters / 4), + safeFilePaths: expectedFilePaths, + fileCharCounts: expectedCharCounts, + }), + testFilePath, + undefined, + ); + }); + + test('should handle malformed JSON by returning zero metrics', async () => { + const testFilePath = '/test/repomix-output.json'; + const malformedJson = '{"files": {"test.js": "content"'; // missing closing braces + + vi.mocked(fs.readFile).mockResolvedValue(malformedJson); + + await toolHandler({ path: testFilePath }); + + expect(formatPackToolResponse).toHaveBeenCalledWith( + { directory: 'test' }, + expect.objectContaining({ + totalFiles: 0, + totalCharacters: 0, + totalTokens: 0, + safeFilePaths: [], + fileCharCounts: {}, + }), + testFilePath, + undefined, + ); + }); + + test('should handle CRLF line endings in Markdown format', async () => { + const testFilePath = '/test/repomix-output.md'; + const markdownContentWithCRLF = `# Files\r\n\r\n## File: src/index.js\r\n\`\`\`javascript\r\nconsole.log('Hello');\r\n\`\`\`\r\n\r\n## File: src/utils.js\r\n\`\`\`javascript\r\nfunction helper() {}\r\n\`\`\``; + + vi.mocked(fs.readFile).mockResolvedValue(markdownContentWithCRLF); + + await toolHandler({ path: testFilePath }); + + expect(formatPackToolResponse).toHaveBeenCalledWith( + { directory: 'test' }, + expect.objectContaining({ + totalFiles: 2, + safeFilePaths: ['src/index.js', 'src/utils.js'], + fileCharCounts: { + 'src/index.js': "console.log('Hello');\r\n".length, + 'src/utils.js': 'function helper() {}\r\n'.length, + }, + }), + testFilePath, + undefined, + ); + }); + + test('should handle CRLF line endings in Plain text format', async () => { + const testFilePath = '/test/repomix-output.txt'; + const plainContentWithCRLF = `================\r\nFile: src/index.js\r\n================\r\nconsole.log('Hello');\r\n\r\n================\r\nFile: src/utils.js\r\n================\r\nfunction helper() {}`; + + vi.mocked(fs.readFile).mockResolvedValue(plainContentWithCRLF); + + await toolHandler({ path: testFilePath }); + + expect(formatPackToolResponse).toHaveBeenCalledWith( + { directory: 'test' }, + expect.objectContaining({ + totalFiles: 2, + safeFilePaths: ['src/index.js', 'src/utils.js'], + fileCharCounts: { + 'src/index.js': "console.log('Hello');".length, + 'src/utils.js': 'function helper() {}'.length, + }, + }), + testFilePath, + undefined, + ); + }); });