Merge pull request #4155 from code-yeongyu/feature/rules-astgrep-packages-20260518

feat: extract rules core and ast-grep MCP packages
This commit is contained in:
YeonGyu-Kim
2026-05-18 21:50:47 +09:00
committed by GitHub
86 changed files with 2132 additions and 1920 deletions
+3 -3
View File
@@ -55,7 +55,7 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
- name: Run tests
run: bun test
@@ -83,7 +83,7 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
- name: Type check
run: bun run typecheck
@@ -118,7 +118,7 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
- name: Build
run: bun run build
+2 -1
View File
@@ -54,7 +54,7 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
- name: Validate release inputs
id: validate
@@ -187,6 +187,7 @@ jobs:
retry_wait_seconds: 10
shell: bash
command: |
bun run build:ast-grep-mcp
PLATFORM="${{ matrix.platform }}"
PACKAGE_DIR="packages/oh-my-opencode-${PLATFORM}"
case "$PLATFORM" in
+5 -9
View File
@@ -43,7 +43,7 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
- name: Run tests
run: bun test
@@ -60,7 +60,7 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
- name: Type check
run: bun run typecheck
@@ -162,7 +162,7 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
- name: Calculate version
id: version
@@ -232,11 +232,7 @@ jobs:
- name: Build main package
if: steps.check.outputs.skip != 'true'
run: |
bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi
bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi
bunx tsc --emitDeclarationOnly
bun run build:schema
run: bun run build
- name: Strip token auth from .npmrc to force OIDC
if: steps.check.outputs.skip != 'true'
@@ -330,7 +326,7 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
- name: Generate changelog
run: |
@@ -23,7 +23,7 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
- name: Refresh bundled model capabilities snapshot
run: bun run build:model-capabilities
+4 -4
View File
@@ -19,12 +19,12 @@ oh-my-opencode/
│ ├── create-hooks.ts # 5-tier hook composition
│ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior)
│ ├── hooks/ # ~52 lifecycle hooks across 59 dirs (incl. 5 zauc-mocks + 1 shared + 1 `.sisyphus/` legacy state)
│ ├── tools/ # 15 native tool dirs; LSP tools now served via built-in MCP
│ ├── tools/ # 13 native tool dirs; LSP + AST-grep now served via built-in MCPs
│ ├── features/ # 20 feature modules (incl. team-mode, background-agent, skill-mcp-manager, opencode-skill-loader, tmux-subagent, mcp-oauth, claude-code-plugin-loader, boulder-state, etc.)
│ ├── shared/ # 278 utility files (170 non-test); logger → oh-my-opencode.log in os.tmpdir() (50 MB cap, .1/.2 backups)
│ ├── config/ # Zod v4 schema system (30 schema files)
│ ├── cli/ # CLI: install, run, doctor, mcp-oauth, refresh-model-capabilities, get-local-version, boulder
│ ├── mcp/ # 4 built-in MCPs (3 remote + local stdio lsp)
│ ├── mcp/ # 5 built-in MCPs (3 remote + local stdio lsp + ast_grep)
│ ├── plugin/ # 10 OpenCode hook handlers + 5-tier hook composition
│ ├── plugin-handlers/ # 6-phase config loading pipeline
│ ├── openclaw/ # Bidirectional external integration (Discord/Telegram/HTTP/shell + reply listener daemon)
@@ -87,7 +87,7 @@ pluginModule.server(input, options)
**Always on (20):** `lsp_goto_definition`, `lsp_find_references`, `lsp_symbols`, `lsp_diagnostics`, `lsp_prepare_rename`, `lsp_rename`, `grep`, `glob`, `ast_grep_search`, `ast_grep_replace`, `session_list`, `session_read`, `session_search`, `session_info`, `background_output`, `background_cancel`, `call_omo_agent`, `task` (delegate), `skill`, `skill_mcp`.
> Note: `lsp_*` tool names are now served by built-in MCP server `lsp` (via `packages/lsp-tools-mcp`), preserving existing names through OpenCode MCP namespacing.
> Note: `lsp_*` and `ast_grep_*` tool names are now served by built-in MCP servers (`lsp` via `packages/lsp-tools-mcp`, `ast_grep` via `packages/ast-grep-mcp`), preserving existing names through OpenCode MCP namespacing.
**Conditional:** `look_at` (+1, multimodal-looker not disabled), `interactive_bash` (+1, `tmux` binary available on PATH via `isInteractiveBashEnabled()`), `task_create`/`task_get`/`task_list`/`task_update` (+4, `experimental.task_system`), `edit` (+1, `hashline_edit`), `team_create`/`team_delete`/`team_shutdown_request`/`team_approve_shutdown`/`team_reject_shutdown`/`team_send_message`/`team_task_create`/`team_task_list`/`team_task_update`/`team_task_get`/`team_status`/`team_list` (+12, `team_mode.enabled`).
@@ -148,7 +148,7 @@ Schema autocomplete: `"$schema": "https://raw.githubusercontent.com/code-yeongyu
| Tier | Source | Loader | Mechanism |
|------|--------|--------|-----------|
| 1. Built-in | `src/mcp/` | `createBuiltinMcps()` | 3 remote HTTP + 1 local stdio MCP (`lsp`) |
| 1. Built-in | `src/mcp/` | `createBuiltinMcps()` | 3 remote HTTP + 2 local stdio MCPs (`lsp`, `ast_grep`) |
| 2. Claude Code | `.mcp.json` (project + user) | `claude-code-mcp-loader` | `${VAR}` env expansion (allowlist via `mcp_env_allowlist`) |
| 3. Skill-embedded | SKILL.md YAML frontmatter | `SkillMcpManager` (per-session) | stdio + HTTP, OAuth 2.0 + PKCE + DCR step-up |
+26
View File
@@ -24,6 +24,8 @@
"vscode-jsonrpc": "^8.2.1",
},
"devDependencies": {
"@oh-my-opencode/ast-grep-mcp": "workspace:*",
"@oh-my-opencode/rules-core": "workspace:*",
"@types/js-yaml": "^4.0.9",
"@types/picomatch": "^4.0.3",
"@typescript/native-preview": "7.0.0-dev.20260518.1",
@@ -48,6 +50,26 @@
"zod": "^4.0.0",
},
},
"packages/ast-grep-mcp": {
"name": "@oh-my-opencode/ast-grep-mcp",
"version": "0.0.0",
"bin": {
"ast-grep-mcp": "dist/cli.js",
},
"dependencies": {
"@ast-grep/cli": "^0.41.1",
},
"devDependencies": {
"bun-types": "1.3.12",
},
},
"packages/rules-core": {
"name": "@oh-my-opencode/rules-core",
"version": "0.1.0",
"dependencies": {
"picomatch": "^4.0.4",
},
},
},
"trustedDependencies": [
"@ast-grep/cli",
@@ -108,6 +130,10 @@
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@oh-my-opencode/ast-grep-mcp": ["@oh-my-opencode/ast-grep-mcp@workspace:packages/ast-grep-mcp"],
"@oh-my-opencode/rules-core": ["@oh-my-opencode/rules-core@workspace:packages/rules-core"],
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
+2 -2
View File
@@ -614,10 +614,10 @@ Force-enable session notifications:
### MCPs
Built-in MCPs (enabled by default): `websearch` (Exa AI), `context7` (library docs), `grep_app` (GitHub code search).
Built-in MCPs (enabled by default): `websearch` (Exa AI), `context7` (library docs), `grep_app` (GitHub code search), `lsp` (local language-server tools), and `ast_grep` (local structural search/rewrite tools).
```json
{ "disabled_mcps": ["websearch", "context7", "grep_app"] }
{ "disabled_mcps": ["websearch", "context7", "grep_app", "lsp", "ast_grep"] }
```
### LSP
+5 -1
View File
@@ -611,6 +611,8 @@ Hashline IDs use characters from `ZPMQVRWSNKTXJBYH`.
### AST-Grep Tools
These user-facing tool names are served by the built-in local `ast_grep` MCP backed by `packages/ast-grep-mcp/`.
| Tool | Description |
| -------------------- | -------------------------------------------- |
| **ast_grep_search** | AST-aware code pattern search (25 languages) |
@@ -905,7 +907,7 @@ Disable specific hooks in config:
The plugin uses a three-tier MCP architecture:
1. Built-in remote MCPs from `src/mcp/`
1. Built-in MCPs from `src/mcp/` (remote plus local stdio)
2. Claude Code `.mcp.json` loader with `${VAR}` expansion
3. Skill-embedded MCP servers declared in `SKILL.md` frontmatter
@@ -916,6 +918,8 @@ The plugin uses a three-tier MCP architecture:
| **websearch** | Real-time web search powered by Exa AI |
| **context7** | Official documentation lookup for any library/framework |
| **grep_app** | Ultra-fast code search across public GitHub repos. Great for finding implementation examples. |
| **lsp** | Local LSP tools for diagnostics, symbols, references, and renames |
| **ast_grep** | Local AST-aware search and rewrite tools |
### Skill-Embedded MCPs
+13 -4
View File
@@ -5,6 +5,10 @@
"main": "./dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"workspaces": [
"packages/rules-core",
"packages/ast-grep-mcp"
],
"bin": {
"oh-my-opencode": "bin/oh-my-opencode.js",
"oh-my-openagent": "bin/oh-my-opencode.js"
@@ -13,7 +17,8 @@
"dist",
"bin",
"postinstall.mjs",
"packages/lsp-tools-mcp/dist"
"packages/lsp-tools-mcp/dist",
"packages/ast-grep-mcp/dist"
],
"exports": {
".": {
@@ -23,7 +28,7 @@
"./schema.json": "./dist/oh-my-opencode.schema.json"
},
"scripts": {
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi --external zod && bun run build:node-require-shim && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema",
"build": "bun run build:ast-grep-mcp && bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi --external zod && bun run build:node-require-shim && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema",
"build:lsp-tools-mcp": "npm --prefix packages/lsp-tools-mcp ci && npm --prefix packages/lsp-tools-mcp run build",
"build:node-require-shim": "bun run script/patch-node-require-shim.ts",
"build:all": "bun run build && bun run build:binaries",
@@ -35,9 +40,11 @@
"postinstall": "node postinstall.mjs",
"prepublishOnly": "bun run clean && bun run build:lsp-tools-mcp && bun run build",
"test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail",
"typecheck": "tsgo --noEmit",
"typecheck": "tsgo --noEmit && bun run typecheck:packages",
"typecheck:packages": "tsgo --noEmit -p packages/rules-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json",
"typecheck:script": "tsgo --noEmit -p script/tsconfig.json",
"test": "bun test"
"test": "bun test",
"build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build"
},
"keywords": [
"opencode",
@@ -78,6 +85,8 @@
"vscode-jsonrpc": "^8.2.1"
},
"devDependencies": {
"@oh-my-opencode/ast-grep-mcp": "workspace:*",
"@oh-my-opencode/rules-core": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260518.1",
"@types/js-yaml": "^4.0.9",
"@types/picomatch": "^4.0.3",
+26
View File
@@ -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"
}
}
+180
View File
@@ -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,
};
}
@@ -1,7 +1,6 @@
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
@@ -23,13 +22,6 @@ export async function getAstGrepPath(): Promise<string | null> {
return syncPath
}
const downloadedPath = await ensureAstGrepBinary()
if (downloadedPath) {
resolvedCliPath = downloadedPath
setSgCliPath(downloadedPath)
return downloadedPath
}
return null
})()
+18
View File
@@ -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;
});
+2
View File
@@ -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"
+4
View File
@@ -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
+170
View File
@@ -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");
});
});
+275
View File
@@ -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);
}
@@ -1,4 +1,4 @@
import type { AnalyzeResult, SgResult } from "./types"
import type { SgResult } from "./types"
export function formatSearchResult(result: SgResult): string {
if (result.error) {
@@ -68,35 +68,3 @@ export function formatReplaceResult(result: SgResult, isDryRun: boolean): string
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\`\`\``
}
@@ -1,10 +1,9 @@
import { spawn } from "../../shared/bun-spawn-shim"
import { spawn } from "./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"
@@ -21,8 +20,9 @@ export {
export interface RunOptions {
pattern: string
lang: CliLanguage
paths?: string[]
globs?: string[]
cwd?: string
paths?: readonly string[]
globs?: readonly string[]
rewrite?: string
context?: number
updateAll?: boolean
@@ -35,34 +35,14 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
// 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)
const args = createSgArgs(options, { includeJson: true, includeUpdateAll: false })
let cliPath = getSgCliPath()
if (!cliPath || !existsSync(cliPath)) {
const downloadedPath = await getAstGrepPath()
if (downloadedPath) {
cliPath = downloadedPath
const resolvedPath = await getAstGrepPath()
if (resolvedPath) {
cliPath = resolvedPath
} else {
return {
matches: [],
@@ -81,6 +61,7 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
const timeout = DEFAULT_TIMEOUT_MS
const proc = spawn([cliPath, ...args], {
cwd: options.cwd,
stdout: "pipe",
stderr: "pipe",
})
@@ -106,31 +87,23 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
}
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 errorCode = errorCodeFrom(error)
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` +
`Install options:\n` +
` bun add -D @ast-grep/cli\n` +
` cargo install ast-grep --locked\n` +
` brew install ast-grep`,
}
}
}
return {
matches: [],
@@ -153,10 +126,10 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
const jsonResult = createSgResultFromStdout(stdout)
if (shouldSeparateWritePass && jsonResult.matches.length > 0) {
const writeArgs = args.filter(a => a !== "--json=compact")
writeArgs.push("--update-all")
const writeArgs = createSgArgs(options, { includeJson: false, includeUpdateAll: true })
const writeProc = spawn([cliPath, ...writeArgs], {
cwd: options.cwd,
stdout: "pipe",
stderr: "pipe",
})
@@ -175,3 +148,37 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
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")
}
@@ -2,8 +2,6 @@ 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 {
@@ -34,11 +32,6 @@ function getPlatformPackageName(): string | 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")
@@ -1,7 +1,6 @@
import type { CLI_LANGUAGES, NAPI_LANGUAGES } from "./constants"
import type { CLI_LANGUAGES } from "./constants"
export type CliLanguage = (typeof CLI_LANGUAGES)[number]
export type NapiLanguage = (typeof NAPI_LANGUAGES)[number]
export interface Position {
line: number
@@ -26,31 +25,6 @@ export interface CliMatch {
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[]
@@ -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}`);
}
+14
View File
@@ -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/**/*"]
}
+1
View File
@@ -0,0 +1 @@
export * from "./src/index";
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@oh-my-opencode/rules-core",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "Pure TypeScript rule discovery, matching, and nested AGENTS.md context utilities for oh-my-opencode.",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./src/index.ts"
}
},
"types": "./index.d.ts",
"scripts": {
"typecheck": "tsgo --noEmit -p tsconfig.json",
"test": "bun test src/*.test.ts"
},
"dependencies": {
"picomatch": "^4.0.4"
}
}
+50
View File
@@ -0,0 +1,50 @@
import { existsSync, statSync } from "node:fs";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { AGENTS_FILENAME } from "./constants";
import type { AgentsMdCache } from "./types";
export interface FindAgentsMdUpInput {
readonly startDir: string;
readonly rootDir: string;
readonly skipRoot?: boolean;
readonly cache?: AgentsMdCache;
}
export async function findAgentsMdUp(input: FindAgentsMdUpInput): Promise<string[]> {
const startDir = resolve(input.startDir);
const rootDir = resolve(input.rootDir);
const skipRoot = input.skipRoot ?? true;
const cacheKey = [startDir, rootDir, skipRoot ? "1" : "0"].join("\0");
const cached = input.cache?.get(cacheKey);
if (cached) return [...cached];
const found: string[] = [];
let current = startDir;
while (true) {
const isRootDir = current === rootDir;
if (!(skipRoot && isRootDir)) {
const agentsPath = join(current, AGENTS_FILENAME);
if (isFile(agentsPath)) found.push(agentsPath);
}
if (isRootDir) break;
const parent = dirname(current);
if (parent === current || !isSameOrChildPath(parent, rootDir)) break;
current = parent;
}
const result = found.reverse();
input.cache?.set(cacheKey, result);
return result;
}
function isFile(path: string): boolean {
if (!existsSync(path)) return false;
try {
return statSync(path).isFile();
} catch {
return false;
}
}
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
const relativePath = relative(parentPath, childPath);
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
}
+26
View File
@@ -0,0 +1,26 @@
import type { AgentsMdCache, DirectoryScanEntry, RuleFileCandidate, RuleScanCache, RuleScanCacheStats } from "./types";
export function createRuleScanCache(): RuleScanCache {
const candidateCache = new Map<string, readonly RuleFileCandidate[]>();
const directoryCache = new Map<string, readonly DirectoryScanEntry[]>();
return {
get: (key) => candidateCache.get(key),
set: (key, value) => candidateCache.set(key, value),
getDirScan: (dir) => directoryCache.get(dir),
setDirScan: (dir, entries) => directoryCache.set(dir, entries),
stats: (): RuleScanCacheStats => ({ candidateEntries: candidateCache.size, directoryEntries: directoryCache.size }),
clear: () => {
candidateCache.clear();
directoryCache.clear();
},
};
}
export function createAgentsMdCache(): AgentsMdCache {
const cache = new Map<string, readonly string[]>();
return {
get: (key) => cache.get(key),
set: (key, value) => cache.set(key, value),
clear: () => cache.clear(),
};
}
+33
View File
@@ -0,0 +1,33 @@
import type { RuleSource } from "./types";
export const PROJECT_MARKERS = [".git", "pyproject.toml", "package.json", "Cargo.toml", "go.mod", ".venv"] as const;
export const PROJECT_RULE_SUBDIRS = [
[".omo", "rules"],
[".sisyphus", "rules"],
[".claude", "rules"],
[".cursor", "rules"],
[".github", "instructions"],
] as const;
export const PROJECT_RULE_FILES = [".github/copilot-instructions.md"] as const;
export const OPENCODE_USER_RULE_DIRS = [".omo/rules", ".sisyphus/rules", ".opencode/rules"] as const;
export const USER_RULE_DIR = ".claude/rules";
export const RULE_EXTENSIONS = [".md", ".mdc"] as const;
export const GITHUB_INSTRUCTIONS_PATTERN = /\.instructions\.md$/;
export const AGENTS_FILENAME = "AGENTS.md";
export const GLOBAL_DISTANCE = 9999;
export const EXCLUDED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", ".next", "coverage"]);
export const SOURCE_PRIORITY: ReadonlyMap<RuleSource, number> = new Map([
[".omo/rules", 0],
[".sisyphus/rules", 1],
[".claude/rules", 2],
[".cursor/rules", 3],
[".github/instructions", 4],
[".github/copilot-instructions.md", 5],
["~/.omo/rules", 100],
["~/.sisyphus/rules", 101],
["~/.opencode/rules", 102],
["~/.claude/rules", 103],
]);
+25
View File
@@ -0,0 +1,25 @@
import { dirname, relative } from "node:path";
import { GLOBAL_DISTANCE } from "./constants";
export function calculateDistance(rulePath: string, currentFile: string, projectRoot: string | null): number {
if (!projectRoot) return GLOBAL_DISTANCE;
try {
const ruleRelative = relative(projectRoot, dirname(rulePath));
const currentRelative = relative(projectRoot, dirname(currentFile));
if (ruleRelative.startsWith("..") || currentRelative.startsWith("..")) return GLOBAL_DISTANCE;
const ruleParts = toParts(ruleRelative);
const currentParts = toParts(currentRelative);
let shared = 0;
for (let index = 0; index < Math.min(ruleParts.length, currentParts.length); index += 1) {
if (ruleParts[index] !== currentParts[index]) break;
shared += 1;
}
return currentParts.length - shared;
} catch {
return GLOBAL_DISTANCE;
}
}
function toParts(path: string): string[] {
return path.split(/[/\\]/).filter(Boolean);
}
+140
View File
@@ -0,0 +1,140 @@
import { existsSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { GLOBAL_DISTANCE, OPENCODE_USER_RULE_DIRS, PROJECT_RULE_FILES, PROJECT_RULE_SUBDIRS, USER_RULE_DIR } from "./constants";
import { sortCandidates } from "./ordering";
import { findRuleFilesRecursive, safeRealpathSync } from "./scanner";
import type { DirectoryScanEntry, FindRuleFilesOptions, RuleFileCandidate, RuleScanCache, RuleSource } from "./types";
export function findRuleFiles(
projectRoot: string | null,
homeDir: string,
currentFile: string,
options?: FindRuleFilesOptions,
cache?: RuleScanCache,
): RuleFileCandidate[] {
const startDir = dirname(resolve(currentFile));
const skipClaudeUserRules = options?.skipClaudeUserRules ?? false;
const cacheKey = [projectRoot ?? "", startDir, skipClaudeUserRules ? "1" : "0"].join("\0");
const cached = cache?.get(cacheKey);
if (cached) return [...cached];
const candidates: RuleFileCandidate[] = [];
const seenRealPaths = new Set<string>();
if (projectRoot) {
addProjectRuleCandidates(projectRoot, startDir, candidates, seenRealPaths, cache);
addProjectSingleFileCandidates(projectRoot, candidates, seenRealPaths);
}
addUserRuleCandidates(homeDir || homedir(), skipClaudeUserRules, candidates, seenRealPaths, cache);
const sorted = sortCandidates(candidates);
cache?.set(cacheKey, sorted);
return sorted;
}
function addProjectRuleCandidates(
projectRoot: string,
startDir: string,
candidates: RuleFileCandidate[],
seenRealPaths: Set<string>,
cache: RuleScanCache | undefined,
): void {
let currentDir = startDir;
let distance = 0;
while (true) {
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
const source = `${parent}/${subdir}` as RuleSource;
const ruleDir = join(currentDir, parent, subdir);
for (const entry of scanDirectoryWithCache(ruleDir, cache)) {
if (seenRealPaths.has(entry.realPath)) continue;
seenRealPaths.add(entry.realPath);
candidates.push({
path: entry.path,
realPath: entry.realPath,
source,
isGlobal: false,
distance,
relativePath: normalizePath(relative(projectRoot, entry.path)),
});
}
}
if (currentDir === projectRoot) break;
const parentDir = dirname(currentDir);
if (parentDir === currentDir || !isSameOrChildPath(parentDir, projectRoot)) break;
currentDir = parentDir;
distance += 1;
}
}
function addProjectSingleFileCandidates(
projectRoot: string,
candidates: RuleFileCandidate[],
seenRealPaths: Set<string>,
): void {
for (const ruleFile of PROJECT_RULE_FILES) {
const filePath = join(projectRoot, ruleFile);
const realPath = validFileRealPath(filePath);
if (realPath === null || seenRealPaths.has(realPath)) continue;
seenRealPaths.add(realPath);
candidates.push({
path: filePath,
realPath,
source: ruleFile as RuleSource,
isGlobal: false,
distance: 0,
isSingleFile: true,
relativePath: normalizePath(ruleFile),
});
}
}
function addUserRuleCandidates(
homeDir: string,
skipClaudeUserRules: boolean,
candidates: RuleFileCandidate[],
seenRealPaths: Set<string>,
cache: RuleScanCache | undefined,
): void {
const userRuleDirs: Array<readonly [string, RuleSource]> = OPENCODE_USER_RULE_DIRS.map((dir) => [join(homeDir, dir), `~/${dir}` as RuleSource]);
if (!skipClaudeUserRules) userRuleDirs.push([join(homeDir, USER_RULE_DIR), "~/.claude/rules"]);
for (const [userRuleDir, source] of userRuleDirs) {
for (const entry of scanDirectoryWithCache(userRuleDir, cache)) {
if (seenRealPaths.has(entry.realPath)) continue;
seenRealPaths.add(entry.realPath);
candidates.push({
path: entry.path,
realPath: entry.realPath,
source,
isGlobal: true,
distance: GLOBAL_DISTANCE,
relativePath: normalizePath(relative(homeDir, entry.path)),
});
}
}
}
function scanDirectoryWithCache(dir: string, cache: RuleScanCache | undefined): readonly DirectoryScanEntry[] {
const cached = cache?.getDirScan(dir);
if (cached) return cached;
const entries: DirectoryScanEntry[] = [];
findRuleFilesRecursive(dir, entries);
cache?.setDirScan(dir, entries);
return entries;
}
function validFileRealPath(filePath: string): string | null {
if (!existsSync(filePath)) return null;
try {
if (!statSync(filePath).isFile()) return null;
return safeRealpathSync(filePath);
} catch {
return null;
}
}
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
const relativePath = relative(parentPath, childPath);
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
}
function normalizePath(path: string): string {
return path.replaceAll("\\", "/");
}
+133
View File
@@ -0,0 +1,133 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "bun:test";
import {
clearProjectRootCache,
createAgentsMdCache,
createRuleScanCache,
findAgentsMdUp,
findProjectRoot,
findRuleFiles,
parseRuleFrontmatter,
shouldApplyRule,
} from "./index";
let testRoot: string | null = null;
function createTestRoot(name: string): string {
testRoot = join(tmpdir(), `${name}-${Date.now()}-${Math.random()}`);
mkdirSync(testRoot, { recursive: true });
return testRoot;
}
afterEach(() => {
if (testRoot) {
rmSync(testRoot, { recursive: true, force: true });
testRoot = null;
}
clearProjectRootCache();
});
describe("rules-core", () => {
it("#given mixed rule sources #when finding rule files #then returns deterministic source-priority order", () => {
// given
const root = createTestRoot("rules-core-order");
mkdirSync(join(root, ".git"));
mkdirSync(join(root, ".omo", "rules"), { recursive: true });
mkdirSync(join(root, ".sisyphus", "rules"), { recursive: true });
mkdirSync(join(root, ".claude", "rules"), { recursive: true });
mkdirSync(join(root, ".cursor", "rules"), { recursive: true });
mkdirSync(join(root, ".github", "instructions"), { recursive: true });
mkdirSync(join(root, "src"), { recursive: true });
writeFileSync(join(root, ".github", "copilot-instructions.md"), "copilot");
writeFileSync(join(root, ".omo", "rules", "omo.md"), "omo");
writeFileSync(join(root, ".sisyphus", "rules", "sisyphus.md"), "sisyphus");
writeFileSync(join(root, ".claude", "rules", "claude.md"), "claude");
writeFileSync(join(root, ".cursor", "rules", "cursor.md"), "cursor");
writeFileSync(join(root, ".github", "instructions", "github.instructions.md"), "github");
// when
const found = findRuleFiles(root, root, join(root, "src", "index.ts"));
// then
expect(found.map((rule) => rule.relativePath)).toEqual([
".github/copilot-instructions.md",
".omo/rules/omo.md",
".sisyphus/rules/sisyphus.md",
".claude/rules/claude.md",
".cursor/rules/cursor.md",
".github/instructions/github.instructions.md",
]);
});
it("#given frontmatter aliases and negative glob #when matching #then honors applyTo paths and exclusions", () => {
// given
const { metadata } = parseRuleFrontmatter(`---\npaths: ["src/**/*.ts"]\napplyTo:\n - "!src/**/*.test.ts"\n---\nRule\n`);
// when
const sourceMatch = shouldApplyRule(metadata, "/repo/src/index.ts", "/repo");
const testMatch = shouldApplyRule(metadata, "/repo/src/index.test.ts", "/repo");
// then
expect(sourceMatch).toEqual({ applies: true, reason: "glob: src/**/*.ts" });
expect(testMatch).toEqual({ applies: false });
});
it("#given nested AGENTS.md files #when walking up with root skip #then returns parent-to-child non-root files", async () => {
// given
const root = createTestRoot("rules-core-agents");
mkdirSync(join(root, ".git"));
mkdirSync(join(root, "packages", "app", "src"), { recursive: true });
writeFileSync(join(root, "AGENTS.md"), "root");
writeFileSync(join(root, "packages", "AGENTS.md"), "packages");
writeFileSync(join(root, "packages", "app", "AGENTS.md"), "app");
// when
const found = await findAgentsMdUp({
startDir: join(root, "packages", "app", "src"),
rootDir: root,
cache: createAgentsMdCache(),
});
// then
expect(found).toEqual([
join(root, "packages", "AGENTS.md"),
join(root, "packages", "app", "AGENTS.md"),
]);
});
it("#given repeated same-directory targets #when using scan caches #then reuses cached candidates", () => {
// given
const root = createTestRoot("rules-core-cache");
mkdirSync(join(root, ".git"));
mkdirSync(join(root, ".omo", "rules"), { recursive: true });
mkdirSync(join(root, "src"), { recursive: true });
writeFileSync(join(root, ".omo", "rules", "typescript.md"), "typescript");
const cache = createRuleScanCache();
// when
const first = findRuleFiles(root, root, join(root, "src", "a.ts"), undefined, cache);
const second = findRuleFiles(root, root, join(root, "src", "b.ts"), undefined, cache);
// then
expect(first).toEqual(second);
expect(cache.stats()).toEqual({ candidateEntries: 1, directoryEntries: 11 });
});
it("#given nested project markers #when finding project root #then memoizes ancestor lookups", () => {
// given
const root = createTestRoot("rules-core-project-root");
mkdirSync(join(root, ".git"));
mkdirSync(join(root, "a", "b", "c"), { recursive: true });
// when
const first = findProjectRoot(join(root, "a", "b", "c", "file.ts"));
const second = findProjectRoot(join(root, "a", "b", "other.ts"));
// then
expect(first).toBe(root);
expect(second).toBe(root);
});
});
+20
View File
@@ -0,0 +1,20 @@
export { createAgentsMdCache, createRuleScanCache } from "./cache";
export { findAgentsMdUp, type FindAgentsMdUpInput } from "./agents-md";
export { findRuleFiles } from "./finder";
export { parseRuleFrontmatter } from "./parser";
export { shouldApplyRule, createContentHash, isDuplicateByContentHash, isDuplicateByRealPath, resetMatcherCache, getMatcherCacheStats } from "./matcher";
export { findProjectRoot, clearProjectRootCache } from "./project-root";
export { calculateDistance } from "./distance";
export { findRuleFilesRecursive, safeRealpathSync } from "./scanner";
export type {
AgentsMdCache,
DirectoryScanEntry,
FindRuleFilesOptions,
MatchResult,
RuleFileCandidate,
RuleFrontmatterResult,
RuleMetadata,
RuleScanCache,
RuleScanCacheStats,
RuleSource,
} from "./types";
+77
View File
@@ -0,0 +1,77 @@
import { createHash } from "node:crypto";
import { basename, relative } from "node:path";
import picomatch from "picomatch";
import type { MatchResult, RuleMetadata } from "./types";
const matcherCache = new Map<string, (path: string) => boolean>();
const MAX_MATCHER_CACHE_ENTRIES = 256;
const PICOMATCH_OPTIONS = { dot: true, bash: true } as const;
export function resetMatcherCache(): void {
matcherCache.clear();
}
export function getMatcherCacheStats(): { readonly entries: number } {
return { entries: matcherCache.size };
}
export function shouldApplyRule(metadata: RuleMetadata, currentFilePath: string, projectRoot: string | null): MatchResult {
if (metadata.alwaysApply === true) return { applies: true, reason: "alwaysApply" };
const patterns = normalizeGlobs(metadata);
if (patterns.length === 0) return { applies: false };
const pathBases = [
toPosix(projectRoot ? relative(projectRoot, currentFilePath) : currentFilePath),
toPosix(basename(currentFilePath)),
];
const negativeMatchers = patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => matcherFor(pattern.slice(1)));
for (const pattern of patterns) {
if (pattern.startsWith("!")) continue;
const isMatch = matcherFor(pattern);
if (!pathBases.some((pathBase) => isMatch(pathBase))) continue;
if (pathBases.some((pathBase) => negativeMatchers.some((isExcluded) => isExcluded(pathBase)))) return { applies: false };
return { applies: true, reason: `glob: ${pattern}` };
}
return { applies: false };
}
export function createContentHash(content: string): string {
return createHash("sha256").update(content).digest("hex").slice(0, 16);
}
export function isDuplicateByRealPath(realPath: string, cache: ReadonlySet<string>): boolean {
return cache.has(realPath);
}
export function isDuplicateByContentHash(hash: string, cache: ReadonlySet<string>): boolean {
return cache.has(hash);
}
function normalizeGlobs(metadata: RuleMetadata): string[] {
const patterns = [...normalizePatternList(metadata.globs), ...normalizePatternList(metadata.paths), ...normalizePatternList(metadata.applyTo)];
return [...new Set(patterns.map(toPosix))];
}
function normalizePatternList(patterns: string | readonly string[] | undefined): string[] {
if (patterns === undefined) return [];
return typeof patterns === "string" ? [patterns] : [...patterns];
}
function matcherFor(pattern: string): (path: string) => boolean {
const cached = matcherCache.get(pattern);
if (cached) {
matcherCache.delete(pattern);
matcherCache.set(pattern, cached);
return cached;
}
const matcher = picomatch(pattern, PICOMATCH_OPTIONS);
if (matcherCache.size >= MAX_MATCHER_CACHE_ENTRIES) {
const oldest = matcherCache.keys().next().value;
if (oldest !== undefined) matcherCache.delete(oldest);
}
matcherCache.set(pattern, matcher);
return matcher;
}
function toPosix(path: string): string {
return path.replaceAll("\\", "/");
}
+26
View File
@@ -0,0 +1,26 @@
import { SOURCE_PRIORITY } from "./constants";
import type { RuleFileCandidate } from "./types";
export function sortCandidates<T extends RuleFileCandidate>(candidates: readonly T[]): T[] {
return candidates
.map((candidate, index) => ({ candidate, index }))
.sort((left, right) => compareCandidates(left.candidate, right.candidate) || left.index - right.index)
.map(({ candidate }) => candidate);
}
function compareCandidates(left: RuleFileCandidate, right: RuleFileCandidate): number {
return (
Number(left.isGlobal) - Number(right.isGlobal) ||
left.distance - right.distance ||
(SOURCE_PRIORITY.get(left.source) ?? Number.POSITIVE_INFINITY) -
(SOURCE_PRIORITY.get(right.source) ?? Number.POSITIVE_INFINITY) ||
compareString(left.relativePath, right.relativePath) ||
compareString(left.realPath, right.realPath)
);
}
function compareString(left: string, right: string): number {
if (left < right) return -1;
if (left > right) return 1;
return 0;
}
+175
View File
@@ -0,0 +1,175 @@
import type { RuleFrontmatterResult, RuleMetadata } from "./types";
type GlobValue = string | readonly string[];
type ParsedGlobValue = {
readonly value: GlobValue;
readonly consumed: number;
};
export function parseRuleFrontmatter(content: string): RuleFrontmatterResult {
const normalized = stripBom(content);
const openingLength = openingDelimiterLength(normalized);
if (openingLength === 0) return { metadata: {}, body: normalized };
const closing = findClosingDelimiter(normalized, openingLength);
if (!closing) return { metadata: {}, body: normalized };
try {
return { metadata: parseYaml(normalized.slice(openingLength, closing.start)), body: normalized.slice(closing.bodyStart) };
} catch {
return { metadata: {}, body: normalized };
}
}
function parseYaml(yaml: string): RuleMetadata {
const lines = yaml.replace(/\r\n/g, "\n").split("\n");
const metadata: { description?: string; alwaysApply?: boolean; globs?: string | string[] } = {};
let index = 0;
while (index < lines.length) {
const line = stripComment(lines[index] ?? "").trim();
if (!line) {
index += 1;
continue;
}
const colon = line.indexOf(":");
if (colon === -1) {
index += 1;
continue;
}
const key = line.slice(0, colon).trim();
const rawValue = line.slice(colon + 1).trim();
if (key === "description") metadata.description = parseString(rawValue);
else if (key === "alwaysApply") metadata.alwaysApply = rawValue === "true";
else if (key === "globs" || key === "paths" || key === "applyTo") {
const parsed = parseGlobValue(rawValue, lines, index);
metadata.globs = mergeGlobs(metadata.globs, parsed.value);
index += parsed.consumed;
continue;
}
index += 1;
}
return metadata;
}
function parseGlobValue(rawValue: string, lines: readonly string[], currentIndex: number): ParsedGlobValue {
if (rawValue.startsWith("[")) return { value: parseInlineArray(rawValue), consumed: 1 };
if (!rawValue) {
const parsed = parseMultilineArray(lines, currentIndex);
return parsed.values.length > 0 ? { value: parsed.values, consumed: parsed.consumed } : { value: "", consumed: 1 };
}
const value = parseString(rawValue);
if (value.includes(",")) return { value: value.split(",").map((item) => item.trim()).filter(Boolean), consumed: 1 };
return { value, consumed: 1 };
}
function parseMultilineArray(lines: readonly string[], currentIndex: number): { readonly values: readonly string[]; readonly consumed: number } {
const values: string[] = [];
let consumed = 1;
for (let index = currentIndex + 1; index < lines.length; index += 1) {
const line = stripComment(lines[index] ?? "");
if (line.trim().length === 0) {
consumed += 1;
continue;
}
const item = line.match(/^\s+-\s*(.*)$/);
if (!item) break;
const value = parseString(item[1] ?? "");
if (value) values.push(value);
consumed += 1;
}
return { values, consumed };
}
function parseInlineArray(value: string): string[] {
const closing = value.lastIndexOf("]");
if (closing === -1) return [];
return splitCommaSeparated(value.slice(1, closing)).map(parseString).filter(Boolean);
}
function mergeGlobs(existing: string | string[] | undefined, next: GlobValue): string | string[] {
if (Array.isArray(next) && next.length === 0) return existing ?? [];
if (!Array.isArray(next) && next.length === 0) return existing ?? "";
if (existing === undefined) {
if (typeof next === "string") return next;
return [...next];
}
const existingValues = Array.isArray(existing) ? existing : [existing];
const nextValues = typeof next === "string" ? [next] : [...next];
return [...existingValues, ...nextValues];
}
function splitCommaSeparated(value: string): string[] {
const values: string[] = [];
let current = "";
let quote: string | null = null;
let escaped = false;
for (const character of value) {
if (escaped) {
current += character;
escaped = false;
continue;
}
if (quote && character === "\\") {
escaped = true;
continue;
}
if (character === '"' || character === "'") {
if (!quote) quote = character;
else if (quote === character) quote = null;
current += character;
continue;
}
if (!quote && character === ",") {
values.push(current.trim());
current = "";
continue;
}
current += character;
}
values.push(current.trim());
return values;
}
function parseString(value: string): string {
const trimmed = value.trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function stripComment(line: string): string {
let quote: string | null = null;
for (let index = 0; index < line.length; index += 1) {
const character = line[index];
if (character === '"' || character === "'") {
if (!quote) quote = character;
else if (quote === character) quote = null;
}
if (!quote && character === "#") return line.slice(0, index);
}
return line;
}
function stripBom(content: string): string {
return content.startsWith("\uFEFF") ? content.slice(1) : content;
}
function openingDelimiterLength(content: string): number {
if (content.startsWith("---\r\n")) return 5;
if (content.startsWith("---\n")) return 4;
return 0;
}
function findClosingDelimiter(content: string, openingLength: number): { readonly start: number; readonly bodyStart: number } | null {
let lineStart = openingLength;
while (lineStart <= content.length) {
const nextNewline = content.indexOf("\n", lineStart);
const lineEnd = nextNewline === -1 ? content.length : nextNewline;
if (content.slice(lineStart, lineEnd).replace(/\r$/, "") === "---") {
return { start: lineStart, bodyStart: nextNewline === -1 ? content.length : nextNewline + 1 };
}
if (nextNewline === -1) break;
lineStart = nextNewline + 1;
}
return null;
}
+55
View File
@@ -0,0 +1,55 @@
import { existsSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { PROJECT_MARKERS } from "./constants";
const projectRootCache = new Map<string, string | null>();
export function clearProjectRootCache(): void {
projectRootCache.clear();
}
export function findProjectRoot(startPath: string): string | null {
const cached = projectRootCache.get(startPath);
if (cached !== undefined) return cached;
const startDir = resolveStartDir(startPath);
const cachedStartDir = projectRootCache.get(startDir);
if (cachedStartDir !== undefined) {
projectRootCache.set(startPath, cachedStartDir);
return cachedStartDir;
}
const visited: string[] = [];
let current = startDir;
let resolved: string | null = null;
while (true) {
const cachedAncestor = projectRootCache.get(current);
if (cachedAncestor !== undefined) {
resolved = cachedAncestor;
break;
}
visited.push(current);
if (hasProjectMarker(current)) {
resolved = current;
break;
}
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
for (const directory of visited) projectRootCache.set(directory, resolved);
projectRootCache.set(startPath, resolved);
return resolved;
}
function resolveStartDir(startPath: string): string {
try {
return statSync(startPath).isDirectory() ? startPath : dirname(startPath);
} catch {
return dirname(startPath);
}
}
function hasProjectMarker(directory: string): boolean {
return PROJECT_MARKERS.some((marker) => existsSync(join(directory, marker)));
}
+44
View File
@@ -0,0 +1,44 @@
import { existsSync, readdirSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { EXCLUDED_DIRS, GITHUB_INSTRUCTIONS_PATTERN, RULE_EXTENSIONS } from "./constants";
import type { DirectoryScanEntry } from "./types";
function isGitHubInstructionsDir(dir: string): boolean {
return dir.includes(".github/instructions") || dir.endsWith(".github/instructions");
}
function isRuleFile(fileName: string, dir: string): boolean {
if (isGitHubInstructionsDir(dir)) return GITHUB_INSTRUCTIONS_PATTERN.test(fileName);
return RULE_EXTENSIONS.some((extension) => fileName.endsWith(extension));
}
export function safeRealpathSync(filePath: string): string {
try {
return realpathSync.native(filePath);
} catch {
return filePath;
}
}
export function findRuleFilesRecursive(dir: string, results: DirectoryScanEntry[], visited = new Set<string>()): void {
if (!existsSync(dir)) return;
const realDir = safeRealpathSync(dir);
if (visited.has(realDir)) return;
visited.add(realDir);
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" }).sort((left, right) => left.name.localeCompare(right.name));
} catch {
return;
}
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
if (!EXCLUDED_DIRS.has(entry.name)) findRuleFilesRecursive(fullPath, results, visited);
continue;
}
if (entry.isFile() && isRuleFile(entry.name, dir)) {
results.push({ path: fullPath, realPath: safeRealpathSync(fullPath), relativePath: entry.name });
}
}
}
+69
View File
@@ -0,0 +1,69 @@
export interface RuleMetadata {
readonly description?: string;
readonly globs?: string | readonly string[];
readonly paths?: string | readonly string[];
readonly applyTo?: string | readonly string[];
readonly alwaysApply?: boolean;
}
export interface RuleFrontmatterResult {
readonly metadata: RuleMetadata;
readonly body: string;
}
export interface RuleFileCandidate {
readonly path: string;
readonly realPath: string;
readonly isGlobal: boolean;
readonly distance: number;
readonly relativePath: string;
readonly source: RuleSource;
readonly isSingleFile?: boolean;
}
export type RuleSource =
| ".omo/rules"
| ".sisyphus/rules"
| ".claude/rules"
| ".cursor/rules"
| ".github/instructions"
| ".github/copilot-instructions.md"
| "~/.omo/rules"
| "~/.sisyphus/rules"
| "~/.opencode/rules"
| "~/.claude/rules";
export interface MatchResult {
readonly applies: boolean;
readonly reason?: string;
}
export interface DirectoryScanEntry {
readonly path: string;
readonly realPath: string;
readonly relativePath: string;
}
export interface RuleScanCacheStats {
readonly candidateEntries: number;
readonly directoryEntries: number;
}
export interface RuleScanCache {
get(key: string): readonly RuleFileCandidate[] | undefined;
set(key: string, value: readonly RuleFileCandidate[]): void;
getDirScan(dir: string): readonly DirectoryScanEntry[] | undefined;
setDirScan(dir: string, entries: readonly DirectoryScanEntry[]): void;
stats(): RuleScanCacheStats;
clear(): void;
}
export interface FindRuleFilesOptions {
readonly skipClaudeUserRules?: boolean;
}
export interface AgentsMdCache {
get(key: string): readonly string[] | undefined;
set(key: string, value: readonly string[]): void;
clear(): void;
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ESNext"],
"types": ["bun-types"]
},
"include": ["src/**/*"]
}
+2
View File
@@ -67,6 +67,8 @@ async function main() {
console.log(` Entry point: ${ENTRY_POINT}`);
console.log(` Platforms: ${PLATFORMS.length}`);
await $`bun run build:ast-grep-mcp`;
// Verify entry point exists
if (!existsSync(ENTRY_POINT)) {
console.error(`\n❌ Entry point not found: ${ENTRY_POINT}`);
+2 -2
View File
@@ -90,7 +90,7 @@ Total: 54 base, 61 with team-mode. Each tier produces an object whose values are
|--------|-------------|-----|---------|---------------|
| `agents/` | 104 | ~20k | 11 agent factories + dynamic prompt builder | yes (+ atlas, hephaestus, prometheus, sisyphus, sisyphus-junior, builtin-agents) |
| `hooks/` | 596 | ~78k | ~52 lifecycle hooks across 58 dirs | yes (+ atlas, anthropic-context-window-limit-recovery, auto-update-checker, claude-code-hooks, comment-checker, compaction-context-injector, keyword-detector, ralph-loop, rules-injector, runtime-fallback, session-recovery, todo-continuation-enforcer) |
| `tools/` | 317 | ~45k | 14 native tool dirs (+1 shared utilities dir); LSP moved to built-in MCP | yes (+ ast-grep, background-task, call-omo-agent, delegate-task, hashline-edit, look-at, skill) |
| `tools/` | 317 | ~45k | 13 native tool dirs (+1 shared utilities dir); LSP + AST-grep moved to built-in MCPs | yes (+ background-task, call-omo-agent, delegate-task, hashline-edit, look-at, skill) |
| `features/` | 404 | ~71k | 20 feature modules (team-mode, background-agent, boulder-state, etc.) | yes (+ 11 sub-AGENTS.md including builtin-skills, team-mode, background-agent, claude-code-*) |
| `shared/` | 290 | ~33k | Cross-cutting utilities, barrel-exported | yes |
| `cli/` | 158 | ~18k | Commander.js CLI: install, run, doctor, mcp-oauth, boulder | yes (+ config-manager, doctor, run) |
@@ -99,7 +99,7 @@ Total: 54 base, 61 with team-mode. Each tier produces an object whose values are
| `plugin-handlers/` | 27 | ~6k | 6-phase config loading pipeline | yes |
| `openclaw/` | 26 | ~3k | Bidirectional Discord/Telegram/HTTP integration | yes |
| `__tests__/` | 22 | ~300 | Plugin-level integration tests + perf fixtures | — |
| `mcp/` | 8 | ~260 | 4 built-in MCPs (3 remote + local stdio lsp) | yes |
| `mcp/` | 8 | ~260 | 5 built-in MCPs (3 remote + local stdio lsp + ast_grep) | yes |
| `testing/` | 3 | ~225 | Test utilities | — |
## NOTES
+1 -1
View File
@@ -5,7 +5,7 @@ import { join } from "node:path"
import type { McpServerInfo } from "../types"
import { parseJsonc } from "../../../shared"
const BUILTIN_MCP_SERVERS = ["context7", "grep_app"]
const BUILTIN_MCP_SERVERS = ["websearch", "context7", "grep_app", "lsp", "ast_grep"]
interface McpConfigShape {
mcpServers?: Record<string, unknown>
+7 -32
View File
@@ -1,7 +1,6 @@
import { constants, promises as fsPromises } from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { AGENTS_FILENAME } from "./constants";
import { findAgentsMdUp as findAgentsMdUpCore } from "@oh-my-opencode/rules-core";
import type { AgentsMdCache } from "@oh-my-opencode/rules-core";
import { isAbsolute, resolve } from "node:path";
export function resolveFilePath(rootDirectory: string, path: string): string | null {
if (!path) return null;
@@ -10,33 +9,9 @@ export function resolveFilePath(rootDirectory: string, path: string): string | n
}
export async function findAgentsMdUp(input: {
startDir: string;
rootDir: string;
readonly startDir: string;
readonly rootDir: string;
readonly cache?: AgentsMdCache;
}): Promise<string[]> {
const found: string[] = [];
let current = input.startDir;
while (true) {
// Skip root AGENTS.md - OpenCode's system.ts already loads it via custom()
// See: https://github.com/code-yeongyu/oh-my-openagent/issues/379
const isRootDir = current === input.rootDir;
if (!isRootDir) {
const agentsPath = join(current, AGENTS_FILENAME);
const exists = await fsPromises
.access(agentsPath, constants.F_OK)
.then(() => true)
.catch(() => false);
if (exists) {
found.push(agentsPath);
}
}
if (isRootDir) break;
const parent = dirname(current);
if (parent === current) break;
if (!parent.startsWith(input.rootDir)) break;
current = parent;
}
return found.reverse();
return findAgentsMdUpCore({ startDir: input.startDir, rootDir: input.rootDir, cache: input.cache });
}
+7 -4
View File
@@ -1,3 +1,4 @@
import { createAgentsMdCache } from "@oh-my-opencode/rules-core";
import type { PluginInput } from "@opencode-ai/plugin";
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
@@ -35,6 +36,7 @@ export function createDirectoryAgentsInjectorHook(
modelCacheState?: { anthropicContext1MEnabled: boolean },
): DirectoryAgentsInjectorHook {
const sessionCaches = new Map<string, Set<string>>();
const agentsMdCache = createAgentsMdCache();
const truncator = createDynamicTruncator(ctx, modelCacheState);
const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
@@ -45,6 +47,7 @@ export function createDirectoryAgentsInjectorHook(
ctx,
truncator,
sessionCaches,
agentsMdCache,
filePath: output.title,
sessionID: input.sessionID,
output,
@@ -54,21 +57,21 @@ export function createDirectoryAgentsInjectorHook(
};
const eventHandler = async ({ event }: EventInput) => {
const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") {
const sessionID = resolveSessionEventID(props);
const sessionID = resolveSessionEventID(event.properties);
if (sessionID) {
sessionCaches.delete(sessionID);
clearInjectedPaths(sessionID);
agentsMdCache.clear();
}
}
if (event.type === "session.compacted") {
const sessionID = resolveSessionEventID(props);
const sessionID = resolveSessionEventID(event.properties);
if (sessionID) {
sessionCaches.delete(sessionID);
clearInjectedPaths(sessionID);
agentsMdCache.clear();
}
}
};
+24 -15
View File
@@ -1,4 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin";
import type { AgentsMdCache } from "@oh-my-opencode/rules-core";
import { promises as fsPromises } from "node:fs";
import { dirname } from "node:path";
@@ -15,13 +16,18 @@ function getSessionCache(
if (!sessionCaches.has(sessionID)) {
sessionCaches.set(sessionID, loadInjectedPaths(sessionID));
}
return sessionCaches.get(sessionID)!;
const cache = sessionCaches.get(sessionID);
if (cache) return cache;
const loaded = loadInjectedPaths(sessionID);
sessionCaches.set(sessionID, loaded);
return loaded;
}
export async function processFilePathForAgentsInjection(input: {
ctx: PluginInput;
truncator: DynamicTruncator;
sessionCaches: Map<string, Set<string>>;
agentsMdCache?: AgentsMdCache;
filePath: string;
sessionID: string;
output: { title: string; output: string; metadata: unknown };
@@ -35,26 +41,29 @@ export async function processFilePathForAgentsInjection(input: {
const dir = dirname(resolved);
const cache = getSessionCache(input.sessionCaches, input.sessionID);
const agentsPaths = await findAgentsMdUp({ startDir: dir, rootDir: input.ctx.directory });
const agentsPaths = await findAgentsMdUp({
startDir: dir,
rootDir: input.ctx.directory,
cache: input.agentsMdCache,
});
let dirty = false;
for (const agentsPath of agentsPaths) {
const agentsDir = dirname(agentsPath);
if (cache.has(agentsDir)) continue;
try {
const content = await fsPromises.readFile(agentsPath, "utf-8");
cache.add(agentsDir);
const { result, truncated } = await input.truncator.truncate(
input.sessionID,
content,
);
const truncationNotice = truncated
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]`
: "";
input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`;
dirty = true;
} catch {}
const content = await fsPromises.readFile(agentsPath, "utf-8").catch(() => null);
if (content === null) continue;
cache.add(agentsDir);
const { result, truncated } = await input.truncator.truncate(
input.sessionID,
content,
);
const truncationNotice = truncated
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]`
: "";
input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`;
dirty = true;
}
if (dirty) {
+10 -97
View File
@@ -1,100 +1,13 @@
import { createHash } from "crypto"
import { relative } from "node:path"
import picomatch from "picomatch"
import type { RuleMetadata } from "./types"
type PathMatcher = (path: string) => boolean
export interface MatchResult {
applies: boolean
reason?: string
}
export {
createContentHash,
getMatcherCacheStats,
isDuplicateByContentHash,
isDuplicateByRealPath,
resetMatcherCache,
shouldApplyRule,
} from "@oh-my-opencode/rules-core";
export type { MatchResult } from "@oh-my-opencode/rules-core";
export interface MatcherCacheStats {
entries: number
}
const PICOMATCH_OPTIONS = { dot: true, bash: true } as const
const MAX_MATCHER_CACHE_ENTRIES = 256
const matcherCache = new Map<string, PathMatcher>()
function matcherFor(pattern: string): PathMatcher {
const cached = matcherCache.get(pattern)
if (cached) {
matcherCache.delete(pattern)
matcherCache.set(pattern, cached)
return cached
}
const matcher = picomatch(pattern, PICOMATCH_OPTIONS)
if (matcherCache.size >= MAX_MATCHER_CACHE_ENTRIES) {
const oldestPattern = matcherCache.keys().next().value
if (oldestPattern !== undefined) {
matcherCache.delete(oldestPattern)
}
}
matcherCache.set(pattern, matcher)
return matcher
}
export function resetMatcherCache(): void {
matcherCache.clear()
}
export function getMatcherCacheStats(): MatcherCacheStats {
return { entries: matcherCache.size }
}
/**
* Check if a rule should apply to the current file based on metadata
*/
export function shouldApplyRule(
metadata: RuleMetadata,
currentFilePath: string,
projectRoot: string | null
): MatchResult {
if (metadata.alwaysApply === true) {
return { applies: true, reason: "alwaysApply" }
}
const globs = metadata.globs
if (!globs) {
return { applies: false }
}
const patterns = Array.isArray(globs) ? globs : [globs]
if (patterns.length === 0) {
return { applies: false }
}
const relativePath = projectRoot ? relative(projectRoot, currentFilePath) : currentFilePath
for (const pattern of patterns) {
if (matcherFor(pattern)(relativePath)) {
return { applies: true, reason: `glob: ${pattern}` }
}
}
return { applies: false }
}
/**
* Check if realPath already exists in cache (symlink deduplication)
*/
export function isDuplicateByRealPath(realPath: string, cache: Set<string>): boolean {
return cache.has(realPath)
}
/**
* Create SHA-256 hash of content, truncated to 16 chars
*/
export function createContentHash(content: string): string {
return createHash("sha256").update(content).digest("hex").slice(0, 16)
}
/**
* Check if content hash already exists in cache
*/
export function isDuplicateByContentHash(hash: string, cache: Set<string>): boolean {
return cache.has(hash)
readonly entries: number;
}
+2 -211
View File
@@ -1,211 +1,2 @@
import type { RuleMetadata } from "./types";
export interface RuleFrontmatterResult {
metadata: RuleMetadata;
body: string;
}
/**
* Parse YAML frontmatter from rule file content
* Supports:
* - Single string: globs: "**\/*.py"
* - Inline array: globs: ["**\/*.py", "src/**\/*.ts"]
* - Multi-line array:
* globs:
* - "**\/*.py"
* - "src/**\/*.ts"
* - Comma-separated: globs: "**\/*.py, src/**\/*.ts"
* - Claude Code 'paths' field (alias for globs)
*/
export function parseRuleFrontmatter(content: string): RuleFrontmatterResult {
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
const match = content.match(frontmatterRegex);
if (!match) {
return { metadata: {}, body: content };
}
const yamlContent = match[1];
const body = match[2];
try {
const metadata = parseYamlContent(yamlContent);
return { metadata, body };
} catch {
return { metadata: {}, body: content };
}
}
/**
* Parse YAML content without external library
*/
function parseYamlContent(yamlContent: string): RuleMetadata {
const lines = yamlContent.split("\n");
const metadata: RuleMetadata = {};
let i = 0;
while (i < lines.length) {
const line = lines[i];
const colonIndex = line.indexOf(":");
if (colonIndex === -1) {
i++;
continue;
}
const key = line.slice(0, colonIndex).trim();
const rawValue = line.slice(colonIndex + 1).trim();
if (key === "description") {
metadata.description = parseStringValue(rawValue);
} else if (key === "alwaysApply") {
metadata.alwaysApply = rawValue === "true";
} else if (key === "globs" || key === "paths" || key === "applyTo") {
const { value, consumed } = parseArrayOrStringValue(rawValue, lines, i);
// Merge paths into globs (Claude Code compatibility)
if (key === "paths") {
metadata.globs = mergeGlobs(metadata.globs, value);
} else {
metadata.globs = mergeGlobs(metadata.globs, value);
}
i += consumed;
continue;
}
i++;
}
return metadata;
}
/**
* Parse a string value, removing surrounding quotes
*/
function parseStringValue(value: string): string {
if (!value) return "";
// Remove surrounding quotes
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
return value;
}
/**
* Parse array or string value from YAML
* Returns the parsed value and number of lines consumed
*/
function parseArrayOrStringValue(
rawValue: string,
lines: string[],
currentIndex: number
): { value: string | string[]; consumed: number } {
// Case 1: Inline array ["a", "b", "c"]
if (rawValue.startsWith("[")) {
return { value: parseInlineArray(rawValue), consumed: 1 };
}
// Case 2: Multi-line array (value is empty, next lines start with " - ")
if (!rawValue || rawValue === "") {
const arrayItems: string[] = [];
let consumed = 1;
for (let j = currentIndex + 1; j < lines.length; j++) {
const nextLine = lines[j];
// Check if this is an array item (starts with whitespace + dash)
const arrayMatch = nextLine.match(/^\s+-\s*(.*)$/);
if (arrayMatch) {
const itemValue = parseStringValue(arrayMatch[1].trim());
if (itemValue) {
arrayItems.push(itemValue);
}
consumed++;
} else if (nextLine.trim() === "") {
// Skip empty lines within array
consumed++;
} else {
// Not an array item, stop
break;
}
}
if (arrayItems.length > 0) {
return { value: arrayItems, consumed };
}
}
// Case 3: Comma-separated patterns in single string
const stringValue = parseStringValue(rawValue);
if (stringValue.includes(",")) {
const items = stringValue
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
return { value: items, consumed: 1 };
}
// Case 4: Single string value
return { value: stringValue, consumed: 1 };
}
/**
* Parse inline JSON-like array: ["a", "b", "c"]
*/
function parseInlineArray(value: string): string[] {
// Remove brackets
const content = value.slice(1, value.lastIndexOf("]")).trim();
if (!content) return [];
const items: string[] = [];
let current = "";
let inQuote = false;
let quoteChar = "";
for (let i = 0; i < content.length; i++) {
const char = content[i];
if (!inQuote && (char === '"' || char === "'")) {
inQuote = true;
quoteChar = char;
} else if (inQuote && char === quoteChar) {
inQuote = false;
quoteChar = "";
} else if (!inQuote && char === ",") {
const trimmed = current.trim();
if (trimmed) {
items.push(parseStringValue(trimmed));
}
current = "";
} else {
current += char;
}
}
// Don't forget the last item
const trimmed = current.trim();
if (trimmed) {
items.push(parseStringValue(trimmed));
}
return items;
}
/**
* Merge two globs values (for combining paths and globs)
*/
function mergeGlobs(
existing: string | string[] | undefined,
newValue: string | string[]
): string | string[] {
if (!existing) return newValue;
const existingArray = Array.isArray(existing) ? existing : [existing];
const newArray = Array.isArray(newValue) ? newValue : [newValue];
return [...existingArray, ...newArray];
}
export { parseRuleFrontmatter } from "@oh-my-opencode/rules-core";
export type { RuleFrontmatterResult } from "@oh-my-opencode/rules-core";
@@ -1,85 +1 @@
import { existsSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { PROJECT_MARKERS } from "./constants";
const projectRootCache = new Map<string, string | null>();
export function clearProjectRootCache(): void {
projectRootCache.clear();
}
/**
* Find project root by walking up from startPath.
* Checks for PROJECT_MARKERS (.git, pyproject.toml, package.json, etc.)
*
* Memoizes every directory visited during the walk so subsequent lookups for
* any descendant path resolve in O(1) without re-running marker existsSync
* probes.
*
* @param startPath - Starting path to search from (file or directory)
* @returns Project root path or null if not found
*/
export function findProjectRoot(startPath: string): string | null {
const cached = projectRootCache.get(startPath);
if (cached !== undefined) {
return cached;
}
const startDir = resolveStartDir(startPath);
const cachedFromStartDir = projectRootCache.get(startDir);
if (cachedFromStartDir !== undefined) {
projectRootCache.set(startPath, cachedFromStartDir);
return cachedFromStartDir;
}
const visited: string[] = [];
let current = startDir;
let resolved: string | null = null;
while (true) {
const cachedAncestor = projectRootCache.get(current);
if (cachedAncestor !== undefined) {
resolved = cachedAncestor;
break;
}
visited.push(current);
if (hasProjectMarker(current)) {
resolved = current;
break;
}
const parent = dirname(current);
if (parent === current) {
resolved = null;
break;
}
current = parent;
}
for (const dir of visited) {
projectRootCache.set(dir, resolved);
}
projectRootCache.set(startPath, resolved);
return resolved;
}
function resolveStartDir(startPath: string): string {
try {
const stat = statSync(startPath);
return stat.isDirectory() ? startPath : dirname(startPath);
} catch {
return dirname(startPath);
}
}
function hasProjectMarker(dir: string): boolean {
for (const marker of PROJECT_MARKERS) {
if (existsSync(join(dir, marker))) {
return true;
}
}
return false;
}
export { clearProjectRootCache, findProjectRoot } from "@oh-my-opencode/rules-core";
+1 -53
View File
@@ -1,53 +1 @@
import { dirname, relative } from "node:path";
/**
* Calculate directory distance between a rule file and current file.
* Distance is based on common ancestor within project root.
*
* @param rulePath - Path to the rule file
* @param currentFile - Path to the current file being edited
* @param projectRoot - Project root for relative path calculation
* @returns Distance (0 = same directory, higher = further)
*/
export function calculateDistance(
rulePath: string,
currentFile: string,
projectRoot: string | null,
): number {
if (!projectRoot) {
return 9999;
}
try {
const ruleDir = dirname(rulePath);
const currentDir = dirname(currentFile);
const ruleRel = relative(projectRoot, ruleDir);
const currentRel = relative(projectRoot, currentDir);
// Handle paths outside project root
if (ruleRel.startsWith("..") || currentRel.startsWith("..")) {
return 9999;
}
// Split by both forward and back slashes for cross-platform compatibility
// path.relative() returns OS-native separators (backslashes on Windows)
const ruleParts = ruleRel ? ruleRel.split(/[/\\]/) : [];
const currentParts = currentRel ? currentRel.split(/[/\\]/) : [];
// Find common prefix length
let common = 0;
for (let i = 0; i < Math.min(ruleParts.length, currentParts.length); i++) {
if (ruleParts[i] === currentParts[i]) {
common++;
} else {
break;
}
}
// Distance is how many directories up from current file to common ancestor
return currentParts.length - common;
} catch {
return 9999;
}
}
export { calculateDistance } from "@oh-my-opencode/rules-core";
+2 -148
View File
@@ -1,148 +1,2 @@
import { existsSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import {
OPENCODE_USER_RULE_DIRS,
PROJECT_RULE_FILES,
PROJECT_RULE_SUBDIRS,
USER_RULE_DIR,
} from "./constants";
import type { DirectoryScanEntry, RuleScanCache } from "./rule-scan-cache";
import { findRuleFilesRecursive, safeRealpathSync } from "./rule-file-scanner";
import type { RuleFileCandidate } from "./types";
export interface FindRuleFilesOptions {
skipClaudeUserRules?: boolean;
}
function scanDirectoryWithCache(
dir: string,
cache: RuleScanCache | undefined,
): DirectoryScanEntry[] {
const cached = cache?.getDirScan(dir);
if (cached) {
return cached;
}
const files: string[] = [];
findRuleFilesRecursive(dir, files);
const entries: DirectoryScanEntry[] = files.map((filePath) => ({
path: filePath,
realPath: safeRealpathSync(filePath),
}));
cache?.setDirScan(dir, entries);
return entries;
}
function getUserRuleDirs(homeDir: string, skipClaudeUserRules: boolean): string[] {
const userRuleDirs = OPENCODE_USER_RULE_DIRS.map((dir) => join(homeDir, dir));
if (!skipClaudeUserRules) {
userRuleDirs.push(join(homeDir, USER_RULE_DIR));
}
return userRuleDirs;
}
function createCacheKey(
projectRoot: string | null,
startDir: string,
skipClaudeUserRules: boolean,
): string {
return `${projectRoot ?? ""}|${startDir}|${skipClaudeUserRules ? "1" : "0"}`;
}
export function findRuleFiles(
projectRoot: string | null,
homeDir: string,
currentFile: string,
options?: FindRuleFilesOptions,
cache?: RuleScanCache,
): RuleFileCandidate[] {
const startDir = dirname(currentFile);
const skipClaudeUserRules = options?.skipClaudeUserRules ?? false;
const userRuleDirs = getUserRuleDirs(homeDir, skipClaudeUserRules);
const cacheKey = createCacheKey(projectRoot, startDir, skipClaudeUserRules);
const cachedCandidates = cache?.get(cacheKey);
if (cachedCandidates) {
return cachedCandidates;
}
const candidates: RuleFileCandidate[] = [];
const seenRealPaths = new Set<string>();
let currentDir = startDir;
let distance = 0;
while (true) {
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
const ruleDir = join(currentDir, parent, subdir);
const entries = scanDirectoryWithCache(ruleDir, cache);
for (const entry of entries) {
if (seenRealPaths.has(entry.realPath)) continue;
seenRealPaths.add(entry.realPath);
candidates.push({
path: entry.path,
realPath: entry.realPath,
isGlobal: false,
distance,
});
}
}
if (projectRoot && currentDir === projectRoot) break;
const parentDir = dirname(currentDir);
if (parentDir === currentDir) break;
currentDir = parentDir;
distance += 1;
}
if (projectRoot) {
for (const ruleFile of PROJECT_RULE_FILES) {
const filePath = join(projectRoot, ruleFile);
if (!existsSync(filePath)) continue;
try {
const stat = statSync(filePath);
if (!stat.isFile()) continue;
const realPath = safeRealpathSync(filePath);
if (seenRealPaths.has(realPath)) continue;
seenRealPaths.add(realPath);
candidates.push({
path: filePath,
realPath,
isGlobal: false,
distance: 0,
isSingleFile: true,
});
} catch {
continue;
}
}
}
for (const userRuleDir of userRuleDirs) {
const entries = scanDirectoryWithCache(userRuleDir, cache);
for (const entry of entries) {
if (seenRealPaths.has(entry.realPath)) continue;
seenRealPaths.add(entry.realPath);
candidates.push({
path: entry.path,
realPath: entry.realPath,
isGlobal: true,
distance: 9999,
});
}
}
candidates.sort((left, right) => {
if (left.isGlobal !== right.isGlobal) {
return left.isGlobal ? 1 : -1;
}
return left.distance - right.distance;
});
cache?.set(cacheKey, candidates);
return candidates;
}
export { findRuleFiles } from "@oh-my-opencode/rules-core";
export type { FindRuleFilesOptions } from "@oh-my-opencode/rules-core";
+6 -53
View File
@@ -1,57 +1,10 @@
import { existsSync, readdirSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { EXCLUDED_DIRS } from "../../shared";
import { GITHUB_INSTRUCTIONS_PATTERN, RULE_EXTENSIONS } from "./constants";
import { findRuleFilesRecursive as findRuleFileEntriesRecursive, safeRealpathSync } from "@oh-my-opencode/rules-core";
import type { DirectoryScanEntry } from "@oh-my-opencode/rules-core";
function isGitHubInstructionsDir(dir: string): boolean {
return dir.includes(".github/instructions") || dir.endsWith(".github/instructions");
}
export { safeRealpathSync };
function isValidRuleFile(fileName: string, dir: string): boolean {
if (isGitHubInstructionsDir(dir)) {
return GITHUB_INSTRUCTIONS_PATTERN.test(fileName);
}
return RULE_EXTENSIONS.some((ext) => fileName.endsWith(ext));
}
/**
* Recursively find all rule files (*.md, *.mdc) in a directory
*
* @param dir - Directory to search
* @param results - Array to accumulate results
*/
export function findRuleFilesRecursive(dir: string, results: string[]): void {
if (!existsSync(dir)) return;
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
if (EXCLUDED_DIRS.has(entry.name)) continue;
findRuleFilesRecursive(fullPath, results);
} else if (entry.isFile()) {
if (isValidRuleFile(entry.name, dir)) {
results.push(fullPath);
}
}
}
} catch {
// Permission denied or other errors - silently skip
}
}
/**
* Resolve symlinks safely with fallback to original path
*
* @param filePath - Path to resolve
* @returns Real path or original path if resolution fails
*/
export function safeRealpathSync(filePath: string): string {
try {
return realpathSync(filePath);
} catch {
return filePath;
}
const entries: DirectoryScanEntry[] = [];
findRuleFileEntriesRecursive(dir, entries);
results.push(...entries.map((entry) => entry.path));
}
+2 -38
View File
@@ -1,38 +1,2 @@
import type { RuleFileCandidate } from "./types";
export type DirectoryScanEntry = {
path: string;
realPath: string;
};
export type RuleScanCache = {
get: (key: string) => RuleFileCandidate[] | undefined;
set: (key: string, value: RuleFileCandidate[]) => void;
getDirScan: (dir: string) => DirectoryScanEntry[] | undefined;
setDirScan: (dir: string, entries: DirectoryScanEntry[]) => void;
clear: () => void;
};
export function createRuleScanCache(): RuleScanCache {
const finalResultCache = new Map<string, RuleFileCandidate[]>();
const directoryScanCache = new Map<string, DirectoryScanEntry[]>();
return {
get(key: string): RuleFileCandidate[] | undefined {
return finalResultCache.get(key);
},
set(key: string, value: RuleFileCandidate[]): void {
finalResultCache.set(key, value);
},
getDirScan(dir: string): DirectoryScanEntry[] | undefined {
return directoryScanCache.get(dir);
},
setDirScan(dir: string, entries: DirectoryScanEntry[]): void {
directoryScanCache.set(dir, entries);
},
clear(): void {
finalResultCache.clear();
directoryScanCache.clear();
},
};
}
export { createRuleScanCache } from "@oh-my-opencode/rules-core";
export type { DirectoryScanEntry, RuleScanCache } from "@oh-my-opencode/rules-core";
+3 -39
View File
@@ -1,57 +1,21 @@
/**
* Rule file metadata (Claude Code style frontmatter)
* Supports both Claude Code format (globs, paths) and GitHub Copilot format (applyTo)
* @see https://docs.anthropic.com/en/docs/claude-code/settings#rule-files
* @see https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot
*/
export interface RuleMetadata {
description?: string;
globs?: string | string[];
alwaysApply?: boolean;
}
import type { RuleFileCandidate, RuleMetadata } from "@oh-my-opencode/rules-core";
export type { RuleFileCandidate, RuleMetadata };
/**
* Rule information with path context and content
*/
export interface RuleInfo {
/** Absolute path to the rule file */
path: string;
/** Path relative to project root */
relativePath: string;
/** Directory distance from target file (0 = same dir) */
distance: number;
/** Rule file content (without frontmatter) */
content: string;
/** SHA-256 hash of content for deduplication */
contentHash: string;
/** Parsed frontmatter metadata */
metadata: RuleMetadata;
/** Why this rule matched (e.g., "alwaysApply", "glob: *.ts", "path match") */
matchReason: string;
/** Real path after symlink resolution (for duplicate detection) */
realPath: string;
}
/**
* Rule file candidate with discovery context
*/
export interface RuleFileCandidate {
path: string;
realPath: string;
isGlobal: boolean;
distance: number;
/** Single-file rules (e.g., .github/copilot-instructions.md) always apply without frontmatter */
isSingleFile?: boolean;
}
/**
* Session storage for injected rules tracking
*/
export interface InjectedRulesData {
sessionID: string;
/** Content hashes of already injected rules */
injectedHashes: string[];
/** Real paths of already injected rules (for symlink deduplication) */
injectedRealPaths: string[];
updatedAt: number;
}
+7 -5
View File
@@ -1,10 +1,10 @@
# src/mcp/ — 4 Built-in MCPs
# src/mcp/ — 5 Built-in MCPs
**Generated:** 2026-05-18
## OVERVIEW
Tier 1 of the three-tier MCP system. Built-ins are created by `createBuiltinMcps(disabledMcps, config)` and now include both remote MCPs and one local stdio MCP.
Tier 1 of the three-tier MCP system. Built-ins are created by `createBuiltinMcps(disabledMcps, config, options)` and now include both remote MCPs and local stdio MCPs.
## BUILT-IN MCPs
@@ -14,19 +14,20 @@ Tier 1 of the three-tier MCP system. Built-ins are created by `createBuiltinMcps
| **context7** | remote | `mcp.context7.com/mcp` | `CONTEXT7_API_KEY` (optional) | Library documentation |
| **grep_app** | remote | `mcp.grep.app` | None | GitHub code search |
| **lsp** | local (stdio, node/bun) | `node packages/lsp-tools-mcp/dist/cli.js mcp` or `bun packages/lsp-tools-mcp/src/cli.ts mcp` | `LSP_TOOLS_MCP_PROJECT_CONFIG=.opencode/lsp.json` | `status`, diagnostics, goto definition, references, symbols, prepare_rename, rename |
| **ast_grep** | local (stdio, node/bun) | `node packages/ast-grep-mcp/dist/cli.js mcp` or `bun packages/ast-grep-mcp/src/cli.ts mcp` | `OMO_AST_GREP_WORKSPACE=<project>` | `search`, `replace` |
## SUBMODULE ARCHITECTURE
- The local `lsp` MCP is a git submodule at `packages/lsp-tools-mcp/`.
- Upstream project: https://github.com/code-yeongyu/lsp-tools-mcp
- OMO resolves the CLI path dynamically in `src/mcp/lsp.ts` so both `src/` and `dist/` runtime layouts work.
- `lsp` is registered whenever it is not listed in `disabled_mcps`, even if the CLI artifact has not been built yet. Source checkouts fall back to the Bun source CLI; packaged builds prefer the Node dist CLI.
- `lsp` and `ast_grep` are registered whenever they are not listed in `disabled_mcps`, even if their CLI artifacts have not been built yet. Source checkouts fall back to the Bun source CLI; packaged builds prefer the Node dist CLI.
## THREE-TIER SYSTEM
| Tier | Source | Mechanism |
|------|--------|-----------|
| 1. Built-in | `src/mcp/` | 3 remote HTTP MCPs + 1 local stdio MCP (`lsp`) via `createBuiltinMcps()` |
| 1. Built-in | `src/mcp/` | 3 remote HTTP MCPs + 2 local stdio MCPs (`lsp`, `ast_grep`) via `createBuiltinMcps()` |
| 2. Claude Code | `.mcp.json` | `${VAR}` expansion via `claude-code-mcp-loader` |
| 3. Skill-embedded | SKILL.md YAML | Managed by `SkillMcpManager` (stdio + HTTP) |
@@ -35,8 +36,9 @@ Tier 1 of the three-tier MCP system. Built-ins are created by `createBuiltinMcps
| File | Purpose |
|------|---------|
| `index.ts` | `createBuiltinMcps()` registry for built-in MCPs |
| `types.ts` | `McpNameSchema`: `"websearch" \| "context7" \| "grep_app" \| "lsp"` |
| `types.ts` | `McpNameSchema`: `"websearch" \| "context7" \| "grep_app" \| "lsp" \| "ast_grep"` |
| `websearch.ts` | Exa/Tavily provider with config |
| `context7.ts` | Context7 with optional auth header |
| `grep-app.ts` | Grep.app (no auth) |
| `lsp.ts` | Local stdio MCP config for packaged `lsp-tools-mcp` |
| `ast-grep.ts` | Local stdio MCP config for packaged `ast-grep-mcp` |
+119
View File
@@ -0,0 +1,119 @@
import { afterEach, describe, expect, it } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { pathToFileURL } from "node:url"
import { createAstGrepMcpConfig } from "./ast-grep"
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("createAstGrepMcpConfig", () => {
it("resolves bundled dist cli from module root when cwd is unrelated", () => {
// given
const packageRoot = createTemporaryDirectory("omo-ast-grep-package-root-")
const unrelatedCwd = createTemporaryDirectory("omo-ast-grep-unrelated-cwd-")
const moduleFilePath = join(packageRoot, "dist", "index.js")
const cliPath = join(packageRoot, "packages", "ast-grep-mcp", "dist", "cli.js")
mkdirSync(join(packageRoot, "dist"), { recursive: true })
mkdirSync(join(packageRoot, "packages", "ast-grep-mcp", "dist"), { recursive: true })
writeFileSync(cliPath, "#!/usr/bin/env node\n", "utf-8")
// when
const config = createAstGrepMcpConfig({
cwd: unrelatedCwd,
moduleUrl: pathToFileURL(moduleFilePath).href,
})
// then
expect(config.command).toEqual(["node", cliPath, "mcp"])
expect(config.environment?.OMO_AST_GREP_WORKSPACE).toBe(unrelatedCwd)
})
it("falls back to bun source cli for source checkouts before build", () => {
// given
const packageRoot = createTemporaryDirectory("omo-ast-grep-source-root-")
const moduleFilePath = join(packageRoot, "src", "mcp", "ast-grep.ts")
const sourceCliPath = join(packageRoot, "packages", "ast-grep-mcp", "src", "cli.ts")
mkdirSync(join(packageRoot, "src", "mcp"), { recursive: true })
mkdirSync(join(packageRoot, "packages", "ast-grep-mcp", "src"), { recursive: true })
writeFileSync(sourceCliPath, "console.log('mcp')\n", "utf-8")
// when
const config = createAstGrepMcpConfig({
cwd: createTemporaryDirectory("omo-ast-grep-source-cwd-"),
moduleUrl: pathToFileURL(moduleFilePath).href,
})
// then
expect(config.command).toEqual(["bun", sourceCliPath, "mcp"])
})
it("still returns a built-in MCP config when the cli has not been built yet", () => {
// given
const packageRoot = createTemporaryDirectory("omo-ast-grep-missing-root-")
const moduleFilePath = join(packageRoot, "dist", "index.js")
mkdirSync(join(packageRoot, "dist"), { recursive: true })
// when
const config = createAstGrepMcpConfig({
cwd: createTemporaryDirectory("omo-ast-grep-missing-cwd-"),
moduleUrl: pathToFileURL(moduleFilePath).href,
})
// then
expect(config.enabled).toBe(true)
expect(config.command[0]).toBe("node")
expect(config.command[1]).toContain(join("packages", "ast-grep-mcp", "dist", "cli.js"))
expect(config.command[2]).toBe("mcp")
})
it("does not resolve the MCP command from the opened workspace", () => {
// given
const packageRoot = createTemporaryDirectory("omo-ast-grep-safe-package-root-")
const workspaceRoot = createTemporaryDirectory("omo-ast-grep-malicious-workspace-")
const moduleFilePath = join(packageRoot, "dist", "index.js")
const workspaceCliPath = join(workspaceRoot, "packages", "ast-grep-mcp", "dist", "cli.js")
mkdirSync(join(packageRoot, "dist"), { recursive: true })
mkdirSync(join(workspaceRoot, "packages", "ast-grep-mcp", "dist"), { recursive: true })
writeFileSync(workspaceCliPath, "console.log('malicious')\n", "utf-8")
// when
const config = createAstGrepMcpConfig({
cwd: workspaceRoot,
moduleUrl: pathToFileURL(moduleFilePath).href,
})
// then
expect(config.command[1]).not.toBe(workspaceCliPath)
expect(config.command[1]).toContain(packageRoot)
})
it("maps disabled ast-grep tool names to MCP subtools", () => {
// given
const packageRoot = createTemporaryDirectory("omo-ast-grep-disabled-root-")
const moduleFilePath = join(packageRoot, "dist", "index.js")
mkdirSync(join(packageRoot, "dist"), { recursive: true })
// when
const config = createAstGrepMcpConfig({
cwd: createTemporaryDirectory("omo-ast-grep-disabled-cwd-"),
disabledTools: ["ast_grep_replace", "glob"],
moduleUrl: pathToFileURL(moduleFilePath).href,
})
// then
expect(config.environment?.OMO_AST_GREP_DISABLED_TOOLS).toBe("replace")
})
})
+97
View File
@@ -0,0 +1,97 @@
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { LocalMcpConfig } from "./lsp";
const PACKAGE_REL = "packages/ast-grep-mcp";
const DIST_CLI_REL = "dist/cli.js";
const SOURCE_CLI_REL = "src/cli.ts";
const WORKSPACE_ENV = "OMO_AST_GREP_WORKSPACE";
const DISABLED_TOOLS_ENV = "OMO_AST_GREP_DISABLED_TOOLS";
const MCP_TOOL_BY_OPENCODE_TOOL: Readonly<Record<string, string>> = {
ast_grep_search: "search",
ast_grep_replace: "replace",
};
type AstGrepMcpConfigOptions = {
readonly cwd?: string;
readonly disabledTools?: readonly string[];
readonly moduleUrl?: string;
readonly exists?: (path: string) => boolean;
};
type CommandCandidate = {
readonly command: string[];
readonly path: string;
readonly exists: boolean;
};
function addAncestorCommandCandidates(
startDirectory: string,
target: CommandCandidate[],
seenPaths: Set<string>,
pathExists: (path: string) => boolean,
): void {
let currentDirectory = resolve(startDirectory);
while (true) {
const distCliPath = resolve(currentDirectory, PACKAGE_REL, DIST_CLI_REL);
if (!seenPaths.has(distCliPath)) {
seenPaths.add(distCliPath);
target.push({ command: ["node", distCliPath, "mcp"], path: distCliPath, exists: pathExists(distCliPath) });
}
const sourceCliPath = resolve(currentDirectory, PACKAGE_REL, SOURCE_CLI_REL);
if (!seenPaths.has(sourceCliPath)) {
seenPaths.add(sourceCliPath);
target.push({ command: ["bun", sourceCliPath, "mcp"], path: sourceCliPath, exists: pathExists(sourceCliPath) });
}
const parentDirectory = resolve(currentDirectory, "..");
if (parentDirectory === currentDirectory) return;
currentDirectory = parentDirectory;
}
}
function getModuleDirectory(moduleUrl: string): string | null {
try {
return dirname(fileURLToPath(moduleUrl));
} catch {
return null;
}
}
function resolveAstGrepCommand(options: AstGrepMcpConfigOptions = {}): string[] {
const pathExists = options.exists ?? existsSync;
const candidates: CommandCandidate[] = [];
const seenPaths = new Set<string>();
const moduleDirectory = getModuleDirectory(options.moduleUrl ?? import.meta.url);
if (moduleDirectory) addAncestorCommandCandidates(moduleDirectory, candidates, seenPaths, pathExists);
const distCandidate = candidates.find((candidate) => candidate.path.endsWith(DIST_CLI_REL) && candidate.exists);
if (distCandidate) return distCandidate.command;
const sourceCandidate = candidates.find((candidate) => candidate.path.endsWith(SOURCE_CLI_REL) && candidate.exists);
if (sourceCandidate) return sourceCandidate.command;
return candidates[0]?.command ?? ["node", resolve(PACKAGE_REL, DIST_CLI_REL), "mcp"];
}
function astGrepDisabledTools(disabledTools: readonly string[] | undefined): string {
if (!disabledTools) return "";
return disabledTools
.map((toolName) => MCP_TOOL_BY_OPENCODE_TOOL[toolName])
.filter((toolName): toolName is string => typeof toolName === "string")
.join(",");
}
export function createAstGrepMcpConfig(options: AstGrepMcpConfigOptions = {}): LocalMcpConfig {
const workspaceDirectory = options.cwd ?? process.cwd();
return {
type: "local",
command: resolveAstGrepCommand(options),
enabled: true,
environment: {
[WORKSPACE_ENV]: workspaceDirectory,
[DISABLED_TOOLS_ENV]: astGrepDisabledTools(options.disabledTools),
},
};
}
+10 -1
View File
@@ -1,6 +1,7 @@
import { createWebsearchConfig } from "./websearch"
import { context7 } from "./context7"
import { grep_app } from "./grep-app"
import { createAstGrepMcpConfig } from "./ast-grep"
import { createLspMcpConfig, type LocalMcpConfig } from "./lsp"
import type { OhMyOpenCodeConfig } from "../config/schema"
@@ -16,7 +17,11 @@ type RemoteMcpConfig = {
type BuiltinMcpConfig = RemoteMcpConfig | LocalMcpConfig
export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpenCodeConfig) {
type BuiltinMcpOptions = {
readonly cwd?: string
}
export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpenCodeConfig, options: BuiltinMcpOptions = {}) {
const mcps: Record<string, BuiltinMcpConfig> = {}
if (!disabledMcps.includes("websearch")) {
@@ -38,5 +43,9 @@ export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpen
mcps.lsp = createLspMcpConfig()
}
if (!disabledMcps.includes("ast_grep")) {
mcps.ast_grep = createAstGrepMcpConfig({ cwd: options.cwd, disabledTools: config?.disabled_tools })
}
return mcps
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { z } from "zod"
export const McpNameSchema = z.enum(["websearch", "context7", "grep_app", "lsp"])
export const McpNameSchema = z.enum(["websearch", "context7", "grep_app", "lsp", "ast_grep"])
export type McpName = z.infer<typeof McpNameSchema>
+18 -12
View File
@@ -4,12 +4,19 @@ afterEach(() => {
mock.restore()
})
function mockLocalMcps(): void {
mock.module("../lsp", () => ({
createLspMcpConfig: () => ({ type: "local", command: ["node", "dist/cli.js", "mcp"], enabled: true }),
}))
mock.module("../ast-grep", () => ({
createAstGrepMcpConfig: () => ({ type: "local", command: ["node", "ast-grep-mcp", "mcp"], enabled: true }),
}))
}
describe("createBuiltinMcps", () => {
test("should return all MCPs when disabled_mcps is empty", () => {
// given
mock.module("../lsp", () => ({
createLspMcpConfig: () => ({ type: "local", command: ["node", "dist/cli.js", "mcp"], enabled: true }),
}))
mockLocalMcps()
const { createBuiltinMcps } = require("../index") as typeof import("../index")
const disabledMcps: string[] = []
@@ -22,13 +29,12 @@ describe("createBuiltinMcps", () => {
expect(result.context7).toBeDefined()
expect(result.grep_app).toBeDefined()
expect(result.lsp).toBeDefined()
expect(result.ast_grep).toBeDefined()
})
test("should filter out disabled MCPs", () => {
// given
mock.module("../lsp", () => ({
createLspMcpConfig: () => ({ type: "local", command: ["node", "dist/cli.js", "mcp"], enabled: true }),
}))
mockLocalMcps()
const { createBuiltinMcps } = require("../index") as typeof import("../index")
const disabledMcps = ["websearch"]
@@ -40,6 +46,7 @@ describe("createBuiltinMcps", () => {
expect(result.context7).toBeDefined()
expect(result.grep_app).toBeDefined()
expect(result.lsp).toBeDefined()
expect(result.ast_grep).toBeDefined()
})
test("should keep lsp when it uses a bootstrap command", () => {
@@ -57,22 +64,21 @@ describe("createBuiltinMcps", () => {
})
test("should return empty array when all MCPs are disabled", () => {
// given - disable all known MCPs
mock.module("../lsp", () => ({
createLspMcpConfig: () => ({ type: "local", command: ["node", "dist/cli.js", "mcp"], enabled: true }),
}))
// given
mockLocalMcps()
const { createBuiltinMcps } = require("../index") as typeof import("../index")
const disabledMcps = ["websearch", "context7", "grep_app", "lsp"]
const disabledMcps = ["websearch", "context7", "grep_app", "lsp", "ast_grep"]
// when
const result = createBuiltinMcps(disabledMcps)
// then - may still have MCPs we didn't list
// then
const remainingMcpNames = Object.keys(result)
expect(remainingMcpNames).not.toContain("websearch")
expect(remainingMcpNames).not.toContain("context7")
expect(remainingMcpNames).not.toContain("grep_app")
expect(remainingMcpNames).not.toContain("lsp")
expect(remainingMcpNames).not.toContain("ast_grep")
expect(remainingMcpNames).toEqual([])
})
})
+1 -1
View File
@@ -38,7 +38,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
});
applyToolConfig({ config, pluginConfig, agentResult });
await applyMcpConfig({ config, pluginConfig, pluginComponents });
await applyMcpConfig({ config, pluginConfig, ctx, pluginComponents });
await applyCommandConfig({ config, pluginConfig, ctx, pluginComponents });
config.formatter = formatterConfig;
@@ -46,6 +46,8 @@ const EMPTY_PLUGIN_COMPONENTS = {
errors: [],
}
const TEST_CTX = { directory: "/workspace/project" }
async function importFreshMcpConfigHandlerModule(): Promise<typeof import("./mcp-config-handler")> {
return import(`./mcp-config-handler?test=${Date.now()}-${Math.random()}`)
}
@@ -69,7 +71,7 @@ describe("applyMcpConfig collision handling", () => {
//#when
const { applyMcpConfig } = await importFreshMcpConfigHandlerModule()
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
//#then
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
@@ -98,7 +100,7 @@ describe("applyMcpConfig collision handling", () => {
//#when
const { applyMcpConfig } = await importFreshMcpConfigHandlerModule()
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
//#then
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
@@ -126,7 +128,7 @@ describe("applyMcpConfig collision handling", () => {
//#when
const { applyMcpConfig } = await importFreshMcpConfigHandlerModule()
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
//#then
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
+20 -3
View File
@@ -42,6 +42,8 @@ const EMPTY_PLUGIN_COMPONENTS = {
errors: [],
}
const TEST_CTX = { directory: "/workspace/project" }
describe("applyMcpConfig", () => {
test("preserves enabled:false from user config after merge with .mcp.json MCPs", async () => {
//#given
@@ -62,7 +64,7 @@ describe("applyMcpConfig", () => {
//#when
const { applyMcpConfig } = await import("./mcp-config-handler")
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
//#then
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
@@ -89,6 +91,7 @@ describe("applyMcpConfig", () => {
const { applyMcpConfig } = await import("./mcp-config-handler")
await applyMcpConfig({
config,
ctx: TEST_CTX,
pluginConfig,
pluginComponents: {
...EMPTY_PLUGIN_COMPONENTS,
@@ -112,7 +115,7 @@ describe("applyMcpConfig", () => {
//#when
const { applyMcpConfig } = await import("./mcp-config-handler")
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
//#then
expect(loadMcpConfigsSpy).toHaveBeenCalledWith(["firecrawl", "exa"])
@@ -135,7 +138,7 @@ describe("applyMcpConfig", () => {
//#when
const { applyMcpConfig } = await import("./mcp-config-handler")
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
//#then
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
@@ -152,6 +155,7 @@ describe("applyMcpConfig", () => {
const { applyMcpConfig } = await import("./mcp-config-handler")
await applyMcpConfig({
config,
ctx: TEST_CTX,
pluginConfig,
pluginComponents: {
...EMPTY_PLUGIN_COMPONENTS,
@@ -166,4 +170,17 @@ describe("applyMcpConfig", () => {
expect(mergedMcp).not.toHaveProperty("plugin:custom")
})
test("passes the OpenCode workspace directory into built-in MCP config", async () => {
//#given
const config: Record<string, unknown> = { mcp: {} }
const pluginConfig = createPluginConfig()
//#when
const { applyMcpConfig } = await import("./mcp-config-handler")
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
//#then
expect(createBuiltinMcpsSpy).toHaveBeenCalledWith([], pluginConfig, { cwd: TEST_CTX.directory })
})
})
+2 -1
View File
@@ -27,6 +27,7 @@ function captureUserDisabledMcps(
export async function applyMcpConfig(params: {
config: Record<string, unknown>;
ctx: { directory: string };
pluginConfig: OhMyOpenCodeConfig;
pluginComponents: PluginComponents;
}): Promise<void> {
@@ -47,7 +48,7 @@ export async function applyMcpConfig(params: {
}
const merged = {
...createBuiltinMcps(disabledMcps, params.pluginConfig),
...createBuiltinMcps(disabledMcps, params.pluginConfig, { cwd: params.ctx.directory }),
...mcpResult.servers,
...(userMcp ?? {}),
...params.pluginComponents.mcpServers,
+1 -2
View File
@@ -62,7 +62,6 @@ const interactiveBashTool = interactiveBashEnabled ? { interactive_bash } : {}
const allTools = {
...createGrepTools(ctx),
...createGlobTools(ctx),
...createAstGrepTools(ctx),
...createSessionManagerTools(ctx),
...backgroundTools, // 2 background_*
call_omo_agent, task,
@@ -74,7 +73,7 @@ const allTools = {
...hashlineToolsRecord, // +1 conditional
}
// lsp_* tools are now supplied by built-in MCP server "lsp"
// lsp_* and ast_grep_* tools are supplied by built-in MCP servers "lsp" and "ast_grep"
```
## KEY PATTERNS
@@ -21,7 +21,7 @@ function getNestedRecord(record: Record<string, unknown>, key: string): Record<s
async function loadSeparateHostZodModule(): Promise<typeof import("zod")> {
const pluginPackageDirectory = dirname(Bun.resolveSync("@opencode-ai/plugin/package.json", import.meta.dir))
const sourceZodDirectory = join(pluginPackageDirectory, "node_modules", "zod")
const sourceZodDirectory = dirname(Bun.resolveSync("zod/package.json", pluginPackageDirectory))
const tempDirectory = mkdtempSync(join(tmpdir(), "omo-host-zod-"))
const copiedZodDirectory = join(tempDirectory, "zod")
@@ -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,
@@ -6,11 +6,6 @@ import ts from "typescript"
const SOURCE_ROOT = path.resolve(import.meta.dir, "..")
const MOCK_MODULE_TOKEN = "mock.module"
const MOCK_MODULE_LIFECYCLE_ALLOWLIST = new Map<string, string>([
// TODO(MOCK-MODULE-AUDIT): add cleanup for ast-grep tool module mocks.
[
path.join(SOURCE_ROOT, "tools", "ast-grep", "tools.test.ts"),
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
],
// TODO(MOCK-MODULE-AUDIT): add cleanup for team mailbox inbox module mocks.
[
path.join(SOURCE_ROOT, "features", "team-mode", "team-mailbox", "inbox.test.ts"),
@@ -195,7 +190,6 @@ describe("mock.module lifecycle hygiene", () => {
if (!contents.includes(MOCK_MODULE_TOKEN)) {
continue
}
const sourceFile = ts.createSourceFile(filePath, contents, ts.ScriptTarget.Latest, true)
if (hasMockModuleCall(sourceFile) && !hasCleanupPattern(sourceFile)) {
offenders.push(relativeSourcePath(filePath))
+5 -6
View File
@@ -1,24 +1,24 @@
# src/tools/ — 1433 Native Tools Across 14 Tool Directories (+ shared utilities)
# src/tools/ — 1231 Native Tools Across 13 Tool Directories (+ shared utilities)
**Generated:** 2026-05-15
## OVERVIEW
Tools registered via [`createToolRegistry()`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/tool-registry.ts) in `src/plugin/`. Native tools are factory-based (`createXXXTool`) except `interactive_bash` (`ToolDefinition`). LSP tools are no longer native `src/tools/` implementations; they are served by Tier-1 built-in MCP `lsp` and keep the same exposed names (`lsp_diagnostics`, `lsp_goto_definition`, etc.).
Tools registered via [`createToolRegistry()`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/tool-registry.ts) in `src/plugin/`. Native tools are factory-based (`createXXXTool`) except `interactive_bash` (`ToolDefinition`). LSP and AST-grep tools are no longer native `src/tools/` implementations; they are served by Tier-1 built-in MCPs `lsp` and `ast_grep` and keep the same exposed names (`lsp_diagnostics`, `ast_grep_search`, etc.).
## TOOL CATALOG
### Always On (14 native tools)
### Always On (12 native tools)
| Group | Tools |
|-------|-------|
| **Search** (4) | `grep`, `glob`, `ast_grep_search`, `ast_grep_replace` |
| **Search** (2) | `grep`, `glob` |
| **Sessions** (4) | `session_list`, `session_read`, `session_search`, `session_info` |
| **Background tasks** (2) | `background_output`, `background_cancel` |
| **Delegation** (2) | `task` (delegate, full skill+category support), `call_omo_agent` (named agent only: explore, librarian) |
| **Skills/MCP** (2) | `skill` (load skill or invoke command), `skill_mcp` (call skill-embedded MCP tool/resource/prompt) |
> LSP tools are now provided by built-in MCP server `lsp` (Tier-1 stdio), backed by `packages/lsp-tools-mcp/`. OpenCode-compatible aliases remain available (`lsp_status`, `lsp_diagnostics`, `lsp_goto_definition`, `lsp_find_references`, `lsp_symbols`, `lsp_prepare_rename`, `lsp_rename`).
> LSP and AST-grep tools are now provided by built-in MCP servers `lsp` and `ast_grep` (Tier-1 stdio), backed by `packages/lsp-tools-mcp/` and `packages/ast-grep-mcp/`. OpenCode-compatible aliases remain available (`lsp_status`, `lsp_diagnostics`, `lsp_goto_definition`, `lsp_find_references`, `lsp_symbols`, `lsp_prepare_rename`, `lsp_rename`, `ast_grep_search`, `ast_grep_replace`).
### Conditional (up to +19 native tools)
@@ -68,7 +68,6 @@ User-defined categories declared in `categories: { ... }` config override and ex
```
tools/
├── ast-grep/ # ast_grep_search, ast_grep_replace
├── background-task/ # background_output, background_cancel (LLM interface; engine in features/background-agent)
├── call-omo-agent/ # call_omo_agent (explore + librarian only)
├── delegate-task/ # task — full delegation with categories + skills
-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.
-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")
})
})
@@ -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)")
})
})
-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 }
}
-1
View File
@@ -1,4 +1,3 @@
export { createAstGrepTools } from "./ast-grep"
export { createGrepTools } from "./grep"
export { createGlobTools } from "./glob"
export { createSkillTool } from "./skill"