repomix
Version:
A tool to pack repository contents to single file for AI consumption
201 lines (200 loc) • 8.36 kB
JavaScript
import fs from 'node:fs/promises';
import path from 'node:path';
import { z } from 'zod';
import { defaultFilePathMap } from '../../config/configSchema.js';
import { buildMcpToolErrorResponse, convertErrorToJson, formatPackToolResponse, } from './mcpToolRuntime.js';
const attachPackedOutputInputSchema = z.object({
path: z
.string()
.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)'),
});
const attachPackedOutputOutputSchema = z.object({
description: z.string().describe('Human-readable description of the attached output'),
result: z.string().describe('JSON string containing detailed metrics and file information'),
directoryStructure: z.string().describe('Tree structure extracted from the packed output'),
outputId: z.string().describe('Unique identifier for accessing the packed content'),
outputFilePath: z.string().describe('File path to the attached output file'),
totalFiles: z.number().describe('Total number of files in the packed output'),
totalTokens: z.number().describe('Total token count of the content'),
});
async function resolveOutputFilePath(inputPath) {
try {
const stats = await fs.stat(inputPath);
if (stats.isDirectory()) {
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 {
}
}
throw new Error(`No repomix output file found in directory: ${inputPath}. Looking for: ${possibleFiles.join(', ')}`);
}
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(', ')}`);
}
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 });
}
throw error;
}
}
function getFormatFromFileName(fileName) {
for (const [format, defaultFileName] of Object.entries(defaultFilePathMap)) {
if (fileName === defaultFileName) {
return format;
}
}
return 'xml';
}
function getFormatFromExtension(extension) {
switch (extension) {
case '.xml':
return 'xml';
case '.md':
return 'markdown';
case '.txt':
return 'plain';
case '.json':
return 'json';
default:
return 'xml';
}
}
function extractFileMetrics(content, format) {
switch (format) {
case 'xml':
return extractFileMetricsXml(content);
case 'markdown':
return extractFileMetricsMarkdown(content);
case 'plain':
return extractFileMetricsPlain(content);
case 'json':
return extractFileMetricsJson(content);
default:
return extractFileMetricsXml(content);
}
}
function createProcessedFiles(filePaths, charCounts) {
return filePaths.map((path) => ({
path,
content: ''.padEnd(charCounts[path]),
}));
}
function extractFileMetricsXml(content) {
const filePaths = [];
const fileCharCounts = {};
const fileRegex = /<file path="([^"]+)">([\s\S]*?)<\/file>/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 };
}
function extractFileMetricsMarkdown(content) {
const filePaths = [];
const fileCharCounts = {};
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 };
}
function extractFileMetricsPlain(content) {
const filePaths = [];
const fileCharCounts = {};
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 };
}
function extractFileMetricsJson(content) {
const filePaths = [];
const fileCharCounts = {};
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 {
}
return { filePaths, fileCharCounts };
}
export const registerAttachPackedOutputTool = (mcpServer) => {
mcpServer.registerTool('attach_packed_output', {
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 file or a direct path to a packed repository file.
Supports multiple formats: XML (structured with <file> 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,
outputSchema: attachPackedOutputOutputSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
}, async ({ path: inputPath, topFilesLength }) => {
try {
const { filePath: outputFilePath, format } = await resolveOutputFilePath(inputPath);
const content = await fs.readFile(outputFilePath, 'utf8');
const { filePaths, fileCharCounts } = extractFileMetrics(content, format);
const totalCharacters = Object.values(fileCharCounts).reduce((sum, count) => sum + count, 0);
const totalTokens = Math.floor(totalCharacters / 4);
const fileTokenCounts = {};
for (const [filePath, charCount] of Object.entries(fileCharCounts)) {
fileTokenCounts[filePath] = Math.floor(charCount / 4);
}
const processedFiles = createProcessedFiles(filePaths, fileCharCounts);
const packResult = {
totalFiles: filePaths.length,
totalCharacters,
totalTokens,
safeFilePaths: filePaths,
fileCharCounts,
fileTokenCounts,
processedFiles,
};
const context = {
directory: path.basename(path.dirname(outputFilePath)),
};
return await formatPackToolResponse(context, packResult, outputFilePath, topFilesLength);
}
catch (error) {
return buildMcpToolErrorResponse(convertErrorToJson(error));
}
});
};