feat(mcp): add package-backed ast-grep MCP
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@oh-my-opencode/ast-grep-mcp",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"bin": {
|
||||
"ast-grep-mcp": "dist/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun build src/cli.ts --outdir dist --target node --format esm",
|
||||
"typecheck": "tsgo --noEmit -p tsconfig.json",
|
||||
"test": "bun test src/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ast-grep/cli": "^0.41.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "1.3.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from "node:child_process";
|
||||
import { Writable } from "node:stream";
|
||||
|
||||
type StdioMode = "pipe" | "inherit" | "ignore";
|
||||
type StdioTuple = [StdioMode, StdioMode, StdioMode];
|
||||
|
||||
export interface SpawnOptions {
|
||||
readonly cmd?: readonly string[];
|
||||
readonly cwd?: string;
|
||||
readonly env?: NodeJS.ProcessEnv;
|
||||
readonly stdin?: StdioMode;
|
||||
readonly stdout?: StdioMode;
|
||||
readonly stderr?: StdioMode;
|
||||
readonly stdio?: StdioTuple;
|
||||
readonly detached?: boolean;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface SpawnedProcess {
|
||||
readonly exitCode: number | null;
|
||||
readonly exited: Promise<number>;
|
||||
readonly stdout: ReadableStream<Uint8Array<ArrayBuffer>>;
|
||||
readonly stderr: ReadableStream<Uint8Array<ArrayBuffer>>;
|
||||
readonly stdin: NodeJS.WritableStream;
|
||||
readonly pid: number | undefined;
|
||||
kill(signal?: NodeJS.Signals): void;
|
||||
ref(): void;
|
||||
unref(): void;
|
||||
}
|
||||
|
||||
export interface SpawnSyncResult {
|
||||
readonly exitCode: number;
|
||||
readonly stdout: Buffer | undefined;
|
||||
readonly stderr: Buffer | undefined;
|
||||
readonly success: boolean;
|
||||
readonly pid: number;
|
||||
}
|
||||
|
||||
type BunSpawnRuntime = {
|
||||
spawn(command: readonly string[], options?: SpawnOptions): BunSpawnedProcess;
|
||||
spawn(options: SpawnOptions & { readonly cmd: readonly string[] }): BunSpawnedProcess;
|
||||
spawnSync(command: readonly string[], options?: SpawnOptions): SpawnSyncResult;
|
||||
spawnSync(options: SpawnOptions & { readonly cmd: readonly string[] }): SpawnSyncResult;
|
||||
};
|
||||
|
||||
type BunSpawnedProcess = Omit<SpawnedProcess, "stdout" | "stderr"> & {
|
||||
readonly stdout?: ReadableStream<Uint8Array<ArrayBuffer>>;
|
||||
readonly stderr?: ReadableStream<Uint8Array<ArrayBuffer>>;
|
||||
};
|
||||
|
||||
const runtime = globalThis as typeof globalThis & { readonly Bun?: BunSpawnRuntime };
|
||||
const IS_BUN = typeof runtime.Bun !== "undefined";
|
||||
|
||||
function emptyReadableStream(): ReadableStream<Uint8Array<ArrayBuffer>> {
|
||||
return new ReadableStream<Uint8Array<ArrayBuffer>>({
|
||||
start(controller) {
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toReadableStream(stream: NodeJS.ReadableStream | null): ReadableStream<Uint8Array<ArrayBuffer>> {
|
||||
if (!stream) return emptyReadableStream();
|
||||
return new ReadableStream<Uint8Array<ArrayBuffer>>({
|
||||
async start(controller) {
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
controller.enqueue(toUint8Array(chunk));
|
||||
}
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toUint8Array(chunk: unknown): Uint8Array<ArrayBuffer> {
|
||||
if (chunk instanceof Uint8Array) return new Uint8Array(chunk);
|
||||
return new TextEncoder().encode(String(chunk));
|
||||
}
|
||||
|
||||
function emptyWritableStream(): Writable {
|
||||
return new Writable({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isOptionsWithCommand(value: unknown): value is SpawnOptions & { readonly cmd: readonly string[] } {
|
||||
return typeof value === "object" && value !== null && "cmd" in value && Array.isArray(value.cmd);
|
||||
}
|
||||
|
||||
function resolveCommand(cmdOrOpts: readonly string[] | (SpawnOptions & { readonly cmd: readonly string[] }), optsArg?: SpawnOptions): { readonly cmd: readonly string[]; readonly opts: SpawnOptions } {
|
||||
if (isOptionsWithCommand(cmdOrOpts)) return { cmd: cmdOrOpts.cmd, opts: cmdOrOpts };
|
||||
return { cmd: cmdOrOpts, opts: optsArg ?? {} };
|
||||
}
|
||||
|
||||
function resolveStdio(options: SpawnOptions): StdioTuple {
|
||||
if (options.stdio) return options.stdio;
|
||||
return [options.stdin ?? "ignore", options.stdout ?? "pipe", options.stderr ?? "inherit"];
|
||||
}
|
||||
|
||||
function wrapNodeProcess(proc: ReturnType<typeof nodeSpawn>): SpawnedProcess {
|
||||
let exitCode: number | null = null;
|
||||
const exited = new Promise<number>((resolve, reject) => {
|
||||
proc.on("exit", (code) => {
|
||||
exitCode = code ?? 1;
|
||||
resolve(exitCode);
|
||||
});
|
||||
proc.on("error", (error) => {
|
||||
if (exitCode === null) {
|
||||
exitCode = 1;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
get exitCode() {
|
||||
return exitCode;
|
||||
},
|
||||
exited,
|
||||
stdout: toReadableStream(proc.stdout),
|
||||
stderr: toReadableStream(proc.stderr),
|
||||
stdin: proc.stdin ?? emptyWritableStream(),
|
||||
pid: proc.pid,
|
||||
kill(signal?: NodeJS.Signals) {
|
||||
if (proc.killed || exitCode !== null) return;
|
||||
proc.kill(signal);
|
||||
},
|
||||
ref() {
|
||||
proc.ref();
|
||||
},
|
||||
unref() {
|
||||
proc.unref();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function wrapBunProcess(proc: BunSpawnedProcess): SpawnedProcess {
|
||||
return {
|
||||
...proc,
|
||||
stdout: proc.stdout ?? emptyReadableStream(),
|
||||
stderr: proc.stderr ?? emptyReadableStream(),
|
||||
};
|
||||
}
|
||||
|
||||
export function spawn(command: readonly string[], options?: SpawnOptions): SpawnedProcess;
|
||||
export function spawn(options: SpawnOptions & { readonly cmd: readonly string[] }): SpawnedProcess;
|
||||
export function spawn(cmdOrOpts: readonly string[] | (SpawnOptions & { readonly cmd: readonly string[] }), opts?: SpawnOptions): SpawnedProcess {
|
||||
const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts);
|
||||
if (IS_BUN) return wrapBunProcess(runtime.Bun.spawn(cmd, options));
|
||||
const [bin, ...args] = cmd;
|
||||
if (!bin) throw new Error("spawn requires a command");
|
||||
return wrapNodeProcess(nodeSpawn(bin, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: resolveStdio(options),
|
||||
detached: options.detached,
|
||||
signal: options.signal,
|
||||
}));
|
||||
}
|
||||
|
||||
export function spawnSync(command: readonly string[], options?: SpawnOptions): SpawnSyncResult;
|
||||
export function spawnSync(options: SpawnOptions & { readonly cmd: readonly string[] }): SpawnSyncResult;
|
||||
export function spawnSync(cmdOrOpts: readonly string[] | (SpawnOptions & { readonly cmd: readonly string[] }), opts?: SpawnOptions): SpawnSyncResult {
|
||||
const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts);
|
||||
if (IS_BUN) return runtime.Bun.spawnSync(cmd, options);
|
||||
const [bin, ...args] = cmd;
|
||||
if (!bin) throw new Error("spawnSync requires a command");
|
||||
const result = nodeSpawnSync(bin, args, { cwd: options.cwd, env: options.env, stdio: resolveStdio(options) });
|
||||
return {
|
||||
exitCode: result.status ?? 1,
|
||||
stdout: result.stdout ?? undefined,
|
||||
stderr: result.stderr ?? undefined,
|
||||
success: (result.status ?? 1) === 0,
|
||||
pid: result.pid ?? -1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { existsSync } from "fs"
|
||||
|
||||
import { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./constants"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
import { argv, stderr } from "node:process";
|
||||
import { runMcpStdioServer } from "./mcp";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [command = "mcp"] = argv.slice(2);
|
||||
if (command === "mcp") {
|
||||
await runMcpStdioServer();
|
||||
return;
|
||||
}
|
||||
stderr.write("Usage: ast-grep-mcp [mcp]\n");
|
||||
process.exitCode = 2;
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CLI_LANGUAGES, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_MAX_MATCHES } from "./language-support"
|
||||
export { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./sg-cli-path"
|
||||
@@ -0,0 +1,4 @@
|
||||
export { handleAstGrepMcpRequest, runMcpStdioServer } from "./mcp";
|
||||
export type { AstGrepMcpOptions, JsonRpcId, JsonRpcResponse, JsonRpcResult, McpToolDescriptor, TextContent } from "./mcp";
|
||||
export { runSg } from "./runner";
|
||||
export type { RunOptions } from "./runner";
|
||||
@@ -0,0 +1,31 @@
|
||||
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
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 300_000
|
||||
export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024
|
||||
export const DEFAULT_MAX_MATCHES = 500
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { handleAstGrepMcpRequest } from "./mcp";
|
||||
import type { RunOptions } from "./runner";
|
||||
import type { SgResult } from "./types";
|
||||
|
||||
const emptyResult: SgResult = {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
function createTemporaryDirectory(prefix: string): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), prefix));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("ast-grep MCP", () => {
|
||||
it("#given initialize request #when handled #then advertises tools capability", async () => {
|
||||
const response = await handleAstGrepMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: { protocolVersion: "2024-11-05" },
|
||||
});
|
||||
|
||||
expect(response).toEqual({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: {
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: "ast_grep", version: "0.1.0" },
|
||||
protocolVersion: "2024-11-05",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("#given tools list request #when handled #then exposes search and replace tools", async () => {
|
||||
const response = await handleAstGrepMcpRequest({ jsonrpc: "2.0", id: "tools", method: "tools/list" });
|
||||
|
||||
expect(response?.result?.tools?.map((tool) => tool.name)).toEqual(["search", "replace"]);
|
||||
});
|
||||
|
||||
it("#given search call without paths #when handled #then defaults paths to workspace directory", async () => {
|
||||
const captured: { value?: RunOptions } = {};
|
||||
const workspaceDirectory = createTemporaryDirectory("omo-ast-grep-workspace-");
|
||||
const response = await handleAstGrepMcpRequest(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: "search",
|
||||
method: "tools/call",
|
||||
params: { name: "search", arguments: { pattern: "console.log($$$)", lang: "typescript" } },
|
||||
},
|
||||
{
|
||||
workspaceDirectory,
|
||||
runSg: async (options) => {
|
||||
captured.value = options;
|
||||
return emptyResult;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(captured.value).toEqual({ pattern: "console.log($$$)", lang: "typescript", cwd: realpathSync(workspaceDirectory), paths: ["."], globs: undefined, context: undefined });
|
||||
expect(response?.result?.content?.[0]?.text).toContain("No matches found");
|
||||
});
|
||||
|
||||
it("#given replace call without dryRun #when handled #then keeps dry-run default", async () => {
|
||||
const captured: { value?: RunOptions } = {};
|
||||
const workspaceDirectory = createTemporaryDirectory("omo-ast-grep-replace-workspace-");
|
||||
mkdirSync(join(workspaceDirectory, "src"));
|
||||
await handleAstGrepMcpRequest(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: "replace",
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "replace",
|
||||
arguments: { pattern: "console.log($MSG)", rewrite: "logger.info($MSG)", lang: "typescript", paths: ["src"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
workspaceDirectory,
|
||||
runSg: async (options) => {
|
||||
captured.value = options;
|
||||
return emptyResult;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(captured.value).toEqual({
|
||||
pattern: "console.log($MSG)",
|
||||
rewrite: "logger.info($MSG)",
|
||||
lang: "typescript",
|
||||
cwd: realpathSync(workspaceDirectory),
|
||||
paths: ["src"],
|
||||
globs: undefined,
|
||||
updateAll: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("#given disabled replace tool #when listed and called #then hides and rejects it", async () => {
|
||||
const listResponse = await handleAstGrepMcpRequest({ jsonrpc: "2.0", id: "tools", method: "tools/list" }, { disabledTools: ["replace"] });
|
||||
|
||||
expect(listResponse?.result?.tools?.map((tool) => tool.name)).toEqual(["search"]);
|
||||
|
||||
const callResponse = await handleAstGrepMcpRequest(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: "replace",
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "replace",
|
||||
arguments: { pattern: "console.log($MSG)", rewrite: "logger.info($MSG)", lang: "typescript", paths: ["src"] },
|
||||
},
|
||||
},
|
||||
{ disabledTools: ["replace"] },
|
||||
);
|
||||
|
||||
expect(callResponse?.result?.isError).toBe(true);
|
||||
expect(callResponse?.result?.content?.[0]?.text).toContain("ast-grep tool is disabled: replace");
|
||||
});
|
||||
|
||||
it("#given unsafe paths #when search is called #then rejects before running ast-grep", async () => {
|
||||
const workspaceDirectory = createTemporaryDirectory("omo-ast-grep-sandbox-");
|
||||
const outsideDirectory = createTemporaryDirectory("omo-ast-grep-outside-");
|
||||
symlinkSync(outsideDirectory, join(workspaceDirectory, "outside-link"));
|
||||
let didRun = false;
|
||||
|
||||
for (const path of ["../outside", "/tmp", "--update-all", "outside-link"]) {
|
||||
const response = await handleAstGrepMcpRequest(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: path,
|
||||
method: "tools/call",
|
||||
params: { name: "search", arguments: { pattern: "console.log($$$)", lang: "typescript", paths: [path] } },
|
||||
},
|
||||
{
|
||||
workspaceDirectory,
|
||||
runSg: async () => {
|
||||
didRun = true;
|
||||
return emptyResult;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(response?.result?.isError).toBe(true);
|
||||
}
|
||||
|
||||
expect(didRun).toBe(false);
|
||||
});
|
||||
|
||||
it("#given tools list request #when handled #then preserves detailed ast-grep guidance", async () => {
|
||||
const response = await handleAstGrepMcpRequest({ jsonrpc: "2.0", id: "tools", method: "tools/list" });
|
||||
const searchTool = response?.result?.tools?.find((tool) => tool.name === "search");
|
||||
|
||||
expect(searchTool?.description).toContain("This is NOT regex");
|
||||
expect(searchTool?.description).toContain("Meta-variables");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import { CLI_LANGUAGES } from "./constants";
|
||||
import { getPatternHint } from "./pattern-hints";
|
||||
import { formatReplaceResult, formatSearchResult } from "./result-formatter";
|
||||
import { runSg, type RunOptions } from "./runner";
|
||||
import { AST_GREP_REPLACE_DESCRIPTION, AST_GREP_SEARCH_DESCRIPTION, AST_GREP_SEARCH_PATTERN_PARAM } from "./tool-descriptions";
|
||||
import type { CliLanguage, SgResult } from "./types";
|
||||
import { normalizeWorkspaceDirectory, resolveWorkspacePaths } from "./workspace-paths";
|
||||
|
||||
export type JsonRpcId = string | number | null;
|
||||
|
||||
export interface TextContent {
|
||||
readonly type: "text";
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface McpToolDescriptor {
|
||||
readonly name: string;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly inputSchema: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcError {
|
||||
readonly code: number;
|
||||
readonly message: string;
|
||||
readonly data?: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcResult {
|
||||
readonly capabilities?: Record<string, unknown>;
|
||||
readonly serverInfo?: Record<string, unknown>;
|
||||
readonly protocolVersion?: string;
|
||||
readonly tools?: readonly McpToolDescriptor[];
|
||||
readonly content?: readonly TextContent[];
|
||||
readonly isError?: boolean;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
readonly jsonrpc: "2.0";
|
||||
readonly id: JsonRpcId;
|
||||
readonly result?: JsonRpcResult;
|
||||
readonly error?: JsonRpcError;
|
||||
}
|
||||
|
||||
export interface AstGrepMcpOptions {
|
||||
readonly workspaceDirectory?: string;
|
||||
readonly disabledTools?: readonly string[];
|
||||
readonly runSg?: (options: RunOptions) => Promise<SgResult>;
|
||||
}
|
||||
|
||||
type ToolCallResult = {
|
||||
readonly content: readonly TextContent[];
|
||||
readonly isError?: boolean;
|
||||
};
|
||||
|
||||
const SERVER_NAME = "ast_grep";
|
||||
const SERVER_VERSION = "0.1.0";
|
||||
const LANGUAGE_VALUES: readonly string[] = CLI_LANGUAGES;
|
||||
const DISABLED_TOOLS_ENV = "OMO_AST_GREP_DISABLED_TOOLS";
|
||||
|
||||
const AST_GREP_MCP_TOOLS = [
|
||||
{
|
||||
name: "search",
|
||||
title: "AST grep search",
|
||||
description: AST_GREP_SEARCH_DESCRIPTION,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pattern: { type: "string", description: AST_GREP_SEARCH_PATTERN_PARAM },
|
||||
lang: { type: "string", enum: CLI_LANGUAGES, description: "Target language" },
|
||||
paths: { type: "array", items: { type: "string" }, description: "Paths to search" },
|
||||
globs: { type: "array", items: { type: "string" }, description: "Include/exclude globs" },
|
||||
context: { type: "number", description: "Context lines around each match" },
|
||||
},
|
||||
required: ["pattern", "lang"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "replace",
|
||||
title: "AST grep replace",
|
||||
description: AST_GREP_REPLACE_DESCRIPTION,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pattern: { type: "string", description: "AST pattern to match" },
|
||||
rewrite: { type: "string", description: "Replacement pattern" },
|
||||
lang: { type: "string", enum: CLI_LANGUAGES, description: "Target language" },
|
||||
paths: { type: "array", items: { type: "string" }, description: "Paths to search" },
|
||||
globs: { type: "array", items: { type: "string" }, description: "Include/exclude globs" },
|
||||
dryRun: { type: "boolean", description: "Preview changes without applying. Defaults to true." },
|
||||
},
|
||||
required: ["pattern", "rewrite", "lang"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly McpToolDescriptor[];
|
||||
|
||||
export async function handleAstGrepMcpRequest(input: unknown, options: AstGrepMcpOptions = {}): Promise<JsonRpcResponse | undefined> {
|
||||
if (!isRecord(input)) return errorResponse(null, -32600, "Invalid Request");
|
||||
const id = jsonRpcId(input.id);
|
||||
if (input.method === "notifications/initialized") return undefined;
|
||||
if (input.method === "ping") return successResponse(id, {});
|
||||
if (input.method === "initialize") {
|
||||
return successResponse(id, {
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
||||
protocolVersion: requestedProtocolVersion(input.params),
|
||||
});
|
||||
}
|
||||
if (input.method === "tools/list") return successResponse(id, { tools: enabledTools(options) });
|
||||
if (input.method === "tools/call") return handleToolCall(id, input.params, options);
|
||||
return errorResponse(id, -32601, `Method not found: ${String(input.method)}`);
|
||||
}
|
||||
|
||||
export async function runMcpStdioServer(
|
||||
input: NodeJS.ReadableStream = process.stdin,
|
||||
output: NodeJS.WritableStream = process.stdout,
|
||||
options: AstGrepMcpOptions = {},
|
||||
): Promise<void> {
|
||||
const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
|
||||
for await (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (error) {
|
||||
output.write(`${JSON.stringify(errorResponse(null, -32700, "Parse error", messageFromError(error)))}\n`);
|
||||
continue;
|
||||
}
|
||||
const response = await handleAstGrepMcpRequest(parsed, options);
|
||||
if (response) output.write(`${JSON.stringify(response)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToolCall(id: JsonRpcId, params: unknown, options: AstGrepMcpOptions): Promise<JsonRpcResponse> {
|
||||
if (!isRecord(params) || typeof params.name !== "string") return errorResponse(id, -32602, "tools/call requires params.name");
|
||||
try {
|
||||
const result = await executeAstGrepTool(params.name, params.arguments, options);
|
||||
return successResponse(id, { content: result.content, isError: result.isError ?? false });
|
||||
} catch (error) {
|
||||
return successResponse(id, { content: [{ type: "text", text: messageFromError(error) }], isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function executeAstGrepTool(name: string, args: unknown, options: AstGrepMcpOptions): Promise<ToolCallResult> {
|
||||
if (disabledToolNames(options).has(name)) throw new Error(`ast-grep tool is disabled: ${name}`);
|
||||
const runner = options.runSg ?? runSg;
|
||||
const workspaceDirectory = normalizeWorkspaceDirectory(options.workspaceDirectory ?? process.env.OMO_AST_GREP_WORKSPACE ?? process.cwd());
|
||||
if (name === "search") {
|
||||
const input = parseSearchArgs(args, workspaceDirectory);
|
||||
const result = await runner(input);
|
||||
let output = formatSearchResult(result);
|
||||
if (result.matches.length === 0 && !result.error) {
|
||||
const hint = getPatternHint(input.pattern, input.lang);
|
||||
if (hint) output += `\n\n${hint}`;
|
||||
}
|
||||
return { content: [{ type: "text", text: output }], isError: Boolean(result.error) };
|
||||
}
|
||||
if (name === "replace") {
|
||||
const input = parseReplaceArgs(args, workspaceDirectory);
|
||||
const result = await runner(input.options);
|
||||
return { content: [{ type: "text", text: formatReplaceResult(result, input.dryRun) }], isError: Boolean(result.error) };
|
||||
}
|
||||
throw new Error(`Unknown ast-grep tool: ${name}`);
|
||||
}
|
||||
|
||||
function parseSearchArgs(args: unknown, workspaceDirectory: string): RunOptions {
|
||||
const input = requireRecord(args);
|
||||
return {
|
||||
pattern: requireString(input, "pattern"),
|
||||
lang: requireLanguage(input, "lang"),
|
||||
cwd: workspaceDirectory,
|
||||
paths: resolveWorkspacePaths(optionalStringArray(input, "paths"), workspaceDirectory),
|
||||
globs: optionalStringArray(input, "globs"),
|
||||
context: optionalNumber(input, "context"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReplaceArgs(args: unknown, workspaceDirectory: string): { readonly options: RunOptions; readonly dryRun: boolean } {
|
||||
const input = requireRecord(args);
|
||||
const dryRun = optionalBoolean(input, "dryRun") ?? true;
|
||||
return {
|
||||
dryRun,
|
||||
options: {
|
||||
pattern: requireString(input, "pattern"),
|
||||
rewrite: requireString(input, "rewrite"),
|
||||
lang: requireLanguage(input, "lang"),
|
||||
cwd: workspaceDirectory,
|
||||
paths: resolveWorkspacePaths(optionalStringArray(input, "paths"), workspaceDirectory),
|
||||
globs: optionalStringArray(input, "globs"),
|
||||
updateAll: !dryRun,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new Error("Tool arguments must be an object");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireString(input: Record<string, unknown>, key: string): string {
|
||||
const value = input[key];
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} must be a non-empty string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireLanguage(input: Record<string, unknown>, key: string): CliLanguage {
|
||||
const value = requireString(input, key);
|
||||
if (!isCliLanguage(value)) throw new Error(`${key} must be one of: ${LANGUAGE_VALUES.join(", ")}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function isCliLanguage(value: string): value is CliLanguage {
|
||||
return LANGUAGE_VALUES.includes(value);
|
||||
}
|
||||
|
||||
function optionalStringArray(input: Record<string, unknown>, key: string): string[] | undefined {
|
||||
const value = input[key];
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new Error(`${key} must be an array of strings`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function enabledTools(options: AstGrepMcpOptions): readonly McpToolDescriptor[] {
|
||||
const disabled = disabledToolNames(options);
|
||||
return AST_GREP_MCP_TOOLS.filter((tool) => !disabled.has(tool.name));
|
||||
}
|
||||
|
||||
function disabledToolNames(options: AstGrepMcpOptions): ReadonlySet<string> {
|
||||
const fromOptions = options.disabledTools ?? [];
|
||||
const fromEnv = process.env[DISABLED_TOOLS_ENV]?.split(",") ?? [];
|
||||
return new Set([...fromOptions, ...fromEnv].map((tool) => tool.trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
function optionalNumber(input: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = input[key];
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "number") throw new Error(`${key} must be a number`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBoolean(input: Record<string, unknown>, key: string): boolean | undefined {
|
||||
const value = input[key];
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "boolean") throw new Error(`${key} must be a boolean`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function successResponse(id: JsonRpcId, result: JsonRpcResult): JsonRpcResponse {
|
||||
return { jsonrpc: "2.0", id, result };
|
||||
}
|
||||
|
||||
function errorResponse(id: JsonRpcId, code: number, message: string, data?: unknown): JsonRpcResponse {
|
||||
return { jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } };
|
||||
}
|
||||
|
||||
function requestedProtocolVersion(params: unknown): string {
|
||||
if (!isRecord(params) || typeof params.protocolVersion !== "string") return "2024-11-05";
|
||||
return params.protocolVersion;
|
||||
}
|
||||
|
||||
function jsonRpcId(value: unknown): JsonRpcId {
|
||||
return typeof value === "string" || typeof value === "number" || value === null ? value : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function messageFromError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { 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")
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { spawn } from "./bun-spawn-shim"
|
||||
import { existsSync } from "fs"
|
||||
import {
|
||||
getSgCliPath,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
} from "./constants"
|
||||
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
|
||||
cwd?: string
|
||||
paths?: readonly string[]
|
||||
globs?: readonly 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 = createSgArgs(options, { includeJson: true, includeUpdateAll: false })
|
||||
|
||||
let cliPath = getSgCliPath()
|
||||
|
||||
if (!cliPath || !existsSync(cliPath)) {
|
||||
const resolvedPath = await getAstGrepPath()
|
||||
if (resolvedPath) {
|
||||
cliPath = resolvedPath
|
||||
} 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], {
|
||||
cwd: options.cwd,
|
||||
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 = errorCodeFrom(error)
|
||||
const isNoEntry =
|
||||
errorCode === "ENOENT" || errorMessage.includes("ENOENT") || errorMessage.includes("not found")
|
||||
|
||||
if (isNoEntry) {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
error:
|
||||
`ast-grep CLI 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`,
|
||||
}
|
||||
}
|
||||
|
||||
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 = createSgArgs(options, { includeJson: false, includeUpdateAll: true })
|
||||
|
||||
const writeProc = spawn([cliPath, ...writeArgs], {
|
||||
cwd: options.cwd,
|
||||
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
|
||||
}
|
||||
|
||||
function createSgArgs(options: RunOptions, flags: { readonly includeJson: boolean; readonly includeUpdateAll: boolean }): string[] {
|
||||
const args = ["run", "-p", options.pattern, "--lang", options.lang]
|
||||
|
||||
if (flags.includeJson) {
|
||||
args.push("--json=compact")
|
||||
}
|
||||
|
||||
if (options.rewrite) {
|
||||
args.push("-r", options.rewrite)
|
||||
if (flags.includeUpdateAll) {
|
||||
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)
|
||||
return args
|
||||
}
|
||||
|
||||
function errorCodeFrom(error: unknown): unknown {
|
||||
if (typeof error !== "object" || error === null || !("code" in error)) return undefined
|
||||
return Reflect.get(error, "code")
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createRequire } from "module"
|
||||
import { dirname, join } from "path"
|
||||
import { existsSync, statSync } from "fs"
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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")
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { CLI_LANGUAGES } from "./constants"
|
||||
|
||||
export type CliLanguage = (typeof CLI_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 SgResult {
|
||||
matches: CliMatch[]
|
||||
totalMatches: number
|
||||
truncated: boolean
|
||||
truncatedReason?: "max_matches" | "max_output_bytes" | "timeout"
|
||||
error?: string
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
export function normalizeWorkspaceDirectory(workspaceDirectory: string): string {
|
||||
return realpathSync(resolve(workspaceDirectory));
|
||||
}
|
||||
|
||||
export function resolveWorkspacePaths(rawPaths: readonly string[] | undefined, workspaceDirectory: string): readonly string[] {
|
||||
const workspace = normalizeWorkspaceDirectory(workspaceDirectory);
|
||||
const requestedPaths = rawPaths && rawPaths.length > 0 ? rawPaths : ["."];
|
||||
return requestedPaths.map((rawPath) => resolveWorkspacePath(rawPath, workspace));
|
||||
}
|
||||
|
||||
function resolveWorkspacePath(rawPath: string, workspaceDirectory: string): string {
|
||||
if (rawPath.length === 0) throw new Error("paths entries must be non-empty strings");
|
||||
if (rawPath.startsWith("-")) throw new Error(`paths entries must not start with '-': ${rawPath}`);
|
||||
if (rawPath.includes("\0")) throw new Error("paths entries must not contain null bytes");
|
||||
if (isAbsolute(rawPath)) throw new Error(`paths entries must be relative to the workspace: ${rawPath}`);
|
||||
|
||||
const absolutePath = resolve(workspaceDirectory, rawPath);
|
||||
assertInsideWorkspace(absolutePath, workspaceDirectory, rawPath);
|
||||
|
||||
if (existsSync(absolutePath)) {
|
||||
const realPath = realpathSync(absolutePath);
|
||||
assertInsideWorkspace(realPath, workspaceDirectory, rawPath);
|
||||
}
|
||||
|
||||
const normalizedPath = relative(workspaceDirectory, absolutePath);
|
||||
return normalizedPath === "" ? "." : normalizedPath;
|
||||
}
|
||||
|
||||
function assertInsideWorkspace(candidatePath: string, workspaceDirectory: string, rawPath: string): void {
|
||||
const workspaceRelativePath = relative(workspaceDirectory, candidatePath);
|
||||
if (workspaceRelativePath === "" || (!workspaceRelativePath.startsWith("..") && !isAbsolute(workspaceRelativePath))) return;
|
||||
throw new Error(`paths entries must stay inside the workspace: ${rawPath}`);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user