UNPKG

knip-mcp-server

Version:

MCP server for knip.dev integration to help AI agents identify and clean up unused code

253 lines 10.1 kB
import { promises as fs } from 'node:fs'; import { join, resolve, relative } from 'node:path'; import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import { execSync } from 'child_process'; import { existsSync } from 'fs'; import path from 'path'; const execAsync = promisify(exec); export class KnipClient { projectRoot; knipConfigPath; constructor(projectRoot, knipConfigPath) { this.projectRoot = resolve(projectRoot); // Use provided config path, or fall back to environment variable this.knipConfigPath = knipConfigPath || process.env.KNIP_CONFIG_PATH; } buildKnipCommand(args = [], workingDirectory) { const baseCommand = ['npx', 'knip']; // Add config path if specified if (this.knipConfigPath && existsSync(this.knipConfigPath)) { // If we're running from the config directory, use just the filename // Otherwise use the full path if (workingDirectory && workingDirectory === path.dirname(this.knipConfigPath)) { baseCommand.push('--config', path.basename(this.knipConfigPath)); } else { baseCommand.push('--config', this.knipConfigPath); } } return [...baseCommand, ...args].join(' '); } async scan(options = {}) { const args = ['--reporter', 'json']; if (options.workspace) { args.push('--workspace', options.workspace); } if (options.includePaths?.length) { args.push('--include', options.includePaths.join(',')); } if (options.excludePaths?.length) { args.push('--exclude', options.excludePaths.join(',')); } try { // Determine the correct working directory // If we have a config file, run knip from the directory containing the config // This ensures that relative paths in the config work correctly let workingDirectory = this.projectRoot; if (this.knipConfigPath && existsSync(this.knipConfigPath)) { workingDirectory = path.dirname(this.knipConfigPath); } const command = this.buildKnipCommand(args, workingDirectory); const output = execSync(command, { cwd: workingDirectory, encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] }); return JSON.parse(output); } catch (error) { // Knip returns non-zero exit code when issues are found if (error.stdout) { try { return JSON.parse(error.stdout); } catch { // If JSON parsing fails, return empty result return {}; } } throw new Error(`Knip scan failed: ${error.message}`); } } async getUnusedFiles(workspace) { const result = await this.scan({ workspace }); return result.files || []; } async getUnusedExports(filePath, workspace) { const result = await this.scan({ workspace }); if (filePath && result.exports) { const filtered = {}; if (result.exports[filePath]) { filtered[filePath] = result.exports[filePath]; } return filtered; } return result.exports || {}; } async getUnusedImports(filePath, workspace) { const result = await this.scan({ workspace }); if (filePath && result.imports) { const filtered = {}; if (result.imports[filePath]) { filtered[filePath] = result.imports[filePath]; } return filtered; } return result.imports || {}; } async getUnusedDependencies(type = 'all', workspace) { const result = await this.scan({ workspace }); if (type === 'dependencies') { return result.dependencies || []; } else if (type === 'devDependencies') { return result.devDependencies || []; } else { return [...(result.dependencies || []), ...(result.devDependencies || [])]; } } async getConfig() { const configPaths = [ this.knipConfigPath, join(this.projectRoot, 'knip.json'), join(this.projectRoot, 'knip.jsonc'), join(this.projectRoot, '.knip.json'), join(this.projectRoot, '.knip.jsonc'), join(this.projectRoot, 'knip.config.js'), join(this.projectRoot, 'knip.config.ts'), ].filter(Boolean); for (const configPath of configPaths) { try { const exists = await fs.access(configPath).then(() => true).catch(() => false); if (exists) { if (configPath.endsWith('.json') || configPath.endsWith('.jsonc')) { const content = await fs.readFile(configPath, 'utf8'); return JSON.parse(content); } else if (configPath.endsWith('.js') || configPath.endsWith('.ts')) { const { default: config } = await import(configPath); return config; } } } catch (error) { console.warn(`Failed to load config from ${configPath}:`, error); } } return null; } async validateConfig(configPath) { try { const configToValidate = configPath || this.knipConfigPath; if (configToValidate && !existsSync(configToValidate)) { return { valid: false, errors: [`Config file not found: ${configToValidate}`] }; } const args = ['--reporter', 'json']; if (configToValidate) { args.push('--config', configToValidate); } args.push('--dry-run'); const command = this.buildKnipCommand(args); execSync(command, { cwd: this.projectRoot, encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] }); return { valid: true }; } catch (error) { return { valid: false, errors: [error.message] }; } } async removeUnusedFiles(files, options = {}) { const { dryRun = true, createBackup = true, backupDir } = options; const removed = []; const backed_up = []; const errors = []; for (const file of files) { try { const fullPath = resolve(this.projectRoot, file); const exists = await fs.access(fullPath).then(() => true).catch(() => false); if (!exists) { errors.push({ file, error: 'File does not exist' }); continue; } if (dryRun) { console.log(`[DRY RUN] Would remove: ${file}`); removed.push(file); continue; } if (createBackup) { const backupPath = backupDir ? join(backupDir, relative(this.projectRoot, fullPath)) : `${fullPath}.backup`; const backupDirPath = resolve(backupPath, '..'); await fs.mkdir(backupDirPath, { recursive: true }); await fs.copyFile(fullPath, backupPath); backed_up.push(relative(this.projectRoot, backupPath)); } await fs.unlink(fullPath); removed.push(file); } catch (error) { errors.push({ file, error: error instanceof Error ? error.message : String(error), }); } } return { removed, backed_up, errors }; } async removeUnusedImports(filePath, options = {}) { const { imports, dryRun = true, createBackup = true } = options; try { const fullPath = resolve(this.projectRoot, filePath); const content = await fs.readFile(fullPath, 'utf8'); const unusedImports = imports || (await this.getUnusedImports())[filePath] || []; if (unusedImports.length === 0) { return { success: true, removed_imports: [], backed_up: false, }; } if (dryRun) { console.log(`[DRY RUN] Would remove imports from ${filePath}:`, unusedImports); return { success: true, removed_imports: unusedImports, backed_up: false, }; } if (createBackup) { await fs.copyFile(fullPath, `${fullPath}.backup`); } let modifiedContent = content; const removedImports = []; for (const importName of unusedImports) { const importRegex = new RegExp(`import\\s+.*\\b${importName}\\b.*from\\s+['"'][^'"]+['"];?\\s*`, 'gm'); if (importRegex.test(modifiedContent)) { modifiedContent = modifiedContent.replace(importRegex, ''); removedImports.push(importName); } } await fs.writeFile(fullPath, modifiedContent); return { success: true, removed_imports: removedImports, backed_up: createBackup, }; } catch (error) { return { success: false, removed_imports: [], backed_up: false, error: error instanceof Error ? error.message : String(error), }; } } } //# sourceMappingURL=knip-client.js.map