/**
* Agent Artifact Cleanup Tool
*
* Detects, classifies, and cleans up artifacts generated by AI coding agents.
* Supports scan, clean, and archive operations with configurable behavior.
*/
import { promises as fs } from "fs";
import path from "path";
import { z } from "zod";
import { formatMCPResponse } from "../types/api.js";
import {
ArtifactDetector,
ArtifactScanResult,
ArtifactCleanupConfig,
AgentArtifact,
DEFAULT_CONFIG,
} from "../utils/artifact-detector.js";
const inputSchema = z.object({
path: z.string().describe("Path to the project directory to scan"),
operation: z
.enum(["scan", "clean", "archive"])
.describe(
"Operation to perform: scan (detect only), clean (remove), or archive (move to .agent-archive/)",
),
dryRun: z
.boolean()
.optional()
.default(false)
.describe("Show what would be changed without making changes"),
interactive: z
.boolean()
.optional()
.default(false)
.describe(
"Prompt for confirmation before each action (not supported in MCP, treated as dryRun)",
),
autoDeleteThreshold: z
.number()
.min(0)
.max(1)
.optional()
.default(0.9)
.describe("Confidence threshold for automatic deletion (0-1)"),
includeGitIgnored: z
.boolean()
.optional()
.default(false)
.describe("Include artifacts that are already in .gitignore"),
customPatterns: z
.object({
files: z.array(z.string()).optional(),
directories: z.array(z.string()).optional(),
inlineMarkers: z.array(z.string()).optional(),
})
.optional()
.describe("Custom patterns to detect in addition to defaults"),
});
export type CleanupAgentArtifactsInput = z.infer<typeof inputSchema>;
export interface CleanupAgentArtifactsOutput {
operation: string;
result: ArtifactScanResult;
actionsPerformed?: {
deleted: string[];
archived: string[];
skipped: string[];
};
}
/**
* Cleanup agent artifacts tool
*/
export async function cleanupAgentArtifacts(
args: unknown,
): Promise<{ content: any[]; isError?: boolean }> {
const startTime = Date.now();
try {
const input = inputSchema.parse(args);
// Validate path exists
try {
const stats = await fs.stat(input.path);
if (!stats.isDirectory()) {
return formatMCPResponse({
success: false,
error: {
code: "INVALID_PATH",
message: `Path is not a directory: ${input.path}`,
resolution: "Provide a valid directory path",
},
metadata: {
toolVersion: "1.0.0",
executionTime: Date.now() - startTime,
timestamp: new Date().toISOString(),
},
});
}
} catch (error) {
return formatMCPResponse({
success: false,
error: {
code: "PATH_NOT_FOUND",
message: `Path does not exist: ${input.path}`,
resolution: "Provide a valid directory path",
},
metadata: {
toolVersion: "1.0.0",
executionTime: Date.now() - startTime,
timestamp: new Date().toISOString(),
},
});
}
// Build configuration
const config: Partial<ArtifactCleanupConfig> = {
autoDeleteThreshold: input.autoDeleteThreshold,
preserveGitIgnored: !input.includeGitIgnored,
};
// Merge custom patterns if provided
if (input.customPatterns) {
config.patterns = {
files: [
...DEFAULT_CONFIG.patterns.files,
...(input.customPatterns.files || []),
],
directories: [
...DEFAULT_CONFIG.patterns.directories,
...(input.customPatterns.directories || []),
],
inlineMarkers: [
...DEFAULT_CONFIG.patterns.inlineMarkers,
...(input.customPatterns.inlineMarkers || []),
],
blockPatterns: DEFAULT_CONFIG.patterns.blockPatterns,
};
}
// Create detector and scan
const detector = new ArtifactDetector(input.path, config);
const scanResult = await detector.scan();
// Handle operations
let actionsPerformed:
| { deleted: string[]; archived: string[]; skipped: string[] }
| undefined;
// Interactive mode is treated as dry-run in MCP context
const isDryRun = input.dryRun || input.interactive;
if (input.operation === "clean" && !isDryRun) {
actionsPerformed = await performCleanup(
input.path,
scanResult.artifacts,
input.autoDeleteThreshold,
);
} else if (input.operation === "archive" && !isDryRun) {
actionsPerformed = await performArchive(
input.path,
scanResult.artifacts,
input.autoDeleteThreshold,
);
}
// Build output
const output: CleanupAgentArtifactsOutput = {
operation: isDryRun ? `${input.operation} (dry-run)` : input.operation,
result: scanResult,
actionsPerformed,
};
// Build recommendations
const recommendations = [];
if (input.operation === "scan" && scanResult.artifacts.length > 0) {
const deleteCount = scanResult.summary.byRecommendation["delete"] || 0;
const archiveCount = scanResult.summary.byRecommendation["archive"] || 0;
const reviewCount = scanResult.summary.byRecommendation["review"] || 0;
if (deleteCount > 0) {
recommendations.push({
type: "info" as const,
title: "High-confidence artifacts found",
description: `Found ${deleteCount} artifacts recommended for deletion. Run with operation='clean' to remove them.`,
action: "cleanup_agent_artifacts with operation='clean'",
});
}
if (archiveCount > 0) {
recommendations.push({
type: "info" as const,
title: "Archivable artifacts found",
description: `Found ${archiveCount} artifacts recommended for archiving. Run with operation='archive' to preserve them.`,
action: "cleanup_agent_artifacts with operation='archive'",
});
}
if (reviewCount > 0) {
recommendations.push({
type: "warning" as const,
title: "Manual review recommended",
description: `Found ${reviewCount} artifacts that require manual review before action.`,
});
}
}
if (isDryRun && input.operation !== "scan") {
recommendations.push({
type: "info" as const,
title: "Dry-run mode",
description:
"No changes were made. Remove dryRun=true to apply changes.",
});
}
// Build next steps
const nextSteps = [];
if (input.operation === "scan" && scanResult.artifacts.length > 0) {
nextSteps.push({
action: "Review detected artifacts",
description:
"Examine the artifacts list to understand what was detected",
priority: "high" as const,
});
nextSteps.push({
action: "Run cleanup with dry-run",
toolRequired: "cleanup_agent_artifacts",
description: "Test cleanup operation before making changes",
priority: "medium" as const,
});
}
if (
actionsPerformed &&
(actionsPerformed.deleted.length > 0 ||
actionsPerformed.archived.length > 0)
) {
nextSteps.push({
action: "Review changes and commit",
description: "Verify the cleanup results and commit to version control",
priority: "high" as const,
});
}
return formatMCPResponse(
{
success: true,
data: output,
metadata: {
toolVersion: "1.0.0",
executionTime: Date.now() - startTime,
timestamp: new Date().toISOString(),
},
recommendations:
recommendations.length > 0 ? recommendations : undefined,
nextSteps: nextSteps.length > 0 ? nextSteps : undefined,
},
{ fullResponse: true },
);
} catch (error) {
if (error instanceof z.ZodError) {
return formatMCPResponse({
success: false,
error: {
code: "INVALID_INPUT",
message: "Invalid input parameters",
details: error.errors,
resolution: "Check the input schema and provide valid parameters",
},
metadata: {
toolVersion: "1.0.0",
executionTime: Date.now() - startTime,
timestamp: new Date().toISOString(),
},
});
}
return formatMCPResponse({
success: false,
error: {
code: "TOOL_ERROR",
message: error instanceof Error ? error.message : "Unknown error",
resolution: "Check the error message and try again",
},
metadata: {
toolVersion: "1.0.0",
executionTime: Date.now() - startTime,
timestamp: new Date().toISOString(),
},
});
}
}
/**
* Perform cleanup operation (delete artifacts)
*/
async function performCleanup(
projectPath: string,
artifacts: AgentArtifact[],
threshold: number,
): Promise<{ deleted: string[]; archived: string[]; skipped: string[] }> {
const deleted: string[] = [];
const skipped: string[] = [];
for (const artifact of artifacts) {
// Only delete high-confidence artifacts with delete recommendation
if (
artifact.confidence >= threshold &&
artifact.recommendation === "delete"
) {
const fullPath = path.join(projectPath, artifact.path.split(":")[0]);
try {
if (artifact.type === "file") {
await fs.unlink(fullPath);
deleted.push(artifact.path);
} else if (artifact.type === "directory") {
await fs.rm(fullPath, { recursive: true, force: true });
deleted.push(artifact.path);
} else {
// Inline and block comments require manual editing
skipped.push(artifact.path);
}
} catch (error) {
console.error(`Error deleting ${artifact.path}: ${error}`);
skipped.push(artifact.path);
}
} else {
skipped.push(artifact.path);
}
}
return { deleted, archived: [], skipped };
}
/**
* Perform archive operation (move artifacts to .agent-archive/)
*/
async function performArchive(
projectPath: string,
artifacts: AgentArtifact[],
threshold: number,
): Promise<{ deleted: string[]; archived: string[]; skipped: string[] }> {
const archived: string[] = [];
const skipped: string[] = [];
// Create archive directory
const archiveDir = path.join(projectPath, ".agent-archive");
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const archivePath = path.join(archiveDir, timestamp);
try {
await fs.mkdir(archivePath, { recursive: true });
} catch (error) {
console.error(`Error creating archive directory: ${error}`);
return { deleted: [], archived: [], skipped: artifacts.map((a) => a.path) };
}
for (const artifact of artifacts) {
// Archive medium to high confidence artifacts
if (
artifact.confidence >= threshold * 0.7 &&
(artifact.recommendation === "archive" ||
artifact.recommendation === "delete")
) {
const sourcePath = path.join(projectPath, artifact.path.split(":")[0]);
try {
if (artifact.type === "file" || artifact.type === "directory") {
const destPath = path.join(archivePath, artifact.path);
const destDir = path.dirname(destPath);
// Create destination directory
await fs.mkdir(destDir, { recursive: true });
// Move file or directory
if (artifact.type === "file") {
await fs.copyFile(sourcePath, destPath);
await fs.unlink(sourcePath);
} else {
await fs.cp(sourcePath, destPath, { recursive: true });
await fs.rm(sourcePath, { recursive: true, force: true });
}
archived.push(artifact.path);
} else {
// Inline and block comments require manual editing
skipped.push(artifact.path);
}
} catch (error) {
console.error(`Error archiving ${artifact.path}: ${error}`);
skipped.push(artifact.path);
}
} else {
skipped.push(artifact.path);
}
}
return { deleted: [], archived, skipped };
}