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
JavaScript
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