import { parametersSchema as z, defineCustomTool, CustomToolContext } from "@roo-code/types" // @ts-ignore - Node built-ins import { spawnSync } from "child_process" // @ts-ignore - Node built-ins import { readFileSync, unlinkSync, existsSync } from "fs" // @ts-ignore - Node built-ins import path from "path" // @ts-ignore - Node built-ins import os from "os" interface DepNode { groupId: string artifactId: string version: string scope: string depth: number children: DepNode[] } export default defineCustomTool({ name: "mvn_dependency_tree", description: "Run 'mvn dependency:tree' and return parsed JSON. Filters by groupId and scope. Saves piping raw mvn output through grep.", parameters: z.object({ projectRoot: z.string().describe("Directory containing pom.xml (absolute or relative)"), module: z.string().optional().describe("-pl scope, e.g. 'backend' or 'java/modules/cs-modules/eau'"), groupIdFilter: z.string().optional().describe("Only include deps whose groupId contains this string"), scope: z.enum(["compile", "test", "runtime", "provided", "all"]).optional().describe("Filter by Maven scope (default: all)"), }), async execute({ projectRoot, module, groupIdFilter, scope = "all" }, context: CustomToolContext) { try { // Resolve projectRoot against task CWD if relative // @ts-ignore - task.cwd exists at runtime const cwd = context?.task?.cwd ?? process.cwd() const resolvedRoot = path.isAbsolute(projectRoot) ? projectRoot : path.resolve(cwd, projectRoot) // Use a temp file for output — avoids parsing noisy Maven stdout const outFile = path.join(os.tmpdir(), `mvn-deptree-${Date.now()}.txt`) const args = ["dependency:tree", "-B", `-DoutputFile=${outFile}`, "-DoutputType=text"] // Only add -pl when module is a non-empty string if (module && module.trim()) { args.push("-pl", module, "-am") } if (scope !== "all") { args.push(`-Dscope=${scope}`) } const result = spawnSync("mvn", args, { cwd: resolvedRoot, encoding: "utf-8", timeout: 120_000, maxBuffer: 10 * 1024 * 1024, }) if (result.error) { return JSON.stringify({ error: `spawn error: ${result.error.message}` }, null, 2) } const stderr = result.stderr || "" if (result.status !== 0) { const errLines = stderr.split("\n").filter((l: string) => l.includes("[ERROR]")).slice(0, 10) // Clean up temp file if it exists try { if (existsSync(outFile)) unlinkSync(outFile) } catch {} return JSON.stringify({ ok: false, command: `mvn ${args.join(" ")}`, exitCode: result.status, errorLines: errLines, }, null, 2) } // Read the output file let output = "" try { output = readFileSync(outFile, "utf-8") } catch (e: any) { return JSON.stringify({ error: `mvn succeeded but output file not found: ${outFile}. stderr: ${stderr.slice(0, 500)}`, }, null, 2) } finally { // Clean up temp file try { if (existsSync(outFile)) unlinkSync(outFile) } catch {} } // Parse the tree output // Lines look like: // de.platesoft:inspectflow:pom:0.1.0 // +- org.springframework.boot:spring-boot-starter-web:jar:3.5.11:compile // | +- org.springframework.boot:spring-boot-starter:jar:3.5.11:compile // \- junit:junit:jar:4.13.2:test const lines = output.split("\n").filter((l: string) => l.trim()) let rootArtifact = "" const tree: DepNode[] = [] const stack: { node: DepNode; depth: number }[] = [] let totalCount = 0 for (const line of lines) { // Determine depth by prefix characters // Root line has no prefix markers const trimmed = line.replace(/^[\s|\\+\-]+/, "").trim() if (!trimmed || trimmed.startsWith("[")) continue // Calculate depth from prefix let depth = 0 const prefixMatch = line.match(/^([\s|\\+\-]*)/) if (prefixMatch) { const prefix = prefixMatch[1] // Each level is 3 chars: "+- " or "| " or "\- " if (prefix.length === 0) { depth = 0 } else { depth = Math.ceil(prefix.length / 3) } } // Parse: groupId:artifactId:packaging:version:scope // or: groupId:artifactId:packaging:classifier:version:scope const parts = trimmed.split(":") if (parts.length < 4) { if (depth === 0 && parts.length >= 3) { rootArtifact = trimmed } continue } let gId: string, aId: string, ver: string, sc: string if (parts.length === 5) { [gId, aId, , ver, sc] = parts } else if (parts.length >= 6) { [gId, aId, , , ver, sc] = parts } else { [gId, aId, , ver] = parts sc = "compile" } if (depth === 0 && !rootArtifact) { rootArtifact = trimmed continue } totalCount++ // Apply filters if (groupIdFilter && !gId.includes(groupIdFilter)) continue if (scope !== "all" && sc !== scope) continue const node: DepNode = { groupId: gId, artifactId: aId, version: ver, scope: sc, depth, children: [], } // Place in tree while (stack.length > 0 && stack[stack.length - 1].depth >= depth) { stack.pop() } if (stack.length === 0) { tree.push(node) } else { stack[stack.length - 1].node.children.push(node) } stack.push({ node, depth }) } // Count filtered nodes const countNodes = (nodes: DepNode[]): number => { let c = nodes.length for (const n of nodes) c += countNodes(n.children) return c } const filtered = countNodes(tree) // Truncate if too large let truncated = false const MAX_NODES = 500 if (filtered > MAX_NODES) { truncated = true // Flatten to first 2 levels only for (const node of tree) { for (const child of node.children) { child.children = [] } } } return JSON.stringify({ command: `mvn ${args.join(" ")}`, ok: true, rootArtifact, dependencyCount: totalCount, filtered, truncated, tree, }, null, 2) } catch (err: any) { return JSON.stringify({ error: err.message ?? String(err) }, null, 2) } }, })