refactor(tools): remove native ast-grep tool

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-05-18 21:19:33 +09:00
parent ef09880e26
commit a86cc6af18
23 changed files with 0 additions and 1642 deletions
@@ -90,7 +90,6 @@ describe("team-mode tool registry wiring", () => {
createSkillTool: mock(() => fakeTool),
createGrepTools: mock(() => ({})),
createGlobTools: mock(() => ({})),
createAstGrepTools: mock(() => ({})),
createSessionManagerTools: mock(() => ({})),
createDelegateTask: mock(() => fakeTool),
discoverCommandsSync: mock(() => []),
@@ -164,7 +163,6 @@ describe("team-mode tool registry wiring", () => {
createSkillTool: mock(() => fakeTool),
createGrepTools: mock(() => ({})),
createGlobTools: mock(() => ({})),
createAstGrepTools: mock(() => ({})),
createSessionManagerTools: mock(() => ({})),
createDelegateTask: mock(() => fakeTool),
discoverCommandsSync: mock(() => []),
-1
View File
@@ -53,7 +53,6 @@ const toolFactories: NonNullable<Parameters<typeof createToolRegistry>[0]["toolF
createSkillTool: mock(() => fakeTool),
createGrepTools: mock(() => ({})),
createGlobTools: mock(() => ({})),
createAstGrepTools: mock(() => ({})),
createSessionManagerTools: mock(() => ({})),
createDelegateTask: mock((options: { onSyncSessionCreated?: typeof syncSessionCreatedCallbacks[number] }) => {
syncSessionCreatedCallbacks.push(options.onSyncSessionCreated)
-4
View File
@@ -32,7 +32,6 @@ import {
createSkillTool,
createGrepTools,
createGlobTools,
createAstGrepTools,
createSessionManagerTools,
createDelegateTask,
discoverCommandsSync,
@@ -59,7 +58,6 @@ type ToolRegistryFactories = {
createSkillTool: typeof createSkillTool
createGrepTools: typeof createGrepTools
createGlobTools: typeof createGlobTools
createAstGrepTools: typeof createAstGrepTools
createSessionManagerTools: typeof createSessionManagerTools
createDelegateTask: typeof createDelegateTask
discoverCommandsSync: typeof discoverCommandsSync
@@ -91,7 +89,6 @@ const defaultToolRegistryFactories: ToolRegistryFactories = {
createSkillTool,
createGrepTools,
createGlobTools,
createAstGrepTools,
createSessionManagerTools,
createDelegateTask,
discoverCommandsSync,
@@ -338,7 +335,6 @@ export function createToolRegistry(args: {
const allTools: Record<string, ToolDefinition> = {
...factories.createGrepTools(ctx),
...factories.createGlobTools(ctx),
...factories.createAstGrepTools(ctx),
...factories.createSessionManagerTools(ctx),
...backgroundTools,
call_omo_agent: callOmoAgent,
-54
View File
@@ -1,54 +0,0 @@
# src/tools/ast-grep/ -- AST-Aware Search and Rewrite
**Generated:** 2026-05-18
## OVERVIEW
Two always-on tools: `ast_grep_search` (find AST patterns) and `ast_grep_replace` (rewrite AST patterns). 25 languages supported via `@ast-grep/napi` as primary backend with fallback to `sg` CLI.
Pattern syntax uses AST meta-variables, not regex. `$VAR` matches one AST node. `$$$` matches zero or more nodes. `$$$VAR` captures a named list. Patterns must be complete, parseable source code.
`ast_grep_replace` defaults to dry-run. Pass `dryRun=false` to apply changes.
## FILE CATALOG
| File | Role |
|------|------|
| `tools.ts` | `createAstGrepTools` factory -- returns Record with 2 tool entries |
| `cli.ts` | `runSg` -- spawns sg process, handles two-pass rewrite |
| `cli-binary-path-resolution.ts` | Async init wrapper with singleton promise dedup |
| `sg-cli-path.ts` | Resolve sg via node_modules, platform subpackages, Homebrew, or cache |
| `downloader.ts` | Auto-download from GitHub releases if missing |
| `environment-check.ts` | Verify CLI + NAPI availability at startup |
| `language-support.ts` | 25 CLI languages + 5 NAPI languages + extension map |
| `pattern-hints.ts` | Detect regex misuse and language-specific mistakes |
| `result-formatter.ts` | Format matches with file:line:column for LLM |
| `sg-compact-json-output.ts` | Parse `sg --json=compact` into `SgResult` |
| `tool-descriptions.ts` | Tool description constants |
| `process-output-timeout.ts` | 300s timeout wrapper for spawn |
| `types.ts` | `CliMatch`, `SgResult`, `AnalyzeResult`, etc. |
| `constants.ts` | Re-exports from language-support, environment-check, sg-cli-path |
| `index.ts` | Barrel |
## KEY BEHAVIORS
- Dual binary detection: NAPI primary, CLI fallback
- Fallback chain: node_modules → platform subpackage → Homebrew → cached download
- Dry-run protection: `ast_grep_replace` defaults to preview; pass `dryRun=false` to apply
- Two-pass rewrite: when rewrite + apply both requested, cli.ts runs `--json=compact` first, then `--update-all`
- Output limits: 1MB max output or 500 matches, whichever comes first
- Timeout: 300s cap via `process-output-timeout.ts`; kills process and returns truncated result
## LANGUAGES
25 CLI languages: bash, c, cpp, csharp, css, elixir, go, haskell, html, java, javascript, json, kotlin, lua, nix, php, python, ruby, rust, scala, solidity, swift, typescript, tsx, yaml.
5 NAPI languages (native bindings): html, javascript, tsx, css, typescript.
## PATTERN HINTS
When a search returns zero matches, `pattern-hints.ts` scans for regex-style misuse (`|`, `.*`, `\w`, `[a-z]`) and returns a corrective hint redirecting to ast-grep meta-variable syntax. Also catches language-specific mistakes like trailing colons in Python def/class patterns or incomplete function signatures in JS/Go/Rust.
## RELATED
Doctor check at `src/cli/doctor/checks/tools.ts` verifies both NAPI and CLI availability.
@@ -1,60 +0,0 @@
import { existsSync } from "fs"
import { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./constants"
import { ensureAstGrepBinary } from "./downloader"
let resolvedCliPath: string | null = null
let initPromise: Promise<string | null> | null = null
export async function getAstGrepPath(): Promise<string | null> {
if (resolvedCliPath !== null && existsSync(resolvedCliPath)) {
return resolvedCliPath
}
if (initPromise) {
return initPromise
}
initPromise = (async () => {
const syncPath = findSgCliPathSync()
if (syncPath && existsSync(syncPath)) {
resolvedCliPath = syncPath
setSgCliPath(syncPath)
return syncPath
}
const downloadedPath = await ensureAstGrepBinary()
if (downloadedPath) {
resolvedCliPath = downloadedPath
setSgCliPath(downloadedPath)
return downloadedPath
}
return null
})()
return initPromise
}
export function startBackgroundInit(): void {
if (!initPromise) {
initPromise = getAstGrepPath()
initPromise.catch(() => {})
}
}
export function isCliAvailable(): boolean {
const path = findSgCliPathSync()
return path !== null && existsSync(path)
}
export async function ensureCliAvailable(): Promise<boolean> {
const path = await getAstGrepPath()
return path !== null && existsSync(path)
}
export function getResolvedSgCliPath(): string | null {
const path = getSgCliPath()
if (path && existsSync(path)) return path
return null
}
-177
View File
@@ -1,177 +0,0 @@
import { spawn } from "../../shared/bun-spawn-shim"
import { existsSync } from "fs"
import {
getSgCliPath,
DEFAULT_TIMEOUT_MS,
} from "./constants"
import { ensureAstGrepBinary } from "./downloader"
import type { CliLanguage, SgResult } from "./types"
import { getAstGrepPath } from "./cli-binary-path-resolution"
import { collectProcessOutputWithTimeout } from "./process-output-timeout"
import { createSgResultFromStdout } from "./sg-compact-json-output"
export {
ensureCliAvailable,
getAstGrepPath,
isCliAvailable,
startBackgroundInit,
} from "./cli-binary-path-resolution"
export interface RunOptions {
pattern: string
lang: CliLanguage
paths?: string[]
globs?: string[]
rewrite?: string
context?: number
updateAll?: boolean
}
export async function runSg(options: RunOptions): Promise<SgResult> {
// ast-grep CLI silently ignores --update-all when --json is present.
// When both rewrite and updateAll are requested, we must run two separate
// invocations: one with --json=compact to collect match results, and
// another with --update-all to perform the actual file writes.
const shouldSeparateWritePass = !!(options.rewrite && options.updateAll)
const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
if (options.rewrite) {
args.push("-r", options.rewrite)
if (options.updateAll && !shouldSeparateWritePass) {
args.push("--update-all")
}
}
if (options.context && options.context > 0) {
args.push("-C", String(options.context))
}
if (options.globs) {
for (const glob of options.globs) {
args.push("--globs", glob)
}
}
const paths = options.paths && options.paths.length > 0 ? options.paths : ["."]
args.push(...paths)
let cliPath = getSgCliPath()
if (!cliPath || !existsSync(cliPath)) {
const downloadedPath = await getAstGrepPath()
if (downloadedPath) {
cliPath = downloadedPath
} else {
return {
matches: [],
totalMatches: 0,
truncated: false,
error:
`ast-grep (sg) binary not found.\n\n` +
`Install options:\n` +
` bun add -D @ast-grep/cli\n` +
` cargo install ast-grep --locked\n` +
` brew install ast-grep`,
}
}
}
const timeout = DEFAULT_TIMEOUT_MS
const proc = spawn([cliPath, ...args], {
stdout: "pipe",
stderr: "pipe",
})
let stdout: string
let stderr: string
let exitCode: number
try {
const output = await collectProcessOutputWithTimeout(proc, timeout)
stdout = output.stdout
stderr = output.stderr
exitCode = output.exitCode
} catch (error) {
if (error instanceof Error && error.message.includes("timeout")) {
return {
matches: [],
totalMatches: 0,
truncated: true,
truncatedReason: "timeout",
error: error.message,
}
}
const errorMessage = error instanceof Error ? error.message : String(error)
const errorCode =
typeof error === "object" && error !== null && "code" in error
? (error as { code?: unknown }).code
: undefined
const isNoEntry =
errorCode === "ENOENT" || errorMessage.includes("ENOENT") || errorMessage.includes("not found")
if (isNoEntry) {
const downloadedPath = await ensureAstGrepBinary()
if (downloadedPath) {
return runSg(options)
} else {
return {
matches: [],
totalMatches: 0,
truncated: false,
error:
`ast-grep CLI binary not found.\n\n` +
`Auto-download failed. Manual install options:\n` +
` bun add -D @ast-grep/cli\n` +
` cargo install ast-grep --locked\n` +
` brew install ast-grep`,
}
}
}
return {
matches: [],
totalMatches: 0,
truncated: false,
error: `Failed to spawn ast-grep: ${errorMessage}`,
}
}
if (exitCode !== 0 && stdout.trim() === "") {
if (stderr.includes("No files found")) {
return { matches: [], totalMatches: 0, truncated: false }
}
if (stderr.trim()) {
return { matches: [], totalMatches: 0, truncated: false, error: stderr.trim() }
}
return { matches: [], totalMatches: 0, truncated: false }
}
const jsonResult = createSgResultFromStdout(stdout)
if (shouldSeparateWritePass && jsonResult.matches.length > 0) {
const writeArgs = args.filter(a => a !== "--json=compact")
writeArgs.push("--update-all")
const writeProc = spawn([cliPath, ...writeArgs], {
stdout: "pipe",
stderr: "pipe",
})
try {
const writeOutput = await collectProcessOutputWithTimeout(writeProc, timeout)
if (writeOutput.exitCode !== 0) {
const errorDetail = writeOutput.stderr.trim() || `ast-grep exited with code ${writeOutput.exitCode}`
return { ...jsonResult, error: `Replace failed: ${errorDetail}` }
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return { ...jsonResult, error: `Replace failed: ${errorMessage}` }
}
}
return jsonResult
}
-5
View File
@@ -1,5 +0,0 @@
export type { EnvironmentCheckResult } from "./environment-check"
export { checkEnvironment, formatEnvironmentCheck } from "./environment-check"
export { CLI_LANGUAGES, NAPI_LANGUAGES, LANG_EXTENSIONS } from "./language-support"
export { DEFAULT_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_MAX_MATCHES } from "./language-support"
export { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./sg-cli-path"
-119
View File
@@ -1,119 +0,0 @@
import { existsSync } from "fs"
import { join } from "path"
import { homedir } from "os"
import { createRequire } from "module"
import {
cleanupArchive,
downloadArchive,
ensureCacheDir,
ensureExecutable,
extractZipArchive,
getCachedBinaryPath as getCachedBinaryPathShared,
} from "../../shared/binary-downloader"
import { log } from "../../shared/logger"
import { CACHE_DIR_NAME, PUBLISHED_PACKAGE_NAME } from "../../shared/plugin-identity"
const REPO = "ast-grep/ast-grep"
// IMPORTANT: Update this when bumping @ast-grep/cli in package.json
// This is only used as fallback when @ast-grep/cli package.json cannot be read
const DEFAULT_VERSION = "0.41.1"
function getAstGrepVersion(): string {
try {
const require = createRequire(import.meta.url)
const pkg = require("@ast-grep/cli/package.json")
return pkg.version
} catch {
return DEFAULT_VERSION
}
}
interface PlatformInfo {
arch: string
os: string
}
const PLATFORM_MAP: Record<string, PlatformInfo> = {
"darwin-arm64": { arch: "aarch64", os: "apple-darwin" },
"darwin-x64": { arch: "x86_64", os: "apple-darwin" },
"linux-arm64": { arch: "aarch64", os: "unknown-linux-gnu" },
"linux-x64": { arch: "x86_64", os: "unknown-linux-gnu" },
"win32-x64": { arch: "x86_64", os: "pc-windows-msvc" },
"win32-arm64": { arch: "aarch64", os: "pc-windows-msvc" },
"win32-ia32": { arch: "i686", os: "pc-windows-msvc" },
}
export function getCacheDir(): string {
if (process.platform === "win32") {
const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA
const base = localAppData || join(homedir(), "AppData", "Local")
return join(base, CACHE_DIR_NAME, "bin")
}
const xdgCache = process.env.XDG_CACHE_HOME
const base = xdgCache || join(homedir(), ".cache")
return join(base, CACHE_DIR_NAME, "bin")
}
export function getBinaryName(): string {
return process.platform === "win32" ? "sg.exe" : "sg"
}
export function getCachedBinaryPath(): string | null {
return getCachedBinaryPathShared(getCacheDir(), getBinaryName())
}
export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promise<string | null> {
const platformKey = `${process.platform}-${process.arch}`
const platformInfo = PLATFORM_MAP[platformKey]
if (!platformInfo) {
log(`[${PUBLISHED_PACKAGE_NAME}] Unsupported platform for ast-grep: ${platformKey}`)
return null
}
const cacheDir = getCacheDir()
const binaryName = getBinaryName()
const binaryPath = join(cacheDir, binaryName)
if (existsSync(binaryPath)) {
return binaryPath
}
const { arch, os } = platformInfo
const assetName = `app-${arch}-${os}.zip`
const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/${assetName}`
log(`[${PUBLISHED_PACKAGE_NAME}] Downloading ast-grep binary...`)
try {
const archivePath = join(cacheDir, assetName)
ensureCacheDir(cacheDir)
await downloadArchive(downloadUrl, archivePath)
await extractZipArchive(archivePath, cacheDir)
cleanupArchive(archivePath)
ensureExecutable(binaryPath)
log(`[${PUBLISHED_PACKAGE_NAME}] ast-grep binary ready.`)
return binaryPath
} catch (err) {
log(
`[${PUBLISHED_PACKAGE_NAME}] Failed to download ast-grep: ${err instanceof Error ? err.message : err}`
)
return null
}
}
export async function ensureAstGrepBinary(): Promise<string | null> {
const cachedPath = getCachedBinaryPath()
if (cachedPath) {
return cachedPath
}
const version = getAstGrepVersion()
return downloadAstGrep(version)
}
-89
View File
@@ -1,89 +0,0 @@
import { existsSync } from "fs"
import { CLI_LANGUAGES, NAPI_LANGUAGES } from "./language-support"
import { getSgCliPath } from "./sg-cli-path"
export interface EnvironmentCheckResult {
cli: {
available: boolean
path: string
error?: string
}
napi: {
available: boolean
error?: string
}
}
/**
* Check if ast-grep CLI and NAPI are available.
* Call this at startup to provide early feedback about missing dependencies.
*/
export function checkEnvironment(): EnvironmentCheckResult {
const cliPath = getSgCliPath()
const result: EnvironmentCheckResult = {
cli: {
available: false,
path: cliPath ?? "not found",
},
napi: {
available: false,
},
}
if (cliPath && existsSync(cliPath)) {
result.cli.available = true
} else if (!cliPath) {
result.cli.error = "ast-grep binary not found. Install with: bun add -D @ast-grep/cli"
} else {
result.cli.error = `Binary not found: ${cliPath}`
}
// Check NAPI availability
try {
require("@ast-grep/napi")
result.napi.available = true
} catch (error) {
result.napi.available = false
result.napi.error = `@ast-grep/napi not installed: ${
error instanceof Error ? error.message : String(error)
}`
}
return result
}
/**
* Format environment check result as user-friendly message.
*/
export function formatEnvironmentCheck(result: EnvironmentCheckResult): string {
const lines: string[] = ["ast-grep Environment Status:", ""]
// CLI status
if (result.cli.available) {
lines.push(`[OK] CLI: Available (${result.cli.path})`)
} else {
lines.push("[X] CLI: Not available")
if (result.cli.error) {
lines.push(` Error: ${result.cli.error}`)
}
lines.push(" Install: bun add -D @ast-grep/cli")
}
// NAPI status
if (result.napi.available) {
lines.push("[OK] NAPI: Available")
} else {
lines.push("[X] NAPI: Not available")
if (result.napi.error) {
lines.push(` Error: ${result.napi.error}`)
}
lines.push(" Install: bun add -D @ast-grep/napi")
}
lines.push("")
lines.push(`CLI supports ${CLI_LANGUAGES.length} languages`)
lines.push(`NAPI supports ${NAPI_LANGUAGES.length} languages: ${NAPI_LANGUAGES.join(", ")}`)
return lines.join("\n")
}
-5
View File
@@ -1,5 +0,0 @@
export { createAstGrepTools } from "./tools"
export { ensureAstGrepBinary, getCachedBinaryPath, getCacheDir } from "./downloader"
export { getAstGrepPath, isCliAvailable, ensureCliAvailable, startBackgroundInit } from "./cli"
export { checkEnvironment, formatEnvironmentCheck } from "./constants"
export type { EnvironmentCheckResult } from "./constants"
-63
View File
@@ -1,63 +0,0 @@
// CLI supported languages (25 total)
export const CLI_LANGUAGES = [
"bash",
"c",
"cpp",
"csharp",
"css",
"elixir",
"go",
"haskell",
"html",
"java",
"javascript",
"json",
"kotlin",
"lua",
"nix",
"php",
"python",
"ruby",
"rust",
"scala",
"solidity",
"swift",
"typescript",
"tsx",
"yaml",
] as const
// NAPI supported languages (5 total - native bindings)
export const NAPI_LANGUAGES = ["html", "javascript", "tsx", "css", "typescript"] as const
export const DEFAULT_TIMEOUT_MS = 300_000
export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024
export const DEFAULT_MAX_MATCHES = 500
export const LANG_EXTENSIONS: Record<string, string[]> = {
bash: [".bash", ".sh", ".zsh", ".bats"],
c: [".c", ".h"],
cpp: [".cpp", ".cc", ".cxx", ".hpp", ".hxx", ".h"],
csharp: [".cs"],
css: [".css"],
elixir: [".ex", ".exs"],
go: [".go"],
haskell: [".hs", ".lhs"],
html: [".html", ".htm"],
java: [".java"],
javascript: [".js", ".jsx", ".mjs", ".cjs"],
json: [".json"],
kotlin: [".kt", ".kts"],
lua: [".lua"],
nix: [".nix"],
php: [".php"],
python: [".py", ".pyi"],
ruby: [".rb", ".rake"],
rust: [".rs"],
scala: [".scala", ".sc"],
solidity: [".sol"],
swift: [".swift"],
typescript: [".ts", ".cts", ".mts"],
tsx: [".tsx"],
yaml: [".yml", ".yaml"],
}
-299
View File
@@ -1,299 +0,0 @@
/// <reference types="bun-types" />
import { describe, expect, it } from "bun:test"
import {
detectLanguageSpecificMistake,
detectRegexMisuse,
getPatternHint,
} from "./pattern-hints"
describe("detectRegexMisuse", () => {
describe("#given pure regex alternation", () => {
it("#when pattern is lowercase alternation #then returns alternation hint", () => {
// given
const pattern = "watch|WatchMode|--watch"
// when
const hint = detectRegexMisuse(pattern)
// then
expect(hint).not.toBeNull()
expect(hint).toContain("|")
expect(hint).toContain("alternation")
expect(hint).toContain("grep")
})
it("#when pattern is camelCase alternation #then returns alternation hint", () => {
// given
const pattern = "noEmit|NoEmit"
// when
const hint = detectRegexMisuse(pattern)
// then
expect(hint).toContain("alternation")
})
it("#when pattern mixes wildcard and alternation #then returns a hint", () => {
// given
const pattern = "func.*build|BuildMode|projectReferences"
// when
const hint = detectRegexMisuse(pattern)
// then
expect(hint).not.toBeNull()
})
})
describe("#given valid AST patterns using |", () => {
it("#when pattern uses meta-vars around pipe (bitwise OR) #then returns null", () => {
// given
const pattern = "$A | $B"
// when
const hint = detectRegexMisuse(pattern)
// then
expect(hint).toBeNull()
})
it("#when pattern is a Rust closure #then returns null", () => {
// given
const pattern = "|x| x + 1"
// when
const hint = detectRegexMisuse(pattern)
// then
expect(hint).toBeNull()
})
})
describe("#given regex escape sequences", () => {
it("#when pattern contains \\w #then returns regex-escape hint", () => {
// given
const pattern = "\\w+Mode"
// when
const hint = detectRegexMisuse(pattern)
// then
expect(hint).toContain("regex escape")
expect(hint).toContain("grep")
})
it("#when pattern contains \\d #then returns regex-escape hint", () => {
// given
const pattern = "id\\d+"
// when
const hint = detectRegexMisuse(pattern)
// then
expect(hint).toContain("regex escape")
})
})
describe("#given character class ranges", () => {
it("#when pattern contains [a-z] #then returns character-class hint", () => {
// given
const pattern = "[a-z]+Mode"
// when
const hint = detectRegexMisuse(pattern)
// then
expect(hint).toContain("character classes")
expect(hint).toContain("grep")
})
it("#when pattern contains [0-9] #then returns character-class hint", () => {
// given
const pattern = "v[0-9]+"
// when
const hint = detectRegexMisuse(pattern)
// then
expect(hint).toContain("character classes")
})
})
describe("#given regex wildcards embedded in identifiers", () => {
it("#when pattern uses foo.*bar without meta-vars #then returns wildcard hint", () => {
// given
const pattern = "func.*build"
// when
const hint = detectRegexMisuse(pattern)
// then
expect(hint).toContain("regex wildcards")
expect(hint).toContain("$$$")
})
it("#when pattern uses $$$ (proper AST) #then returns null", () => {
// given
const pattern = "func $NAME($$$) { $$$ }"
// when
const hint = detectRegexMisuse(pattern)
// then
expect(hint).toBeNull()
})
})
describe("#given legitimate AST patterns", () => {
it("#when pattern is a JS function #then returns null", () => {
// given
const pattern = "function $NAME($$$) { $$$ }"
// when
const hint = detectRegexMisuse(pattern)
// then
expect(hint).toBeNull()
})
it("#when pattern is console.log call #then returns null", () => {
// given
const pattern = "console.log($$$)"
// when
const hint = detectRegexMisuse(pattern)
// then
expect(hint).toBeNull()
})
it("#when pattern is a Python def #then returns null", () => {
// given
const pattern = "def $FUNC($$$)"
// when
const hint = detectRegexMisuse(pattern)
// then
expect(hint).toBeNull()
})
it("#when pattern is array access a[0] #then returns null (not character class)", () => {
// given
const pattern = "$A[0]"
// when
const hint = detectRegexMisuse(pattern)
// then
expect(hint).toBeNull()
})
})
})
describe("detectLanguageSpecificMistake", () => {
describe("#given a Python def with trailing colon", () => {
it("#when lang is python #then suggests removing the colon", () => {
// given
const pattern = "def $FUNC($$$):"
// when
const hint = detectLanguageSpecificMistake(pattern, "python")
// then
expect(hint).toContain("Remove trailing colon")
expect(hint).toContain("def $FUNC($$$)")
})
})
describe("#given a Python class with trailing colon", () => {
it("#when lang is python #then suggests removing the colon", () => {
// given
const pattern = "class $C:"
// when
const hint = detectLanguageSpecificMistake(pattern, "python")
// then
expect(hint).toContain("Remove trailing colon")
})
})
describe("#given a TypeScript function with no body", () => {
it("#when lang is typescript #then suggests adding params and body", () => {
// given
const pattern = "function $NAME"
// when
const hint = detectLanguageSpecificMistake(pattern, "typescript")
// then
expect(hint).toContain("params and body")
expect(hint).toContain("function $NAME($$$) { $$$ }")
})
})
describe("#given a Go function with no body", () => {
it("#when lang is go #then suggests Go function template", () => {
// given
const pattern = "func $NAME"
// when
const hint = detectLanguageSpecificMistake(pattern, "go")
// then
expect(hint).not.toBeNull()
expect(hint).toContain("func $NAME($$$) { $$$ }")
})
})
describe("#given a Rust fn with no body", () => {
it("#when lang is rust #then suggests Rust fn template", () => {
// given
const pattern = "fn $NAME"
// when
const hint = detectLanguageSpecificMistake(pattern, "rust")
// then
expect(hint).not.toBeNull()
expect(hint).toContain("fn $NAME($$$) { $$$ }")
})
})
})
describe("getPatternHint", () => {
it("#given regex alternation #when composing #then regex hint wins over language check", () => {
// given
const pattern = "foo|bar"
// when
const hint = getPatternHint(pattern, "typescript")
// then
expect(hint).toContain("alternation")
})
it("#given a clean AST pattern #when composing #then returns null", () => {
// given
const pattern = "function $NAME($$$) { $$$ }"
// when
const hint = getPatternHint(pattern, "typescript")
// then
expect(hint).toBeNull()
})
it("#given a Python def with trailing colon #when composing #then returns the colon hint", () => {
// given
const pattern = "def $FUNC($$$):"
// when
const hint = getPatternHint(pattern, "python")
// then
expect(hint).toContain("Remove trailing colon")
})
})
-63
View File
@@ -1,63 +0,0 @@
import type { CliLanguage } from "./types"
export function detectRegexMisuse(pattern: string): string | null {
const src = pattern.trim()
if (/\\[wWdDsSbB]/.test(src)) {
return 'Hint: "\\w", "\\d", "\\s", "\\b" are regex escapes. ast-grep matches AST nodes, not text - use $VAR for identifiers, $$$ for node lists, or switch to grep for text search.'
}
if (/\[[a-zA-Z0-9]-[a-zA-Z0-9]\]/.test(src)) {
return 'Hint: "[a-z]" and similar character classes are regex, not AST. Use $VAR to match any identifier, or switch to grep for text search.'
}
if (!src.includes("$") && /\w\.[*+]/.test(src)) {
return 'Hint: ".*" and ".+" are regex wildcards. In ast-grep use $$$ for multiple AST nodes and $VAR for a single node. For text patterns, switch to grep.'
}
if (/^[-\w.*]+\|[-\w.*|]+$/.test(src)) {
return 'Hint: "|" is regex alternation and does NOT work in ast-grep patterns. Options: (a) fire one ast_grep_search per alternative, or (b) switch to grep with a regex pattern like "foo|bar".'
}
return null
}
export function detectLanguageSpecificMistake(
pattern: string,
lang: CliLanguage,
): string | null {
const src = pattern.trim()
if (lang === "python") {
if (src.startsWith("class ") && src.endsWith(":")) {
return `Hint: Remove trailing colon. Try: "${src.slice(0, -1)}"`
}
if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) {
return `Hint: Remove trailing colon. Try: "${src.slice(0, -1)}"`
}
}
if (["javascript", "typescript", "tsx"].includes(lang)) {
if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) {
return 'Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"'
}
}
if (lang === "go") {
if (/^func\s+\$[A-Z_]+\s*$/i.test(src)) {
return 'Hint: Go function patterns need params and body. Try "func $NAME($$$) { $$$ }"'
}
}
if (lang === "rust") {
if (/^fn\s+\$[A-Z_]+\s*$/i.test(src)) {
return 'Hint: Rust fn patterns need params and body. Try "fn $NAME($$$) { $$$ }"'
}
}
return null
}
export function getPatternHint(pattern: string, lang: CliLanguage): string | null {
return detectRegexMisuse(pattern) ?? detectLanguageSpecificMistake(pattern, lang)
}
@@ -1,28 +0,0 @@
type SpawnedProcess = {
stdout: ReadableStream | null
stderr: ReadableStream | null
exited: Promise<number>
kill: () => void
}
export async function collectProcessOutputWithTimeout(
process: SpawnedProcess,
timeoutMs: number
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const timeoutPromise = new Promise<never>((_, reject) => {
const timeoutId = setTimeout(() => {
process.kill()
reject(new Error(`Search timeout after ${timeoutMs}ms`))
}, timeoutMs)
process.exited.then(() => clearTimeout(timeoutId))
})
const stdoutPromise = process.stdout ? new Response(process.stdout).text() : Promise.resolve("")
const stderrPromise = process.stderr ? new Response(process.stderr).text() : Promise.resolve("")
const stdout = await Promise.race([stdoutPromise, timeoutPromise])
const stderr = await stderrPromise
const exitCode = await process.exited
return { stdout, stderr, exitCode }
}
-102
View File
@@ -1,102 +0,0 @@
import type { AnalyzeResult, SgResult } from "./types"
export function formatSearchResult(result: SgResult): string {
if (result.error) {
return `Error: ${result.error}`
}
if (result.matches.length === 0) {
return "No matches found"
}
const lines: string[] = []
if (result.truncated) {
const reason = result.truncatedReason === "max_matches"
? `showing first ${result.matches.length} of ${result.totalMatches}`
: result.truncatedReason === "max_output_bytes"
? "output exceeded 1MB limit"
: "search timed out"
lines.push(`[TRUNCATED] Results truncated (${reason})\n`)
}
lines.push(`Found ${result.matches.length} match(es)${result.truncated ? ` (truncated from ${result.totalMatches})` : ""}:\n`)
for (const match of result.matches) {
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
lines.push(`${loc}`)
lines.push(` ${match.lines.trim()}`)
lines.push("")
}
return lines.join("\n")
}
export function formatReplaceResult(result: SgResult, isDryRun: boolean): string {
if (result.error) {
return `Error: ${result.error}`
}
if (result.matches.length === 0) {
return "No matches found to replace"
}
const prefix = isDryRun ? "[DRY RUN] " : ""
const lines: string[] = []
if (result.truncated) {
const reason = result.truncatedReason === "max_matches"
? `showing first ${result.matches.length} of ${result.totalMatches}`
: result.truncatedReason === "max_output_bytes"
? "output exceeded 1MB limit"
: "search timed out"
lines.push(`[TRUNCATED] Results truncated (${reason})\n`)
}
lines.push(`${prefix}${result.matches.length} replacement(s):\n`)
for (const match of result.matches) {
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
lines.push(`${loc}`)
lines.push(` ${match.text}`)
lines.push("")
}
if (isDryRun) {
lines.push("Use dryRun=false to apply changes")
}
return lines.join("\n")
}
export function formatAnalyzeResult(results: AnalyzeResult[], extractedMetaVars: boolean): string {
if (results.length === 0) {
return "No matches found"
}
const lines: string[] = [`Found ${results.length} match(es):\n`]
for (const result of results) {
const loc = `L${result.range.start.line + 1}:${result.range.start.column + 1}`
lines.push(`[${loc}] (${result.kind})`)
lines.push(` ${result.text}`)
if (extractedMetaVars && result.metaVariables.length > 0) {
lines.push(" Meta-variables:")
for (const mv of result.metaVariables) {
lines.push(` $${mv.name} = "${mv.text}" (${mv.kind})`)
}
}
lines.push("")
}
return lines.join("\n")
}
export function formatTransformResult(_original: string, transformed: string, editCount: number): string {
if (editCount === 0) {
return "No matches found to transform"
}
return `Transformed (${editCount} edit(s)):\n\`\`\`\n${transformed}\n\`\`\``
}
-102
View File
@@ -1,102 +0,0 @@
import { createRequire } from "module"
import { dirname, join } from "path"
import { existsSync, statSync } from "fs"
import { getCachedBinaryPath } from "./downloader"
type Platform = "darwin" | "linux" | "win32" | "unsupported"
function isValidBinary(filePath: string): boolean {
try {
return statSync(filePath).size > 10000
} catch {
return false
}
}
function getPlatformPackageName(): string | null {
const platform = process.platform as Platform
const arch = process.arch
const platformMap: Record<string, string> = {
"darwin-arm64": "@ast-grep/cli-darwin-arm64",
"darwin-x64": "@ast-grep/cli-darwin-x64",
"linux-arm64": "@ast-grep/cli-linux-arm64-gnu",
"linux-x64": "@ast-grep/cli-linux-x64-gnu",
"win32-x64": "@ast-grep/cli-win32-x64-msvc",
"win32-arm64": "@ast-grep/cli-win32-arm64-msvc",
"win32-ia32": "@ast-grep/cli-win32-ia32-msvc",
}
return platformMap[`${platform}-${arch}`] ?? null
}
export function findSgCliPathSync(): string | null {
const binaryName = process.platform === "win32" ? "sg.exe" : "sg"
const cachedPath = getCachedBinaryPath()
if (cachedPath && isValidBinary(cachedPath)) {
return cachedPath
}
try {
const require = createRequire(import.meta.url)
const cliPackageJsonPath = require.resolve("@ast-grep/cli/package.json")
const cliDirectory = dirname(cliPackageJsonPath)
const sgPath = join(cliDirectory, binaryName)
if (existsSync(sgPath) && isValidBinary(sgPath)) {
return sgPath
}
} catch {
// @ast-grep/cli not installed
}
const platformPackage = getPlatformPackageName()
if (platformPackage) {
try {
const require = createRequire(import.meta.url)
const packageJsonPath = require.resolve(`${platformPackage}/package.json`)
const packageDirectory = dirname(packageJsonPath)
const astGrepBinaryName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"
const binaryPath = join(packageDirectory, astGrepBinaryName)
if (existsSync(binaryPath) && isValidBinary(binaryPath)) {
return binaryPath
}
} catch {
// Platform-specific package not installed
}
}
if (process.platform === "darwin") {
const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"]
for (const path of homebrewPaths) {
if (existsSync(path) && isValidBinary(path)) {
return path
}
}
}
return null
}
let resolvedCliPath: string | null = null
export function getSgCliPath(): string | null {
if (resolvedCliPath !== null) {
return resolvedCliPath
}
const syncPath = findSgCliPathSync()
if (syncPath) {
resolvedCliPath = syncPath
return syncPath
}
return null
}
export function setSgCliPath(path: string): void {
resolvedCliPath = path
}
@@ -1,54 +0,0 @@
import { DEFAULT_MAX_MATCHES, DEFAULT_MAX_OUTPUT_BYTES } from "./constants"
import type { CliMatch, SgResult } from "./types"
export function createSgResultFromStdout(stdout: string): SgResult {
if (!stdout.trim()) {
return { matches: [], totalMatches: 0, truncated: false }
}
const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
const outputToProcess = outputTruncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
let matches: CliMatch[] = []
try {
matches = JSON.parse(outputToProcess) as CliMatch[]
} catch {
if (outputTruncated) {
try {
const lastValidIndex = outputToProcess.lastIndexOf("}")
if (lastValidIndex > 0) {
const bracketIndex = outputToProcess.lastIndexOf("},", lastValidIndex)
if (bracketIndex > 0) {
const truncatedJson = outputToProcess.substring(0, bracketIndex + 1) + "]"
matches = JSON.parse(truncatedJson) as CliMatch[]
}
}
} catch {
return {
matches: [],
totalMatches: 0,
truncated: true,
truncatedReason: "max_output_bytes",
error: "Output too large and could not be parsed",
}
}
} else {
return { matches: [], totalMatches: 0, truncated: false }
}
}
const totalMatches = matches.length
const matchesTruncated = totalMatches > DEFAULT_MAX_MATCHES
const finalMatches = matchesTruncated ? matches.slice(0, DEFAULT_MAX_MATCHES) : matches
return {
matches: finalMatches,
totalMatches,
truncated: outputTruncated || matchesTruncated,
truncatedReason: outputTruncated
? "max_output_bytes"
: matchesTruncated
? "max_matches"
: undefined,
}
}
@@ -1,171 +0,0 @@
/// <reference types="bun-types" />
import { describe, expect, it } from "bun:test"
import {
AST_GREP_REPLACE_DESCRIPTION,
AST_GREP_SEARCH_DESCRIPTION,
AST_GREP_SEARCH_PATTERN_PARAM,
} from "./tool-descriptions"
describe("AST_GREP_SEARCH_DESCRIPTION", () => {
it("#given the description #when inspecting #then asserts it is NOT regex", () => {
// given / when
const description = AST_GREP_SEARCH_DESCRIPTION
// then
expect(description).toContain("NOT regex")
})
it("#given the description #when inspecting #then explains meta-variables $VAR and $$$", () => {
// given / when
const description = AST_GREP_SEARCH_DESCRIPTION
// then
expect(description).toContain("$VAR")
expect(description).toContain("$$$")
})
it("#given the description #when inspecting #then warns against regex alternation", () => {
// given / when
const description = AST_GREP_SEARCH_DESCRIPTION
// then
expect(description).toContain("alternation")
expect(description).toContain("|")
})
it("#given the description #when inspecting #then warns against regex wildcards", () => {
// given / when
const description = AST_GREP_SEARCH_DESCRIPTION
// then
expect(description).toContain(".*")
expect(description).toContain("wildcards")
})
it("#given the description #when inspecting #then warns against regex escapes", () => {
// given / when
const description = AST_GREP_SEARCH_DESCRIPTION
// then
expect(description).toContain("\\w")
})
it("#given the description #when inspecting #then warns against character classes", () => {
// given / when
const description = AST_GREP_SEARCH_DESCRIPTION
// then
expect(description).toContain("[a-z]")
})
it("#given the description #when inspecting #then tells LLM to use grep as fallback", () => {
// given / when
const description = AST_GREP_SEARCH_DESCRIPTION
// then
expect(description.toLowerCase()).toContain("grep")
})
it("#given the description #when showing Python example #then omits the trailing colon bug", () => {
// given / when
const description = AST_GREP_SEARCH_DESCRIPTION
// then
expect(description).not.toContain("def $FUNC($$$):")
expect(description).toContain("def $FUNC($$$)")
})
it("#given the description #when inspecting #then shows TypeScript example", () => {
// given / when
const description = AST_GREP_SEARCH_DESCRIPTION
// then
expect(description).toContain("typescript")
expect(description).toContain("function $NAME($$$) { $$$ }")
})
it("#given the description #when inspecting #then shows Go example", () => {
// given / when
const description = AST_GREP_SEARCH_DESCRIPTION
// then
expect(description).toContain("go")
expect(description).toContain("func $NAME($$$) { $$$ }")
})
it("#given the description #when inspecting #then shows Rust example", () => {
// given / when
const description = AST_GREP_SEARCH_DESCRIPTION
// then
expect(description).toContain("rust")
expect(description).toContain("fn $NAME(")
})
it("#given the description #when measuring #then stays within a token-reasonable length", () => {
// given / when
const description = AST_GREP_SEARCH_DESCRIPTION
// then
expect(description.length).toBeLessThan(2000)
expect(description.length).toBeGreaterThan(400)
})
})
describe("AST_GREP_SEARCH_PATTERN_PARAM", () => {
it("#given the param description #when inspecting #then states meta-var rules", () => {
// given / when
const description = AST_GREP_SEARCH_PATTERN_PARAM
// then
expect(description).toContain("$VAR")
expect(description).toContain("$$$")
})
it("#given the param description #when inspecting #then forbids regex syntax", () => {
// given / when
const description = AST_GREP_SEARCH_PATTERN_PARAM
// then
expect(description).toContain("NOT regex")
expect(description).toContain("|")
expect(description).toContain(".*")
})
it("#given the param description #when inspecting #then directs to grep for fallback", () => {
// given / when
const description = AST_GREP_SEARCH_PATTERN_PARAM
// then
expect(description.toLowerCase()).toContain("grep")
})
})
describe("AST_GREP_REPLACE_DESCRIPTION", () => {
it("#given the description #when inspecting #then mentions AST meta-variables", () => {
// given / when
const description = AST_GREP_REPLACE_DESCRIPTION
// then
expect(description).toContain("$VAR")
expect(description).toContain("$$$")
})
it("#given the description #when inspecting #then warns against regex", () => {
// given / when
const description = AST_GREP_REPLACE_DESCRIPTION
// then
expect(description.toLowerCase()).toContain("regex does not work")
})
it("#given the description #when inspecting #then provides an example", () => {
// given / when
const description = AST_GREP_REPLACE_DESCRIPTION
// then
expect(description).toContain("console.log($MSG)")
expect(description).toContain("logger.info($MSG)")
})
})
-35
View File
@@ -1,35 +0,0 @@
export const AST_GREP_SEARCH_DESCRIPTION = [
"Search code by AST structure (25 languages). This is NOT regex.",
"",
"Meta-variables (the only wildcards ast-grep understands):",
" $VAR - one AST node (an identifier, expression, statement, ...)",
" $$$ - zero or more nodes (argument lists, function bodies, ...)",
" $$$VAR - same, captured by name",
"Patterns must be complete, parseable source code. Each meta-variable replaces a whole node, not a substring.",
"",
"Regex syntax does NOT work - never pass these to pattern:",
' "foo|bar" alternation → run separate calls, or switch to grep',
' ".*", ".+" wildcards → use $$$ between AST fragments',
' "\\w", "\\d" escapes → use $VAR to capture any identifier',
' "[a-z]" class ranges → no AST equivalent',
"For text search, cross-language search, or regex features, use the grep tool instead.",
"",
"Examples by language:",
' typescript/tsx "function $NAME($$$) { $$$ }", "console.log($$$)", "import { $$$ } from \'$MOD\'"',
' python "def $FUNC($$$)", "class $C($$$)" - no trailing colon',
' go "func $NAME($$$) { $$$ }", "if err != nil { $$$ }"',
' rust "fn $NAME($$$) -> $RET { $$$ }", "impl $TRAIT for $T { $$$ }"',
"",
"On empty results the tool returns a hint naming the exact mistake. If the pattern is fundamentally text-shaped, stop retrying and switch to grep.",
].join("\n")
export const AST_GREP_SEARCH_PATTERN_PARAM =
"AST pattern - valid, parseable code using $VAR (one node) and $$$ (many nodes). NOT regex: no `|`, no `.*`, no `\\w`, no `[a-z]`. For text or alternation, use grep instead."
export const AST_GREP_REPLACE_DESCRIPTION = [
"Rewrite code by AST pattern (25 languages). Dry-run by default.",
"Both pattern and rewrite use AST syntax ($VAR for one node, $$$ for many) - regex does NOT work.",
"Meta-variables captured in pattern can be reused in rewrite to preserve matched content.",
'Example: pattern="console.log($MSG)" rewrite="logger.info($MSG)"',
"For text-only replacement or regex features, use a text editor instead.",
].join("\n")
-55
View File
@@ -1,55 +0,0 @@
/// <reference types="bun-types" />
import { beforeEach, describe, expect, it, mock } from "bun:test"
import { AST_GREP_REPLACE_DESCRIPTION, AST_GREP_SEARCH_DESCRIPTION } from "./tool-descriptions"
const runSgMock = mock(async () => ({
matches: [],
totalMatches: 0,
truncated: false,
}))
mock.module("./cli", () => ({
runSg: runSgMock,
}))
import { createAstGrepTools } from "./tools"
describe("createAstGrepTools", () => {
beforeEach(() => {
runSgMock.mockClear()
})
it("#given the production tool factory #when creating tools #then exposes shared ast-grep descriptions", () => {
// given / when
const tools = createAstGrepTools({ directory: "/repo" } as never)
// then
expect(tools.ast_grep_search.description).toBe(AST_GREP_SEARCH_DESCRIPTION)
expect(tools.ast_grep_replace.description).toBe(AST_GREP_REPLACE_DESCRIPTION)
expect(tools.ast_grep_search.description).toContain("NOT regex")
})
it("#given empty search results from a regex-shaped pattern #when executing #then appends the pattern hint", async () => {
// given
const tools = createAstGrepTools({ directory: "/repo" } as never)
// when
const output = await tools.ast_grep_search.execute(
{ pattern: "foo|bar", lang: "typescript" },
{},
)
// then
expect(output).toContain("No matches found")
expect(output).toContain("alternation")
expect(output).toContain("grep")
expect(runSgMock).toHaveBeenCalledWith({
pattern: "foo|bar",
lang: "typescript",
paths: ["/repo"],
globs: undefined,
context: undefined,
})
})
})
-92
View File
@@ -1,92 +0,0 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { CLI_LANGUAGES } from "./constants"
import { runSg } from "./cli"
import { formatSearchResult, formatReplaceResult } from "./result-formatter"
import { getPatternHint } from "./pattern-hints"
import {
AST_GREP_REPLACE_DESCRIPTION,
AST_GREP_SEARCH_DESCRIPTION,
AST_GREP_SEARCH_PATTERN_PARAM,
} from "./tool-descriptions"
import type { CliLanguage } from "./types"
async function showOutputToUser(context: unknown, output: string): Promise<void> {
const ctx = context as {
metadata?: (input: { metadata: { output: string } }) => void | Promise<void>
}
await ctx.metadata?.({ metadata: { output } })
}
export function createAstGrepTools(ctx: PluginInput): Record<string, ToolDefinition> {
const ast_grep_search: ToolDefinition = tool({
description: AST_GREP_SEARCH_DESCRIPTION,
args: {
pattern: tool.schema.string().describe(AST_GREP_SEARCH_PATTERN_PARAM),
lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search (default: ['.'])"),
globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs (prefix ! to exclude)"),
context: tool.schema.number().optional().describe("Context lines around match"),
},
execute: async (args, context) => {
try {
const result = await runSg({
pattern: args.pattern,
lang: args.lang as CliLanguage,
paths: args.paths ?? [ctx.directory],
globs: args.globs,
context: args.context,
})
let output = formatSearchResult(result)
if (result.matches.length === 0 && !result.error) {
const hint = getPatternHint(args.pattern, args.lang as CliLanguage)
if (hint) {
output += `\n\n${hint}`
}
}
await showOutputToUser(context, output)
return output
} catch (e) {
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
await showOutputToUser(context, output)
return output
}
},
})
const ast_grep_replace: ToolDefinition = tool({
description: AST_GREP_REPLACE_DESCRIPTION,
args: {
pattern: tool.schema.string().describe("AST pattern to match"),
rewrite: tool.schema.string().describe("Replacement pattern (can use $VAR from pattern)"),
lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search"),
globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs"),
dryRun: tool.schema.boolean().optional().describe("Preview changes without applying (default: true)"),
},
execute: async (args, context) => {
try {
const result = await runSg({
pattern: args.pattern,
rewrite: args.rewrite,
lang: args.lang as CliLanguage,
paths: args.paths ?? [ctx.directory],
globs: args.globs,
updateAll: args.dryRun === false,
})
const output = formatReplaceResult(result, args.dryRun !== false)
await showOutputToUser(context, output)
return output
} catch (e) {
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
await showOutputToUser(context, output)
return output
}
},
})
return { ast_grep_search, ast_grep_replace }
}
-61
View File
@@ -1,61 +0,0 @@
import type { CLI_LANGUAGES, NAPI_LANGUAGES } from "./constants"
export type CliLanguage = (typeof CLI_LANGUAGES)[number]
export type NapiLanguage = (typeof NAPI_LANGUAGES)[number]
export interface Position {
line: number
column: number
}
export interface Range {
start: Position
end: Position
}
export interface CliMatch {
text: string
range: {
byteOffset: { start: number; end: number }
start: Position
end: Position
}
file: string
lines: string
charCount: { leading: number; trailing: number }
language: string
}
export interface SearchMatch {
file: string
text: string
range: Range
lines: string
}
export interface MetaVariable {
name: string
text: string
kind: string
}
export interface AnalyzeResult {
text: string
range: Range
kind: string
metaVariables: MetaVariable[]
}
export interface TransformResult {
original: string
transformed: string
editCount: number
}
export interface SgResult {
matches: CliMatch[]
totalMatches: number
truncated: boolean
truncatedReason?: "max_matches" | "max_output_bytes" | "timeout"
error?: string
}
-1
View File
@@ -1,4 +1,3 @@
export { createAstGrepTools } from "./ast-grep"
export { createGrepTools } from "./grep"
export { createGlobTools } from "./glob"
export { createSkillTool } from "./skill"