feat: archive zoo_backup for home sync
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { parametersSchema as z, defineCustomTool, CustomToolContext } from "@roo-code/types"
|
||||
// @ts-ignore - Node built-ins
|
||||
import { spawnSync } from "child_process"
|
||||
|
||||
export default defineCustomTool({
|
||||
name: "brew_leaves",
|
||||
description: "List Homebrew packages installed on request (not pulled in as dependencies). Optionally includes installed versions. Answers 'what did I install myself?'",
|
||||
parameters: z.object({
|
||||
withVersions: z.boolean().optional().describe("Include installed versions for each leaf package (default: true)"),
|
||||
}),
|
||||
async execute({ withVersions = true }, context: CustomToolContext) {
|
||||
try {
|
||||
const leavesResult = spawnSync("brew", ["leaves", "-r"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 15_000,
|
||||
})
|
||||
|
||||
if (leavesResult.error) {
|
||||
return JSON.stringify({ error: `brew leaves failed: ${leavesResult.error.message}` }, null, 2)
|
||||
}
|
||||
|
||||
const leavesList = (leavesResult.stdout || "")
|
||||
.split("\n")
|
||||
.map((l: string) => l.trim())
|
||||
.filter((l: string) => l.length > 0)
|
||||
|
||||
if (!withVersions) {
|
||||
return JSON.stringify({
|
||||
count: leavesList.length,
|
||||
leaves: leavesList.map((name: string) => ({ name })),
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
// Get versions in batch — brew list --versions for all leaves
|
||||
const versResult = spawnSync("brew", ["list", "--versions", ...leavesList], {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
const versionMap: Record<string, string> = {}
|
||||
if (versResult.stdout) {
|
||||
for (const line of versResult.stdout.split("\n")) {
|
||||
const parts = line.trim().split(/\s+/)
|
||||
if (parts.length >= 2) {
|
||||
versionMap[parts[0]] = parts.slice(1).join(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const leaves = leavesList.map((name: string) => ({
|
||||
name,
|
||||
version: versionMap[name] || "unknown",
|
||||
}))
|
||||
|
||||
return JSON.stringify({ count: leaves.length, leaves }, null, 2)
|
||||
} catch (err: any) {
|
||||
return JSON.stringify({ error: err.message ?? String(err) }, null, 2)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { parametersSchema as z, defineCustomTool, CustomToolContext } from "@roo-code/types"
|
||||
// @ts-ignore - Node built-ins
|
||||
import { spawnSync } from "child_process"
|
||||
|
||||
export default defineCustomTool({
|
||||
name: "brew_search",
|
||||
description: "Search Homebrew for formulae and/or casks matching a query. Returns structured JSON instead of raw terminal output.",
|
||||
parameters: z.object({
|
||||
query: z.string().describe("Search term (e.g. 'python', 'docker', 'ffmpeg')"),
|
||||
casks: z.boolean().optional().describe("Include casks in search results (default: false, formula-only)"),
|
||||
}),
|
||||
async execute({ query, casks = false }, context: CustomToolContext) {
|
||||
try {
|
||||
// Search formulae
|
||||
const formulaResult = spawnSync("brew", ["search", "--formula", query], {
|
||||
encoding: "utf-8",
|
||||
timeout: 15_000,
|
||||
})
|
||||
|
||||
const formulae = (formulaResult.stdout || "")
|
||||
.split("\n")
|
||||
.map((l: string) => l.trim())
|
||||
.filter((l: string) => l.length > 0 && !l.startsWith("==>"))
|
||||
|
||||
let caskList: string[] = []
|
||||
if (casks) {
|
||||
const caskResult = spawnSync("brew", ["search", "--cask", query], {
|
||||
encoding: "utf-8",
|
||||
timeout: 15_000,
|
||||
})
|
||||
caskList = (caskResult.stdout || "")
|
||||
.split("\n")
|
||||
.map((l: string) => l.trim())
|
||||
.filter((l: string) => l.length > 0 && !l.startsWith("==>"))
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
query,
|
||||
formulae,
|
||||
casks: caskList,
|
||||
totalCount: formulae.length + caskList.length,
|
||||
}, null, 2)
|
||||
} catch (err: any) {
|
||||
return JSON.stringify({ error: err.message ?? String(err) }, null, 2)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { parametersSchema as z, defineCustomTool } from "@roo-code/types"
|
||||
import { statSync, readdirSync } from "fs"
|
||||
import { join, relative } from "path"
|
||||
|
||||
export default defineCustomTool({
|
||||
name: "context_budget",
|
||||
description: "Estimate token cost of reading files or directories. Returns file count, total lines, chars, and estimated tokens. Helps decide whether to load full files or use targeted reads.",
|
||||
parameters: z.object({
|
||||
paths: z.array(z.string()).describe("File or directory paths to analyze"),
|
||||
recursive: z.boolean().optional().describe("Recurse into directories. Default: true"),
|
||||
extensions: z.array(z.string()).optional().describe("Filter by file extensions, e.g. ['.java', '.ts']. Default: all files"),
|
||||
}),
|
||||
async execute({ paths, recursive = true, extensions }) {
|
||||
let totalFiles = 0
|
||||
let totalLines = 0
|
||||
let totalChars = 0
|
||||
const breakdown: Array<{ path: string; lines: number; chars: number; tokens: number }> = []
|
||||
|
||||
function processFile(filePath: string) {
|
||||
try {
|
||||
const stat = statSync(filePath)
|
||||
if (!stat.isFile()) return
|
||||
if (extensions && !extensions.some(ext => filePath.endsWith(ext))) return
|
||||
|
||||
const { readFileSync } = require("fs")
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const lines = content.split("\n").length
|
||||
const chars = content.length
|
||||
const tokens = Math.ceil(chars / 4)
|
||||
|
||||
totalFiles++
|
||||
totalLines += lines
|
||||
totalChars += chars
|
||||
breakdown.push({ path: filePath, lines, chars, tokens })
|
||||
} catch (e) {
|
||||
// skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
function processDir(dirPath: string, recurse: boolean) {
|
||||
try {
|
||||
const entries = readdirSync(dirPath, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "target" || entry.name === ".git") continue
|
||||
const fullPath = join(dirPath, entry.name)
|
||||
if (entry.isFile()) {
|
||||
processFile(fullPath)
|
||||
} else if (entry.isDirectory() && recurse) {
|
||||
processDir(fullPath, recurse)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// skip unreadable dirs
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of paths) {
|
||||
try {
|
||||
const stat = statSync(p)
|
||||
if (stat.isFile()) {
|
||||
processFile(p)
|
||||
} else if (stat.isDirectory()) {
|
||||
processDir(p, recursive !== false)
|
||||
}
|
||||
} catch (e) {
|
||||
breakdown.push({ path: p, lines: 0, chars: 0, tokens: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
const totalTokens = Math.ceil(totalChars / 4)
|
||||
|
||||
// Sort by tokens descending, take top 15
|
||||
breakdown.sort((a, b) => b.tokens - a.tokens)
|
||||
const topFiles = breakdown.slice(0, 15)
|
||||
|
||||
const result = {
|
||||
summary: {
|
||||
files: totalFiles,
|
||||
totalLines,
|
||||
totalChars,
|
||||
estimatedTokens: totalTokens,
|
||||
warning: totalTokens > 50000 ? "⚠️ LARGE — will consume significant context budget" :
|
||||
totalTokens > 20000 ? "⚠️ MEDIUM — consider targeted reads" :
|
||||
"✅ Fits comfortably in context",
|
||||
},
|
||||
largestFiles: topFiles,
|
||||
}
|
||||
|
||||
return JSON.stringify(result, null, 2)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { parametersSchema as z, defineCustomTool } from "@roo-code/types"
|
||||
import { spawnSync } from "child_process"
|
||||
|
||||
export default defineCustomTool({
|
||||
name: "git_recent_changes",
|
||||
description: "Get recent git commits across all branches as structured JSON. Shows hash, branch, author, date, and message. Useful at session start for situational awareness.",
|
||||
parameters: z.object({
|
||||
repoPath: z.string().optional().describe("Path to git repo. Defaults to /Users/pplate/git/paisy"),
|
||||
count: z.number().optional().describe("Number of commits to return. Default: 15"),
|
||||
branch: z.string().optional().describe("Specific branch to query. Default: --all (all branches)"),
|
||||
}),
|
||||
async execute({ repoPath, count = 15, branch }) {
|
||||
const repo = repoPath || "/Users/pplate/git/paisy"
|
||||
const args = ["-C", repo, "log", "--oneline", `--max-count=${count}`, "--format=%H|%h|%an|%ai|%D|%s"]
|
||||
|
||||
if (branch) {
|
||||
args.push(branch)
|
||||
} else {
|
||||
args.push("--all")
|
||||
}
|
||||
|
||||
const result = spawnSync("git", args, {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
return `Error: ${result.stderr || "git log failed"}`
|
||||
}
|
||||
|
||||
const commits = result.stdout.trim().split("\n").filter(Boolean).map(line => {
|
||||
const [hash, shortHash, author, date, refs, ...msgParts] = line.split("|")
|
||||
return {
|
||||
hash,
|
||||
shortHash,
|
||||
author,
|
||||
date,
|
||||
refs: refs || null,
|
||||
message: msgParts.join("|"),
|
||||
}
|
||||
})
|
||||
|
||||
return JSON.stringify(commits, null, 2)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
const { parametersSchema: z, defineCustomTool } = require("@roo-code/types")
|
||||
|
||||
module.exports = defineCustomTool({
|
||||
name: "hello_test",
|
||||
description: "A simple test tool that returns a greeting. Used to verify custom tool loading works.",
|
||||
parameters: z.object({
|
||||
name: z.string().optional().describe("Name to greet. Defaults to 'World'"),
|
||||
}),
|
||||
async execute({ name }) {
|
||||
return `Hello, ${name || "World"}! Custom tools are working. Time: ${new Date().toISOString()}`
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
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 <module> 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)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { parametersSchema as z, defineCustomTool } from "@roo-code/types"
|
||||
import { spawnSync } from "child_process"
|
||||
import { readdirSync, readFileSync, existsSync } from "fs"
|
||||
import { join } from "path"
|
||||
|
||||
export default defineCustomTool({
|
||||
name: "mvn_test",
|
||||
description: "Run Maven tests for a PAISY module in a worktree. Parses surefire XML reports and returns structured pass/fail results.",
|
||||
parameters: z.object({
|
||||
worktreePath: z.string().describe("Path to the PAISY worktree, e.g. /Users/pplate/git/paisy-ESIDEPAISY-12081"),
|
||||
module: z.string().describe("Module path relative to java/, e.g. modules/cs-modules/eau"),
|
||||
testClass: z.string().optional().describe("Specific test class to run, e.g. CenterTest"),
|
||||
}),
|
||||
async execute({ worktreePath, module, testClass }) {
|
||||
const pomPath = join(worktreePath, "java", "pom.xml")
|
||||
if (!existsSync(pomPath)) {
|
||||
return `Error: pom.xml not found at ${pomPath}`
|
||||
}
|
||||
|
||||
const args = ["test", "-pl", `java/${module}`, "-f", pomPath, "--batch-mode", "-q"]
|
||||
if (testClass) {
|
||||
args.push(`-Dtest=${testClass}`)
|
||||
}
|
||||
|
||||
const result = spawnSync("mvn", args, {
|
||||
encoding: "utf-8",
|
||||
timeout: 300000,
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
})
|
||||
|
||||
const surefireDir = join(worktreePath, "java", module, "target", "surefire-reports")
|
||||
const summary = { total: 0, passed: 0, failed: 0, errors: 0, skipped: 0, failures: [] as string[] }
|
||||
|
||||
if (existsSync(surefireDir)) {
|
||||
try {
|
||||
const xmlFiles = readdirSync(surefireDir).filter(f => f.startsWith("TEST-") && f.endsWith(".xml"))
|
||||
for (const xmlFile of xmlFiles) {
|
||||
const content = readFileSync(join(surefireDir, xmlFile), "utf-8")
|
||||
const testsMatch = content.match(/tests="(\d+)"/)
|
||||
const failuresMatch = content.match(/failures="(\d+)"/)
|
||||
const errorsMatch = content.match(/errors="(\d+)"/)
|
||||
const skippedMatch = content.match(/skipped="(\d+)"/)
|
||||
|
||||
if (testsMatch) summary.total += parseInt(testsMatch[1])
|
||||
if (failuresMatch) summary.failed += parseInt(failuresMatch[1])
|
||||
if (errorsMatch) summary.errors += parseInt(errorsMatch[1])
|
||||
if (skippedMatch) summary.skipped += parseInt(skippedMatch[1])
|
||||
|
||||
const failureMatches = content.matchAll(/<failure[^>]*message="([^"]*)"[^>]*>/g)
|
||||
for (const m of failureMatches) {
|
||||
summary.failures.push(`${xmlFile.replace("TEST-", "").replace(".xml", "")}: ${m[1]}`)
|
||||
}
|
||||
}
|
||||
summary.passed = summary.total - summary.failed - summary.errors - summary.skipped
|
||||
} catch (e) {
|
||||
// surefire parsing failed, continue with what we have
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
buildStatus: result.status === 0 ? "SUCCESS" : "FAILURE",
|
||||
exitCode: result.status,
|
||||
summary,
|
||||
lastOutput: (result.status !== 0 ? (result.stderr || result.stdout || "") : "").split("\n").slice(-15).join("\n"),
|
||||
}, null, 2)
|
||||
},
|
||||
})
|
||||
Generated
+154
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"name": "zoo-custom-tools",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "zoo-custom-tools",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@roo-code/types": "^1.115.0",
|
||||
"fast-xml-parser": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
|
||||
"integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodable"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@roo-code/types": {
|
||||
"version": "1.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@roo-code/types/-/types-1.115.0.tgz",
|
||||
"integrity": "sha512-aJT8RhxoVdGRyiU7roQKgJRxget+4oOosQj/6XYufLHHGQnrGOkKCPRM7jGAMo88/8CRAmLgJcLAjEEsbpg8Qw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zod": "3.25.76"
|
||||
}
|
||||
},
|
||||
"node_modules/anynum": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
|
||||
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
|
||||
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-expression-matcher": "^1.5.0",
|
||||
"xml-naming": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz",
|
||||
"integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodable/entities": "^2.2.0",
|
||||
"fast-xml-builder": "^1.2.0",
|
||||
"is-unsafe": "^1.0.1",
|
||||
"path-expression-matcher": "^1.5.0",
|
||||
"strnum": "^2.4.1",
|
||||
"xml-naming": "^0.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/is-unsafe": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz",
|
||||
"integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.0.tgz",
|
||||
"integrity": "sha512-e5y7RCLHKjemsgQ4eqGJtPyr10ILz25HO7flzxhTV8bgvd5yHx98DGtCAtbVW9f2TqnYI/gEVZd+vz7snrdPTw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
|
||||
"integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anynum": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-naming": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
|
||||
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "zoo-custom-tools",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@roo-code/types": "^1.115.0",
|
||||
"fast-xml-parser": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { parametersSchema as z, defineCustomTool, CustomToolContext } from "@roo-code/types"
|
||||
// @ts-ignore - Node built-ins
|
||||
import { readFileSync } from "fs"
|
||||
// @ts-ignore - Node built-ins
|
||||
import path from "path"
|
||||
|
||||
export default defineCustomTool({
|
||||
name: "pom_inspect",
|
||||
description: "Parse a pom.xml and return structured JSON with groupId, artifactId, version, modules, properties, parent, and optionally dependencies. Saves reading raw XML just to extract Maven coordinates.",
|
||||
parameters: z.object({
|
||||
pomPath: z.string().describe("Absolute or relative path to pom.xml"),
|
||||
includeDependencies: z.boolean().optional().describe("Include <dependencies> list in output (default: false)"),
|
||||
}),
|
||||
async execute({ pomPath, includeDependencies = false }, context: CustomToolContext) {
|
||||
try {
|
||||
// @ts-ignore - installed dependency
|
||||
const { XMLParser } = require("fast-xml-parser")
|
||||
|
||||
// Resolve relative paths against the task's working directory
|
||||
// @ts-ignore - task.cwd exists at runtime
|
||||
const cwd = context?.task?.cwd ?? process.cwd()
|
||||
const absPath = path.isAbsolute(pomPath) ? pomPath : path.resolve(cwd, pomPath)
|
||||
const xml = readFileSync(absPath, "utf-8")
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: "@_",
|
||||
textNodeName: "#text",
|
||||
isArray: (name: string) => ["module", "dependency"].includes(name),
|
||||
})
|
||||
const doc = parser.parse(xml)
|
||||
const project = doc.project
|
||||
|
||||
if (!project) {
|
||||
return JSON.stringify({ error: "No <project> root element found in POM" }, null, 2)
|
||||
}
|
||||
|
||||
// Extract properties for placeholder resolution
|
||||
const properties: Record<string, string> = {}
|
||||
if (project.properties) {
|
||||
for (const [key, val] of Object.entries(project.properties)) {
|
||||
if (typeof val === "string" || typeof val === "number") {
|
||||
properties[key] = String(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolvePlaceholder = (val: any): string | null => {
|
||||
if (val == null) return null
|
||||
let s = String(val)
|
||||
const match = s.match(/^\$\{(.+)\}$/)
|
||||
if (match) {
|
||||
const propKey = match[1]
|
||||
if (properties[propKey]) return properties[propKey]
|
||||
// Check project-level refs
|
||||
if (propKey === "project.version" && project.version) return String(project.version)
|
||||
if (propKey === "project.groupId" && project.groupId) return String(project.groupId)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Parent
|
||||
let parent = null
|
||||
if (project.parent) {
|
||||
parent = {
|
||||
groupId: String(project.parent.groupId || ""),
|
||||
artifactId: String(project.parent.artifactId || ""),
|
||||
version: resolvePlaceholder(project.parent.version),
|
||||
}
|
||||
}
|
||||
|
||||
// Modules
|
||||
let modules: string[] = []
|
||||
if (project.modules?.module) {
|
||||
modules = project.modules.module.map((m: any) => String(m))
|
||||
}
|
||||
|
||||
// Dependencies
|
||||
let dependencies: any[] | undefined = undefined
|
||||
if (includeDependencies && project.dependencies?.dependency) {
|
||||
dependencies = project.dependencies.dependency.map((d: any) => ({
|
||||
groupId: String(d.groupId || ""),
|
||||
artifactId: String(d.artifactId || ""),
|
||||
version: resolvePlaceholder(d.version),
|
||||
scope: d.scope ? String(d.scope) : "compile",
|
||||
}))
|
||||
}
|
||||
|
||||
// Also check dependencyManagement
|
||||
if (includeDependencies && project.dependencyManagement?.dependencies?.dependency) {
|
||||
const managed = project.dependencyManagement.dependencies.dependency.map((d: any) => ({
|
||||
groupId: String(d.groupId || ""),
|
||||
artifactId: String(d.artifactId || ""),
|
||||
version: resolvePlaceholder(d.version),
|
||||
scope: d.scope ? String(d.scope) : "managed",
|
||||
}))
|
||||
if (!dependencies) dependencies = managed
|
||||
else dependencies = [...dependencies, ...managed]
|
||||
}
|
||||
|
||||
const result: any = {
|
||||
groupId: resolvePlaceholder(project.groupId) || parent?.groupId || "",
|
||||
artifactId: String(project.artifactId || ""),
|
||||
version: resolvePlaceholder(project.version) || parent?.version || "",
|
||||
packaging: String(project.packaging || "jar"),
|
||||
name: project.name ? String(project.name) : null,
|
||||
parent,
|
||||
modules,
|
||||
properties,
|
||||
}
|
||||
|
||||
if (includeDependencies && dependencies) {
|
||||
result.dependencies = dependencies
|
||||
}
|
||||
|
||||
return JSON.stringify(result, null, 2)
|
||||
} catch (err: any) {
|
||||
return JSON.stringify({ error: err.message ?? String(err) }, null, 2)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { parametersSchema as z, defineCustomTool, CustomToolContext } from "@roo-code/types"
|
||||
// @ts-ignore - Node built-ins
|
||||
import { spawnSync } from "child_process"
|
||||
|
||||
export default defineCustomTool({
|
||||
name: "port_watch",
|
||||
description: "Check what process holds a given TCP/UDP port. Replaces 'lsof -i :PORT' shell calls when debugging dev servers.",
|
||||
parameters: z.object({
|
||||
port: z.number().describe("TCP port number to check (1-65535)"),
|
||||
protocol: z.enum(["tcp", "udp", "both"]).optional().describe("Protocol filter (default: tcp)"),
|
||||
}),
|
||||
async execute({ port, protocol = "tcp" }, context: CustomToolContext) {
|
||||
try {
|
||||
if (port < 1 || port > 65535) {
|
||||
return JSON.stringify({ error: "Invalid port: must be between 1 and 65535" }, null, 2)
|
||||
}
|
||||
|
||||
const listeners: any[] = []
|
||||
|
||||
const runLsof = (proto: string) => {
|
||||
const flag = proto === "tcp" ? `-iTCP:${port}` : `-iUDP:${port}`
|
||||
const stateFlag = proto === "tcp" ? ["-sTCP:LISTEN"] : []
|
||||
const result = spawnSync("lsof", [flag, ...stateFlag, "-P", "-n"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 10_000,
|
||||
})
|
||||
|
||||
if (!result.stdout) return
|
||||
|
||||
const lines = result.stdout.split("\n").filter((l: string) => l.trim())
|
||||
// Skip header line
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const parts = lines[i].split(/\s+/)
|
||||
if (parts.length < 9) continue
|
||||
listeners.push({
|
||||
pid: parseInt(parts[1], 10) || 0,
|
||||
command: parts[0],
|
||||
user: parts[2],
|
||||
type: parts[4], // IPv4 or IPv6
|
||||
state: parts[9] || (proto === "udp" ? "UDP" : "UNKNOWN"),
|
||||
address: parts[8] || `*:${port}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (protocol === "tcp" || protocol === "both") {
|
||||
runLsof("tcp")
|
||||
}
|
||||
if (protocol === "udp" || protocol === "both") {
|
||||
runLsof("udp")
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
port,
|
||||
protocol,
|
||||
inUse: listeners.length > 0,
|
||||
listeners,
|
||||
}, null, 2)
|
||||
} catch (err: any) {
|
||||
return JSON.stringify({ error: err.message ?? String(err) }, null, 2)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,366 @@
|
||||
import { parametersSchema as z, defineCustomTool, CustomToolContext } from "@roo-code/types"
|
||||
// @ts-ignore - Node built-ins
|
||||
import { spawn } from "child_process"
|
||||
// @ts-ignore - Node built-ins
|
||||
import { existsSync, mkdirSync, renameSync, createWriteStream, readFileSync, writeFileSync, WriteStream } from "fs"
|
||||
// @ts-ignore - Node built-ins
|
||||
import path from "path"
|
||||
// @ts-ignore - Node built-ins
|
||||
import { spawnSync } from "child_process"
|
||||
|
||||
export default defineCustomTool({
|
||||
name: "snyk_scan",
|
||||
description: "Run Snyk security scans (SCA + SAST) on a project and produce a Zusammenfassung JSON compatible with Sven's Copilot prompt chain. Requires `snyk` CLI authenticated locally.",
|
||||
parameters: z.object({
|
||||
projectRoot: z.string().describe("Absolute or relative path to the project root (must be a git repo)"),
|
||||
mode: z.enum(["sca", "code", "both"]).optional().describe("Which scan(s) to run (default: both)"),
|
||||
severityThreshold: z.enum(["low", "medium", "high", "critical"]).optional().describe("Severity threshold for SAST scan (default: low)"),
|
||||
outputDir: z.string().optional().describe("Output directory for reports (default: <projectRoot>/.snyk-reports/<basename>)"),
|
||||
includeIgnores: z.boolean().optional().describe("Pass --include-ignores to snyk (default: true)"),
|
||||
timeoutSec: z.number().optional().describe("Overall timeout in seconds (default: 600)"),
|
||||
}),
|
||||
async execute(
|
||||
{ projectRoot, mode = "both", severityThreshold = "low", outputDir, includeIgnores = true, timeoutSec = 600 },
|
||||
context: CustomToolContext
|
||||
) {
|
||||
try {
|
||||
// --- Resolve projectRoot ---
|
||||
// @ts-ignore - task.cwd exists at runtime
|
||||
const cwd = context?.task?.cwd ?? process.cwd()
|
||||
const resolvedRoot = path.isAbsolute(projectRoot) ? projectRoot : path.resolve(cwd, projectRoot)
|
||||
|
||||
if (!existsSync(resolvedRoot)) {
|
||||
return JSON.stringify({ ok: false, error: `Project root does not exist: ${resolvedRoot}` }, null, 2)
|
||||
}
|
||||
|
||||
// --- Auth check ---
|
||||
const whoami = spawnSync("snyk", ["whoami"], { encoding: "utf-8", timeout: 15_000 })
|
||||
if (whoami.status !== 0) {
|
||||
return JSON.stringify({ ok: false, error: "Snyk not authenticated. Run 'snyk auth' first." }, null, 2)
|
||||
}
|
||||
|
||||
// --- Git branch ---
|
||||
const gitBranch = spawnSync("git", ["-C", resolvedRoot, "rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 10_000,
|
||||
})
|
||||
if (gitBranch.status !== 0) {
|
||||
return JSON.stringify({ ok: false, error: `Not a git repository: ${resolvedRoot}` }, null, 2)
|
||||
}
|
||||
const branch = (gitBranch.stdout || "").trim()
|
||||
const branchSafe = branch.replace(/[/\\:*?"<>|]/g, "_")
|
||||
|
||||
// --- Timestamp ---
|
||||
const now = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`
|
||||
|
||||
// --- OutputDir resolution ---
|
||||
const baseName = path.basename(resolvedRoot)
|
||||
const resolvedOutputDir = outputDir
|
||||
? (path.isAbsolute(outputDir) ? outputDir : path.resolve(cwd, outputDir))
|
||||
: path.join(resolvedRoot, ".snyk-reports", baseName)
|
||||
|
||||
if (!existsSync(resolvedOutputDir)) {
|
||||
mkdirSync(resolvedOutputDir, { recursive: true })
|
||||
}
|
||||
|
||||
// --- File paths ---
|
||||
const scaJsonPath = path.join(resolvedOutputDir, `snyk-sca_${branchSafe}_${timestamp}.json`)
|
||||
const codeJsonPath = path.join(resolvedOutputDir, `snyk-code_${branchSafe}_${timestamp}.json`)
|
||||
const summaryPath = path.join(resolvedOutputDir, `snyk-zusammenfassung_${branchSafe}_${timestamp}.json`)
|
||||
|
||||
const warnings: string[] = []
|
||||
const commands: { sca: string | null; code: string | null } = { sca: null, code: null }
|
||||
const exitCodes: { sca: number | null; code: number | null } = { sca: null, code: null }
|
||||
|
||||
// --- Maven Wrapper hack ---
|
||||
const hasPom = existsSync(path.join(resolvedRoot, "pom.xml"))
|
||||
const wrapperFiles: { original: string; backup: string }[] = []
|
||||
|
||||
function disableMavenWrapper() {
|
||||
if (!hasPom) return
|
||||
for (const candidate of ["mvnw", "mvnw.cmd"]) {
|
||||
const original = path.join(resolvedRoot, candidate)
|
||||
if (existsSync(original)) {
|
||||
const backup = `${original}.snyk-disabled`
|
||||
renameSync(original, backup)
|
||||
wrapperFiles.push({ original, backup })
|
||||
}
|
||||
}
|
||||
if (wrapperFiles.length > 0) {
|
||||
warnings.push("Maven Wrapper temporarily disabled during scan")
|
||||
}
|
||||
}
|
||||
|
||||
function restoreMavenWrapper() {
|
||||
for (const { original, backup } of wrapperFiles) {
|
||||
if (existsSync(backup)) {
|
||||
renameSync(backup, original)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Spawn helper: stream stdout to file, capture for parsing ---
|
||||
function runSnyk(args: string[], outPath: string, abortSignal: AbortSignal): Promise<{ exitCode: number; outputPath: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (abortSignal.aborted) {
|
||||
return reject(new Error("Timeout"))
|
||||
}
|
||||
|
||||
const proc = spawn("snyk", args, {
|
||||
cwd: resolvedRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
const ws: WriteStream = createWriteStream(outPath, { encoding: "utf-8" })
|
||||
proc.stdout.pipe(ws)
|
||||
// Also capture stderr into the same file (snyk mixes channels)
|
||||
proc.stderr.pipe(ws, { end: false })
|
||||
|
||||
const onAbort = () => {
|
||||
proc.kill("SIGTERM")
|
||||
reject(new Error("Timeout"))
|
||||
}
|
||||
abortSignal.addEventListener("abort", onAbort, { once: true })
|
||||
|
||||
proc.on("error", (err: Error) => {
|
||||
abortSignal.removeEventListener("abort", onAbort)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
proc.on("close", (code: number | null) => {
|
||||
abortSignal.removeEventListener("abort", onAbort)
|
||||
ws.end(() => {
|
||||
resolve({ exitCode: code ?? 1, outputPath: outPath })
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// --- Exit code logic (port of Test-IsSnykFailure) ---
|
||||
function isSnykFailure(exitCode: number, outputPath: string, scanMode: "SCA" | "CODE"): boolean {
|
||||
if (exitCode === 0) return false
|
||||
if (exitCode > 1) return true
|
||||
|
||||
// Exit 1 — usually means "findings detected". Parse to confirm.
|
||||
let text: string
|
||||
try {
|
||||
text = readFileSync(outputPath, "utf-8")
|
||||
} catch {
|
||||
return true // Can't read output → treat as failure
|
||||
}
|
||||
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
return true // Not valid JSON → failure
|
||||
}
|
||||
|
||||
if (scanMode === "SCA") {
|
||||
if (Array.isArray(parsed)) return false
|
||||
if (parsed && typeof parsed === "object" && "error" in parsed) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// CODE mode — SARIF format
|
||||
if (parsed && typeof parsed === "object" && "runs" in parsed) return false
|
||||
if (parsed && typeof parsed === "object" && "error" in parsed) return true
|
||||
return true
|
||||
}
|
||||
|
||||
// --- Run scans ---
|
||||
const controller = new AbortController()
|
||||
const timeoutHandle = setTimeout(() => controller.abort(), timeoutSec * 1000)
|
||||
|
||||
try {
|
||||
disableMavenWrapper()
|
||||
|
||||
// SCA scan
|
||||
if (mode === "sca" || mode === "both") {
|
||||
const scaArgs = ["test", "--all-projects", "--json"]
|
||||
if (includeIgnores) scaArgs.push("--include-ignores")
|
||||
if (hasPom) scaArgs.push("--command=mvn")
|
||||
commands.sca = `snyk ${scaArgs.join(" ")}`
|
||||
|
||||
const scaResult = await runSnyk(scaArgs, scaJsonPath, controller.signal)
|
||||
exitCodes.sca = scaResult.exitCode
|
||||
|
||||
if (isSnykFailure(scaResult.exitCode, scaJsonPath, "SCA")) {
|
||||
warnings.push(`SCA scan failed (exit ${scaResult.exitCode}). Check ${scaJsonPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
// SAST scan
|
||||
if (mode === "code" || mode === "both") {
|
||||
const codeArgs = ["code", "test", ".", `--severity-threshold=${severityThreshold}`, "--json"]
|
||||
if (includeIgnores) codeArgs.push("--include-ignores")
|
||||
commands.code = `snyk ${codeArgs.join(" ")}`
|
||||
|
||||
const codeResult = await runSnyk(codeArgs, codeJsonPath, controller.signal)
|
||||
exitCodes.code = codeResult.exitCode
|
||||
|
||||
if (isSnykFailure(codeResult.exitCode, codeJsonPath, "CODE")) {
|
||||
warnings.push(`SAST scan failed (exit ${codeResult.exitCode}). Check ${codeJsonPath}`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle)
|
||||
restoreMavenWrapper()
|
||||
}
|
||||
|
||||
// --- Build Zusammenfassung ---
|
||||
function getGroupedSCA(jsonPath: string): any[] {
|
||||
if (!existsSync(jsonPath)) return []
|
||||
let raw: string
|
||||
try { raw = readFileSync(jsonPath, "utf-8") } catch { return [] }
|
||||
let data: any
|
||||
try { data = JSON.parse(raw) } catch { return [] }
|
||||
|
||||
// Collect all vulnerabilities
|
||||
let vulnerabilities: any[] = []
|
||||
if (Array.isArray(data)) {
|
||||
for (const project of data) {
|
||||
if (project && Array.isArray(project.vulnerabilities)) {
|
||||
vulnerabilities.push(...project.vulnerabilities)
|
||||
}
|
||||
}
|
||||
} else if (data && Array.isArray(data.vulnerabilities)) {
|
||||
vulnerabilities = data.vulnerabilities
|
||||
}
|
||||
|
||||
if (vulnerabilities.length === 0) return []
|
||||
|
||||
// Group by id
|
||||
const groups: Record<string, any[]> = {}
|
||||
for (const vuln of vulnerabilities) {
|
||||
const id = vuln.id || "unknown"
|
||||
if (!groups[id]) groups[id] = []
|
||||
groups[id].push(vuln)
|
||||
}
|
||||
|
||||
const result: any[] = []
|
||||
for (const [id, group] of Object.entries(groups)) {
|
||||
const first = group[0]
|
||||
result.push({
|
||||
id,
|
||||
title: first.title || "",
|
||||
severity: first.severity || "unknown",
|
||||
packageName: first.packageName || "",
|
||||
version: first.version || "",
|
||||
count: group.length,
|
||||
type: "SCA",
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by severity then id
|
||||
const sevOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3, unknown: 4 }
|
||||
result.sort((a, b) => {
|
||||
const sa = sevOrder[a.severity] ?? 4
|
||||
const sb = sevOrder[b.severity] ?? 4
|
||||
if (sa !== sb) return sa - sb
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function getGroupedSAST(jsonPath: string): any[] {
|
||||
if (!existsSync(jsonPath)) return []
|
||||
let raw: string
|
||||
try { raw = readFileSync(jsonPath, "utf-8") } catch { return [] }
|
||||
let data: any
|
||||
try { data = JSON.parse(raw) } catch { return [] }
|
||||
|
||||
if (!data || !data.runs) return []
|
||||
|
||||
// Collect all results from all runs
|
||||
let allResults: any[] = []
|
||||
for (const run of data.runs) {
|
||||
if (run && Array.isArray(run.results)) {
|
||||
allResults.push(...run.results)
|
||||
}
|
||||
}
|
||||
|
||||
if (allResults.length === 0) return []
|
||||
|
||||
// Group by ruleId
|
||||
const groups: Record<string, any[]> = {}
|
||||
for (const result of allResults) {
|
||||
const ruleId = result.ruleId || "unknown"
|
||||
if (!groups[ruleId]) groups[ruleId] = []
|
||||
groups[ruleId].push(result)
|
||||
}
|
||||
|
||||
const output: any[] = []
|
||||
for (const [ruleId, group] of Object.entries(groups)) {
|
||||
const first = group[0]
|
||||
const findings = group.map((r) => {
|
||||
const loc = r.locations?.[0]?.physicalLocation
|
||||
return {
|
||||
file: loc?.artifactLocation?.uri || "",
|
||||
line: loc?.region?.startLine || 0,
|
||||
}
|
||||
})
|
||||
output.push({
|
||||
id: ruleId,
|
||||
title: first.message?.text || "",
|
||||
severity: first.level || "warning",
|
||||
count: group.length,
|
||||
type: "SAST",
|
||||
findings,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by severity then id
|
||||
const sevOrder: Record<string, number> = { error: 0, warning: 1, note: 2 }
|
||||
output.sort((a, b) => {
|
||||
const sa = sevOrder[a.severity] ?? 3
|
||||
const sb = sevOrder[b.severity] ?? 3
|
||||
if (sa !== sb) return sa - sb
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
const scaFindings = (mode === "sca" || mode === "both") ? getGroupedSCA(scaJsonPath) : []
|
||||
const codeFindings = (mode === "code" || mode === "both") ? getGroupedSAST(codeJsonPath) : []
|
||||
|
||||
const summaryObj = {
|
||||
branch,
|
||||
timestamp,
|
||||
baseDir: resolvedRoot,
|
||||
project: {
|
||||
project: baseName,
|
||||
sca: scaFindings,
|
||||
code: codeFindings,
|
||||
},
|
||||
}
|
||||
|
||||
// Write Zusammenfassung to disk
|
||||
const summaryJson = JSON.stringify(summaryObj, null, 2)
|
||||
writeFileSync(summaryPath, summaryJson, "utf-8")
|
||||
|
||||
// --- Return shape ---
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
command: commands,
|
||||
branch,
|
||||
timestamp,
|
||||
outputDir: resolvedOutputDir,
|
||||
files: {
|
||||
sca: (mode === "sca" || mode === "both") ? scaJsonPath : null,
|
||||
code: (mode === "code" || mode === "both") ? codeJsonPath : null,
|
||||
summary: summaryPath,
|
||||
},
|
||||
summary: summaryObj,
|
||||
exitCodes,
|
||||
warnings,
|
||||
}, null, 2)
|
||||
} catch (err: any) {
|
||||
return JSON.stringify({ ok: false, error: err.message ?? String(err) }, null, 2)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { parametersSchema as z, defineCustomTool, CustomToolContext } from "@roo-code/types"
|
||||
// @ts-ignore - Node built-ins
|
||||
import { readdirSync, readFileSync, statSync } from "fs"
|
||||
// @ts-ignore - Node built-ins
|
||||
import { join, resolve } from "path"
|
||||
|
||||
export default defineCustomTool({
|
||||
name: "surefire_failures_summary",
|
||||
description: "Find Maven surefire/failsafe XML reports and return a structured summary of ONLY the failed/errored tests. Saves reading dozens of XML files manually.",
|
||||
parameters: z.object({
|
||||
projectRoot: z.string().describe("Repo root or module path (absolute or relative)"),
|
||||
module: z.string().optional().describe("Sub-path like 'java/modules/cs-modules/eau' to scope the search"),
|
||||
}),
|
||||
async execute({ projectRoot, module }, context: CustomToolContext) {
|
||||
try {
|
||||
// @ts-ignore - installed dependency
|
||||
const { XMLParser } = require("fast-xml-parser")
|
||||
|
||||
const root = resolve(module ? join(projectRoot, module) : projectRoot)
|
||||
const reportFiles: string[] = []
|
||||
|
||||
// Recursive walk to find surefire report XMLs
|
||||
const walk = (dir: string, depth: number) => {
|
||||
if (depth > 12) return
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(dir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry === "node_modules" || entry === ".git") continue
|
||||
const full = join(dir, entry)
|
||||
try {
|
||||
const st = statSync(full)
|
||||
if (st.isDirectory()) {
|
||||
if (entry === "surefire-reports" || entry === "failsafe-reports") {
|
||||
// Collect TEST-*.xml from this directory
|
||||
const xmls = readdirSync(full).filter(
|
||||
(f: string) => f.startsWith("TEST-") && f.endsWith(".xml")
|
||||
)
|
||||
for (const xml of xmls) {
|
||||
reportFiles.push(join(full, xml))
|
||||
}
|
||||
} else {
|
||||
walk(full, depth + 1)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip unreadable
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root, 0)
|
||||
|
||||
if (reportFiles.length === 0) {
|
||||
return JSON.stringify({
|
||||
reportsFound: 0,
|
||||
totalTests: 0,
|
||||
failed: 0,
|
||||
errors: 0,
|
||||
skipped: 0,
|
||||
failures: [],
|
||||
note: "No surefire/failsafe report XMLs found. Run 'mvn test' first.",
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
// Filter to only recent reports (within 1 hour of the newest)
|
||||
const mtimes = reportFiles.map((f) => {
|
||||
try { return statSync(f).mtimeMs } catch { return 0 }
|
||||
})
|
||||
const newest = Math.max(...mtimes)
|
||||
const cutoff = newest - 3600_000 // 1 hour
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: "@_",
|
||||
isArray: (name: string) => name === "testcase",
|
||||
})
|
||||
|
||||
let totalTests = 0
|
||||
let totalFailed = 0
|
||||
let totalErrors = 0
|
||||
let totalSkipped = 0
|
||||
let reportsProcessed = 0
|
||||
const failures: any[] = []
|
||||
|
||||
for (let i = 0; i < reportFiles.length; i++) {
|
||||
if (mtimes[i] < cutoff) continue
|
||||
reportsProcessed++
|
||||
|
||||
const xml = readFileSync(reportFiles[i], "utf-8")
|
||||
let doc: any
|
||||
try {
|
||||
doc = parser.parse(xml)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const suite = doc.testsuite
|
||||
if (!suite) continue
|
||||
|
||||
const tests = parseInt(suite["@_tests"] || "0", 10)
|
||||
const failed = parseInt(suite["@_failures"] || "0", 10)
|
||||
const errors = parseInt(suite["@_errors"] || "0", 10)
|
||||
const skipped = parseInt(suite["@_skipped"] || "0", 10)
|
||||
|
||||
totalTests += tests
|
||||
totalFailed += failed
|
||||
totalErrors += errors
|
||||
totalSkipped += skipped
|
||||
|
||||
if ((failed + errors) > 0 && suite.testcase) {
|
||||
for (const tc of suite.testcase) {
|
||||
const failure = tc.failure || tc.error
|
||||
if (!failure) continue
|
||||
const msg = typeof failure === "string" ? failure : (failure["#text"] || failure["@_message"] || "")
|
||||
const type = failure["@_type"] || "Unknown"
|
||||
const lines = msg.split("\n").filter((l: string) => l.trim())
|
||||
failures.push({
|
||||
testClass: tc["@_classname"] || suite["@_name"] || "",
|
||||
testName: tc["@_name"] || "",
|
||||
type,
|
||||
messageFirstLine: lines[0]?.substring(0, 200) || "",
|
||||
stackFirstLine: lines[1]?.substring(0, 200) || "",
|
||||
file: reportFiles[i],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
reportsFound: reportsProcessed,
|
||||
totalTests,
|
||||
failed: totalFailed,
|
||||
errors: totalErrors,
|
||||
skipped: totalSkipped,
|
||||
failures: failures.slice(0, 50), // cap at 50 to prevent token explosion
|
||||
}, null, 2)
|
||||
} catch (err: any) {
|
||||
return JSON.stringify({ error: err.message ?? String(err) }, null, 2)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { parametersSchema as z, defineCustomTool } from "@roo-code/types"
|
||||
import { spawnSync } from "child_process"
|
||||
|
||||
export default defineCustomTool({
|
||||
name: "web_fetch",
|
||||
description: "Fetch a web page and return its content as clean text. Uses curl + HTML stripping. Lightweight alternative to the WebScraper MCP for simple page fetches.",
|
||||
parameters: z.object({
|
||||
url: z.string().describe("URL to fetch"),
|
||||
maxChars: z.number().optional().describe("Truncate output at this many characters. Default: 15000"),
|
||||
}),
|
||||
async execute({ url, maxChars }) {
|
||||
const limit = maxChars || 15000
|
||||
const result = spawnSync("curl", ["-sL", "--max-time", "15", "-H", "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", url], {
|
||||
encoding: "utf-8",
|
||||
timeout: 20000,
|
||||
maxBuffer: 5 * 1024 * 1024,
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
return `Error fetching ${url}: ${result.stderr || "curl failed"}`
|
||||
}
|
||||
|
||||
let text = result.stdout
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
|
||||
if (text.length > limit) {
|
||||
text = text.substring(0, limit) + `\n... [truncated at ${limit} chars]`
|
||||
}
|
||||
|
||||
return text
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { parametersSchema as z, defineCustomTool } from "@roo-code/types"
|
||||
import { spawnSync } from "child_process"
|
||||
|
||||
export default defineCustomTool({
|
||||
name: "worktree_list",
|
||||
description: "List all git worktrees in the PAISY repo with their branches and paths. Returns structured JSON.",
|
||||
parameters: z.object({
|
||||
repoPath: z.string().optional().describe("Path to the git repo. Defaults to /Users/pplate/git/paisy"),
|
||||
}),
|
||||
async execute({ repoPath }) {
|
||||
const repo = repoPath || "/Users/pplate/git/paisy"
|
||||
const result = spawnSync("git", ["-C", repo, "worktree", "list", "--porcelain"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
return `Error: ${result.stderr || "git worktree list failed"}`
|
||||
}
|
||||
|
||||
const worktrees: Array<{ path: string; head: string; branch: string }> = []
|
||||
let current: { path: string; head: string; branch: string } = { path: "", head: "", branch: "" }
|
||||
|
||||
for (const line of result.stdout.split("\n")) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
if (current.path) worktrees.push({ ...current })
|
||||
current = { path: line.replace("worktree ", ""), head: "", branch: "" }
|
||||
} else if (line.startsWith("HEAD ")) {
|
||||
current.head = line.replace("HEAD ", "")
|
||||
} else if (line.startsWith("branch ")) {
|
||||
current.branch = line.replace("branch refs/heads/", "")
|
||||
} else if (line === "" && current.path) {
|
||||
worktrees.push({ ...current })
|
||||
current = { path: "", head: "", branch: "" }
|
||||
}
|
||||
}
|
||||
if (current.path) worktrees.push(current)
|
||||
|
||||
return JSON.stringify(worktrees, null, 2)
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user