diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3f414d1a..68b8792f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/publish-platform.yml b/.github/workflows/publish-platform.yml index 7fcf6d913..5f061ab6b 100644 --- a/.github/workflows/publish-platform.yml +++ b/.github/workflows/publish-platform.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a369822ba..210205b95 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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: | diff --git a/.github/workflows/refresh-model-capabilities.yml b/.github/workflows/refresh-model-capabilities.yml index d21a2d661..0bec53521 100644 --- a/.github/workflows/refresh-model-capabilities.yml +++ b/.github/workflows/refresh-model-capabilities.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index c2978da33..f6dd44e67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/bun.lock b/bun.lock index 6e2e18881..21fadc2fb 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index dd28c4e4f..0a2c33f25 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -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 diff --git a/docs/reference/features.md b/docs/reference/features.md index c0c37d841..6d30ac907 100644 --- a/docs/reference/features.md +++ b/docs/reference/features.md @@ -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 diff --git a/package.json b/package.json index ce58519c3..b09b51457 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/ast-grep-mcp/package.json b/packages/ast-grep-mcp/package.json new file mode 100644 index 000000000..286bf7925 --- /dev/null +++ b/packages/ast-grep-mcp/package.json @@ -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" + } +} diff --git a/packages/ast-grep-mcp/src/bun-spawn-shim.ts b/packages/ast-grep-mcp/src/bun-spawn-shim.ts new file mode 100644 index 000000000..a0183ee9b --- /dev/null +++ b/packages/ast-grep-mcp/src/bun-spawn-shim.ts @@ -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; + readonly stdout: ReadableStream>; + readonly stderr: ReadableStream>; + 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 & { + readonly stdout?: ReadableStream>; + readonly stderr?: ReadableStream>; +}; + +const runtime = globalThis as typeof globalThis & { readonly Bun?: BunSpawnRuntime }; +const IS_BUN = typeof runtime.Bun !== "undefined"; + +function emptyReadableStream(): ReadableStream> { + return new ReadableStream>({ + start(controller) { + controller.close(); + }, + }); +} + +function toReadableStream(stream: NodeJS.ReadableStream | null): ReadableStream> { + if (!stream) return emptyReadableStream(); + return new ReadableStream>({ + 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 { + 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): SpawnedProcess { + let exitCode: number | null = null; + const exited = new Promise((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, + }; +} diff --git a/src/tools/ast-grep/cli-binary-path-resolution.ts b/packages/ast-grep-mcp/src/cli-binary-path-resolution.ts similarity index 84% rename from src/tools/ast-grep/cli-binary-path-resolution.ts rename to packages/ast-grep-mcp/src/cli-binary-path-resolution.ts index 7fbd760ef..6efe84b70 100644 --- a/src/tools/ast-grep/cli-binary-path-resolution.ts +++ b/packages/ast-grep-mcp/src/cli-binary-path-resolution.ts @@ -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 | null = null @@ -23,13 +22,6 @@ export async function getAstGrepPath(): Promise { return syncPath } - const downloadedPath = await ensureAstGrepBinary() - if (downloadedPath) { - resolvedCliPath = downloadedPath - setSgCliPath(downloadedPath) - return downloadedPath - } - return null })() diff --git a/packages/ast-grep-mcp/src/cli.ts b/packages/ast-grep-mcp/src/cli.ts new file mode 100644 index 000000000..397ffdb15 --- /dev/null +++ b/packages/ast-grep-mcp/src/cli.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import { argv, stderr } from "node:process"; +import { runMcpStdioServer } from "./mcp"; + +async function main(): Promise { + 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; +}); diff --git a/packages/ast-grep-mcp/src/constants.ts b/packages/ast-grep-mcp/src/constants.ts new file mode 100644 index 000000000..e535d0fe3 --- /dev/null +++ b/packages/ast-grep-mcp/src/constants.ts @@ -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" diff --git a/packages/ast-grep-mcp/src/index.ts b/packages/ast-grep-mcp/src/index.ts new file mode 100644 index 000000000..6f61a5ff5 --- /dev/null +++ b/packages/ast-grep-mcp/src/index.ts @@ -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"; diff --git a/packages/ast-grep-mcp/src/language-support.ts b/packages/ast-grep-mcp/src/language-support.ts new file mode 100644 index 000000000..52aa996c7 --- /dev/null +++ b/packages/ast-grep-mcp/src/language-support.ts @@ -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 diff --git a/packages/ast-grep-mcp/src/mcp.test.ts b/packages/ast-grep-mcp/src/mcp.test.ts new file mode 100644 index 000000000..e2b51815d --- /dev/null +++ b/packages/ast-grep-mcp/src/mcp.test.ts @@ -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"); + }); +}); diff --git a/packages/ast-grep-mcp/src/mcp.ts b/packages/ast-grep-mcp/src/mcp.ts new file mode 100644 index 000000000..20bb86e87 --- /dev/null +++ b/packages/ast-grep-mcp/src/mcp.ts @@ -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; + readonly serverInfo?: Record; + 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; +} + +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 { + 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 { + 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 { + 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 { + 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 { + if (!isRecord(value)) throw new Error("Tool arguments must be an object"); + return value; +} + +function requireString(input: Record, 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, 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, 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 { + 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, 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, 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function messageFromError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/tools/ast-grep/pattern-hints.ts b/packages/ast-grep-mcp/src/pattern-hints.ts similarity index 100% rename from src/tools/ast-grep/pattern-hints.ts rename to packages/ast-grep-mcp/src/pattern-hints.ts diff --git a/src/tools/ast-grep/process-output-timeout.ts b/packages/ast-grep-mcp/src/process-output-timeout.ts similarity index 100% rename from src/tools/ast-grep/process-output-timeout.ts rename to packages/ast-grep-mcp/src/process-output-timeout.ts diff --git a/src/tools/ast-grep/result-formatter.ts b/packages/ast-grep-mcp/src/result-formatter.ts similarity index 65% rename from src/tools/ast-grep/result-formatter.ts rename to packages/ast-grep-mcp/src/result-formatter.ts index 99eb33caa..b43b451aa 100644 --- a/src/tools/ast-grep/result-formatter.ts +++ b/packages/ast-grep-mcp/src/result-formatter.ts @@ -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\`\`\`` -} diff --git a/src/tools/ast-grep/cli.ts b/packages/ast-grep-mcp/src/runner.ts similarity index 81% rename from src/tools/ast-grep/cli.ts rename to packages/ast-grep-mcp/src/runner.ts index 2d76975f7..462c115b1 100644 --- a/src/tools/ast-grep/cli.ts +++ b/packages/ast-grep-mcp/src/runner.ts @@ -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 { // 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 { 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 { } 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 { 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 { 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") +} diff --git a/src/tools/ast-grep/sg-cli-path.ts b/packages/ast-grep-mcp/src/sg-cli-path.ts similarity index 93% rename from src/tools/ast-grep/sg-cli-path.ts rename to packages/ast-grep-mcp/src/sg-cli-path.ts index 5d7103f57..dc1ec4fe0 100644 --- a/src/tools/ast-grep/sg-cli-path.ts +++ b/packages/ast-grep-mcp/src/sg-cli-path.ts @@ -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") diff --git a/src/tools/ast-grep/sg-compact-json-output.ts b/packages/ast-grep-mcp/src/sg-compact-json-output.ts similarity index 100% rename from src/tools/ast-grep/sg-compact-json-output.ts rename to packages/ast-grep-mcp/src/sg-compact-json-output.ts diff --git a/src/tools/ast-grep/tool-descriptions.ts b/packages/ast-grep-mcp/src/tool-descriptions.ts similarity index 100% rename from src/tools/ast-grep/tool-descriptions.ts rename to packages/ast-grep-mcp/src/tool-descriptions.ts diff --git a/src/tools/ast-grep/types.ts b/packages/ast-grep-mcp/src/types.ts similarity index 54% rename from src/tools/ast-grep/types.ts rename to packages/ast-grep-mcp/src/types.ts index f3d28cf5f..82b558903 100644 --- a/src/tools/ast-grep/types.ts +++ b/packages/ast-grep-mcp/src/types.ts @@ -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[] diff --git a/packages/ast-grep-mcp/src/workspace-paths.ts b/packages/ast-grep-mcp/src/workspace-paths.ts new file mode 100644 index 000000000..b3be6bc6e --- /dev/null +++ b/packages/ast-grep-mcp/src/workspace-paths.ts @@ -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}`); +} diff --git a/packages/ast-grep-mcp/tsconfig.json b/packages/ast-grep-mcp/tsconfig.json new file mode 100644 index 000000000..079c808c1 --- /dev/null +++ b/packages/ast-grep-mcp/tsconfig.json @@ -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/**/*"] +} diff --git a/packages/rules-core/index.d.ts b/packages/rules-core/index.d.ts new file mode 100644 index 000000000..e910bb060 --- /dev/null +++ b/packages/rules-core/index.d.ts @@ -0,0 +1 @@ +export * from "./src/index"; diff --git a/packages/rules-core/package.json b/packages/rules-core/package.json new file mode 100644 index 000000000..ca4f75f3b --- /dev/null +++ b/packages/rules-core/package.json @@ -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" + } +} diff --git a/packages/rules-core/src/agents-md.ts b/packages/rules-core/src/agents-md.ts new file mode 100644 index 000000000..5620f66db --- /dev/null +++ b/packages/rules-core/src/agents-md.ts @@ -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 { + 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)); +} diff --git a/packages/rules-core/src/cache.ts b/packages/rules-core/src/cache.ts new file mode 100644 index 000000000..2ac85ec79 --- /dev/null +++ b/packages/rules-core/src/cache.ts @@ -0,0 +1,26 @@ +import type { AgentsMdCache, DirectoryScanEntry, RuleFileCandidate, RuleScanCache, RuleScanCacheStats } from "./types"; + +export function createRuleScanCache(): RuleScanCache { + const candidateCache = new Map(); + const directoryCache = new Map(); + 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(); + return { + get: (key) => cache.get(key), + set: (key, value) => cache.set(key, value), + clear: () => cache.clear(), + }; +} diff --git a/packages/rules-core/src/constants.ts b/packages/rules-core/src/constants.ts new file mode 100644 index 000000000..aafdb0088 --- /dev/null +++ b/packages/rules-core/src/constants.ts @@ -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 = 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], +]); diff --git a/packages/rules-core/src/distance.ts b/packages/rules-core/src/distance.ts new file mode 100644 index 000000000..3e553ea19 --- /dev/null +++ b/packages/rules-core/src/distance.ts @@ -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); +} diff --git a/packages/rules-core/src/finder.ts b/packages/rules-core/src/finder.ts new file mode 100644 index 000000000..ebf6e7ab5 --- /dev/null +++ b/packages/rules-core/src/finder.ts @@ -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(); + 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, + 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, +): 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, + cache: RuleScanCache | undefined, +): void { + const userRuleDirs: Array = 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("\\", "/"); +} diff --git a/packages/rules-core/src/index.test.ts b/packages/rules-core/src/index.test.ts new file mode 100644 index 000000000..bfb069291 --- /dev/null +++ b/packages/rules-core/src/index.test.ts @@ -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); + }); +}); diff --git a/packages/rules-core/src/index.ts b/packages/rules-core/src/index.ts new file mode 100644 index 000000000..e4f3c247e --- /dev/null +++ b/packages/rules-core/src/index.ts @@ -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"; diff --git a/packages/rules-core/src/matcher.ts b/packages/rules-core/src/matcher.ts new file mode 100644 index 000000000..a2d64eb29 --- /dev/null +++ b/packages/rules-core/src/matcher.ts @@ -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 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): boolean { + return cache.has(realPath); +} + +export function isDuplicateByContentHash(hash: string, cache: ReadonlySet): 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("\\", "/"); +} diff --git a/packages/rules-core/src/ordering.ts b/packages/rules-core/src/ordering.ts new file mode 100644 index 000000000..9ef97ccf7 --- /dev/null +++ b/packages/rules-core/src/ordering.ts @@ -0,0 +1,26 @@ +import { SOURCE_PRIORITY } from "./constants"; +import type { RuleFileCandidate } from "./types"; + +export function sortCandidates(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; +} diff --git a/packages/rules-core/src/parser.ts b/packages/rules-core/src/parser.ts new file mode 100644 index 000000000..a75c9f03a --- /dev/null +++ b/packages/rules-core/src/parser.ts @@ -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; +} diff --git a/packages/rules-core/src/project-root.ts b/packages/rules-core/src/project-root.ts new file mode 100644 index 000000000..4e1341a28 --- /dev/null +++ b/packages/rules-core/src/project-root.ts @@ -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(); + +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))); +} diff --git a/packages/rules-core/src/scanner.ts b/packages/rules-core/src/scanner.ts new file mode 100644 index 000000000..ff1fbce6c --- /dev/null +++ b/packages/rules-core/src/scanner.ts @@ -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()): 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 }); + } + } +} diff --git a/packages/rules-core/src/types.ts b/packages/rules-core/src/types.ts new file mode 100644 index 000000000..4bea6d989 --- /dev/null +++ b/packages/rules-core/src/types.ts @@ -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; +} diff --git a/packages/rules-core/tsconfig.json b/packages/rules-core/tsconfig.json new file mode 100644 index 000000000..7a69d1222 --- /dev/null +++ b/packages/rules-core/tsconfig.json @@ -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/**/*"] +} diff --git a/script/build-binaries.ts b/script/build-binaries.ts index 0e1c69f00..7e3c20e15 100644 --- a/script/build-binaries.ts +++ b/script/build-binaries.ts @@ -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}`); diff --git a/src/AGENTS.md b/src/AGENTS.md index 5f1631628..f96ee65b7 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -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 diff --git a/src/cli/doctor/checks/tools-mcp.ts b/src/cli/doctor/checks/tools-mcp.ts index cd3a1406c..4d9c41b41 100644 --- a/src/cli/doctor/checks/tools-mcp.ts +++ b/src/cli/doctor/checks/tools-mcp.ts @@ -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 diff --git a/src/hooks/directory-agents-injector/finder.ts b/src/hooks/directory-agents-injector/finder.ts index e04cfab74..976f0379a 100644 --- a/src/hooks/directory-agents-injector/finder.ts +++ b/src/hooks/directory-agents-injector/finder.ts @@ -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 { - 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 }); } diff --git a/src/hooks/directory-agents-injector/hook.ts b/src/hooks/directory-agents-injector/hook.ts index 58279fd3d..2dac87234 100644 --- a/src/hooks/directory-agents-injector/hook.ts +++ b/src/hooks/directory-agents-injector/hook.ts @@ -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>(); + 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 | 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(); } } }; diff --git a/src/hooks/directory-agents-injector/injector.ts b/src/hooks/directory-agents-injector/injector.ts index f05dc276f..ce3bf405b 100644 --- a/src/hooks/directory-agents-injector/injector.ts +++ b/src/hooks/directory-agents-injector/injector.ts @@ -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>; + 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) { diff --git a/src/hooks/rules-injector/matcher.ts b/src/hooks/rules-injector/matcher.ts index cd6995f6e..4a395e25f 100644 --- a/src/hooks/rules-injector/matcher.ts +++ b/src/hooks/rules-injector/matcher.ts @@ -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() - -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): 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): boolean { - return cache.has(hash) + readonly entries: number; } diff --git a/src/hooks/rules-injector/parser.ts b/src/hooks/rules-injector/parser.ts index 12d41edae..df7fec523 100644 --- a/src/hooks/rules-injector/parser.ts +++ b/src/hooks/rules-injector/parser.ts @@ -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"; diff --git a/src/hooks/rules-injector/project-root-finder.ts b/src/hooks/rules-injector/project-root-finder.ts index 613083c95..0f2499ec7 100644 --- a/src/hooks/rules-injector/project-root-finder.ts +++ b/src/hooks/rules-injector/project-root-finder.ts @@ -1,85 +1 @@ -import { existsSync, statSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { PROJECT_MARKERS } from "./constants"; - -const projectRootCache = new Map(); - -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"; diff --git a/src/hooks/rules-injector/rule-distance.ts b/src/hooks/rules-injector/rule-distance.ts index 4cee64be3..47b003d12 100644 --- a/src/hooks/rules-injector/rule-distance.ts +++ b/src/hooks/rules-injector/rule-distance.ts @@ -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"; diff --git a/src/hooks/rules-injector/rule-file-finder.ts b/src/hooks/rules-injector/rule-file-finder.ts index c2e60e68d..64c50d937 100644 --- a/src/hooks/rules-injector/rule-file-finder.ts +++ b/src/hooks/rules-injector/rule-file-finder.ts @@ -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(); - 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"; diff --git a/src/hooks/rules-injector/rule-file-scanner.ts b/src/hooks/rules-injector/rule-file-scanner.ts index 2cd853d07..3cefabf3b 100644 --- a/src/hooks/rules-injector/rule-file-scanner.ts +++ b/src/hooks/rules-injector/rule-file-scanner.ts @@ -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)); } diff --git a/src/hooks/rules-injector/rule-scan-cache.ts b/src/hooks/rules-injector/rule-scan-cache.ts index fb69e46d6..4c597e0b2 100644 --- a/src/hooks/rules-injector/rule-scan-cache.ts +++ b/src/hooks/rules-injector/rule-scan-cache.ts @@ -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(); - const directoryScanCache = new Map(); - - 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"; diff --git a/src/hooks/rules-injector/types.ts b/src/hooks/rules-injector/types.ts index 63bf9f680..eab530f3a 100644 --- a/src/hooks/rules-injector/types.ts +++ b/src/hooks/rules-injector/types.ts @@ -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; } diff --git a/src/mcp/AGENTS.md b/src/mcp/AGENTS.md index ef27f15f0..ec928cc7a 100644 --- a/src/mcp/AGENTS.md +++ b/src/mcp/AGENTS.md @@ -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=` | `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` | diff --git a/src/mcp/ast-grep.test.ts b/src/mcp/ast-grep.test.ts new file mode 100644 index 000000000..cfc78de1c --- /dev/null +++ b/src/mcp/ast-grep.test.ts @@ -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") + }) +}) diff --git a/src/mcp/ast-grep.ts b/src/mcp/ast-grep.ts new file mode 100644 index 000000000..70df1b892 --- /dev/null +++ b/src/mcp/ast-grep.ts @@ -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> = { + 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, + 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(); + 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), + }, + }; +} diff --git a/src/mcp/index.ts b/src/mcp/index.ts index ad3f588c1..a3e8f8ada 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -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 = {} 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 } diff --git a/src/mcp/types.ts b/src/mcp/types.ts index f5e7f59a8..c22a8eb4e 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -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 diff --git a/src/mcp/zauc-mocks-mcp-index/index.test.ts b/src/mcp/zauc-mocks-mcp-index/index.test.ts index 079df42ef..a7fa110de 100644 --- a/src/mcp/zauc-mocks-mcp-index/index.test.ts +++ b/src/mcp/zauc-mocks-mcp-index/index.test.ts @@ -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([]) }) }) diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index e75d95ab1..d77920c44 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -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; diff --git a/src/plugin-handlers/mcp-config-handler-collision.test.ts b/src/plugin-handlers/mcp-config-handler-collision.test.ts index c8a85034a..a7b4b6d5e 100644 --- a/src/plugin-handlers/mcp-config-handler-collision.test.ts +++ b/src/plugin-handlers/mcp-config-handler-collision.test.ts @@ -46,6 +46,8 @@ const EMPTY_PLUGIN_COMPONENTS = { errors: [], } +const TEST_CTX = { directory: "/workspace/project" } + async function importFreshMcpConfigHandlerModule(): Promise { 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> @@ -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> @@ -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> diff --git a/src/plugin-handlers/mcp-config-handler.test.ts b/src/plugin-handlers/mcp-config-handler.test.ts index 217ca303e..4d1fade91 100644 --- a/src/plugin-handlers/mcp-config-handler.test.ts +++ b/src/plugin-handlers/mcp-config-handler.test.ts @@ -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> @@ -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> @@ -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 = { 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 }) + }) + }) diff --git a/src/plugin-handlers/mcp-config-handler.ts b/src/plugin-handlers/mcp-config-handler.ts index 474c76870..07506b6a3 100644 --- a/src/plugin-handlers/mcp-config-handler.ts +++ b/src/plugin-handlers/mcp-config-handler.ts @@ -27,6 +27,7 @@ function captureUserDisabledMcps( export async function applyMcpConfig(params: { config: Record; + ctx: { directory: string }; pluginConfig: OhMyOpenCodeConfig; pluginComponents: PluginComponents; }): Promise { @@ -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, diff --git a/src/plugin/AGENTS.md b/src/plugin/AGENTS.md index 31b975e82..e568134bf 100644 --- a/src/plugin/AGENTS.md +++ b/src/plugin/AGENTS.md @@ -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 diff --git a/src/plugin/normalize-tool-arg-schemas.test.ts b/src/plugin/normalize-tool-arg-schemas.test.ts index 8a9247dda..53a340727 100644 --- a/src/plugin/normalize-tool-arg-schemas.test.ts +++ b/src/plugin/normalize-tool-arg-schemas.test.ts @@ -21,7 +21,7 @@ function getNestedRecord(record: Record, key: string): Record { 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") diff --git a/src/plugin/tool-registry.team-mode.test.ts b/src/plugin/tool-registry.team-mode.test.ts index bdf995497..7e3926b85 100644 --- a/src/plugin/tool-registry.team-mode.test.ts +++ b/src/plugin/tool-registry.team-mode.test.ts @@ -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(() => []), diff --git a/src/plugin/tool-registry.test.ts b/src/plugin/tool-registry.test.ts index 03a8ec5a9..5273b70ca 100644 --- a/src/plugin/tool-registry.test.ts +++ b/src/plugin/tool-registry.test.ts @@ -53,7 +53,6 @@ const toolFactories: NonNullable[0]["toolF createSkillTool: mock(() => fakeTool), createGrepTools: mock(() => ({})), createGlobTools: mock(() => ({})), - createAstGrepTools: mock(() => ({})), createSessionManagerTools: mock(() => ({})), createDelegateTask: mock((options: { onSyncSessionCreated?: typeof syncSessionCreatedCallbacks[number] }) => { syncSessionCreatedCallbacks.push(options.onSyncSessionCreated) diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index 2b249acc6..1ad6ced34 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -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 = { ...factories.createGrepTools(ctx), ...factories.createGlobTools(ctx), - ...factories.createAstGrepTools(ctx), ...factories.createSessionManagerTools(ctx), ...backgroundTools, call_omo_agent: callOmoAgent, diff --git a/src/shared/mock-module-lifecycle-audit.test.ts b/src/shared/mock-module-lifecycle-audit.test.ts index 375727a83..3f9e00c76 100644 --- a/src/shared/mock-module-lifecycle-audit.test.ts +++ b/src/shared/mock-module-lifecycle-audit.test.ts @@ -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([ - // 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)) diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 0ddddac90..9d8f3d17f 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -1,24 +1,24 @@ -# src/tools/ — 14–33 Native Tools Across 14 Tool Directories (+ shared utilities) +# src/tools/ — 12–31 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 diff --git a/src/tools/ast-grep/AGENTS.md b/src/tools/ast-grep/AGENTS.md deleted file mode 100644 index 08ef8407c..000000000 --- a/src/tools/ast-grep/AGENTS.md +++ /dev/null @@ -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. diff --git a/src/tools/ast-grep/constants.ts b/src/tools/ast-grep/constants.ts deleted file mode 100644 index 8ceba1733..000000000 --- a/src/tools/ast-grep/constants.ts +++ /dev/null @@ -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" diff --git a/src/tools/ast-grep/downloader.ts b/src/tools/ast-grep/downloader.ts deleted file mode 100644 index 0c7389cf4..000000000 --- a/src/tools/ast-grep/downloader.ts +++ /dev/null @@ -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 = { - "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 { - 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 { - const cachedPath = getCachedBinaryPath() - if (cachedPath) { - return cachedPath - } - - const version = getAstGrepVersion() - return downloadAstGrep(version) -} diff --git a/src/tools/ast-grep/environment-check.ts b/src/tools/ast-grep/environment-check.ts deleted file mode 100644 index d78b7b09d..000000000 --- a/src/tools/ast-grep/environment-check.ts +++ /dev/null @@ -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") -} diff --git a/src/tools/ast-grep/index.ts b/src/tools/ast-grep/index.ts deleted file mode 100644 index 7356d000b..000000000 --- a/src/tools/ast-grep/index.ts +++ /dev/null @@ -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" diff --git a/src/tools/ast-grep/language-support.ts b/src/tools/ast-grep/language-support.ts deleted file mode 100644 index b8abc9e9a..000000000 --- a/src/tools/ast-grep/language-support.ts +++ /dev/null @@ -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 = { - 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"], -} diff --git a/src/tools/ast-grep/pattern-hints.test.ts b/src/tools/ast-grep/pattern-hints.test.ts deleted file mode 100644 index 626a640eb..000000000 --- a/src/tools/ast-grep/pattern-hints.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -/// - -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") - }) -}) diff --git a/src/tools/ast-grep/tool-descriptions.test.ts b/src/tools/ast-grep/tool-descriptions.test.ts deleted file mode 100644 index 4cd3835c0..000000000 --- a/src/tools/ast-grep/tool-descriptions.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -/// - -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)") - }) -}) diff --git a/src/tools/ast-grep/tools.test.ts b/src/tools/ast-grep/tools.test.ts deleted file mode 100644 index b02fa8ea4..000000000 --- a/src/tools/ast-grep/tools.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -/// - -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, - }) - }) -}) diff --git a/src/tools/ast-grep/tools.ts b/src/tools/ast-grep/tools.ts deleted file mode 100644 index 2a2454fd2..000000000 --- a/src/tools/ast-grep/tools.ts +++ /dev/null @@ -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 { - const ctx = context as { - metadata?: (input: { metadata: { output: string } }) => void | Promise - } - await ctx.metadata?.({ metadata: { output } }) -} - -export function createAstGrepTools(ctx: PluginInput): Record { - 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 } -} diff --git a/src/tools/index.ts b/src/tools/index.ts index af9ee0ce7..2732b2f64 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,4 +1,3 @@ -export { createAstGrepTools } from "./ast-grep" export { createGrepTools } from "./grep" export { createGlobTools } from "./glob" export { createSkillTool } from "./skill"