Merge pull request #4155 from code-yeongyu/feature/rules-astgrep-packages-20260518
feat: extract rules core and ast-grep MCP packages
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
|
||||
|
||||
- name: Run tests
|
||||
run: bun test
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
|
||||
|
||||
- name: Type check
|
||||
run: bun run typecheck
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
|
||||
|
||||
- name: Calculate version
|
||||
id: version
|
||||
@@ -232,11 +232,7 @@ jobs:
|
||||
|
||||
- name: Build main package
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
run: |
|
||||
bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi
|
||||
bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi
|
||||
bunx tsc --emitDeclarationOnly
|
||||
bun run build:schema
|
||||
run: bun run build
|
||||
|
||||
- name: Strip token auth from .npmrc to force OIDC
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
@@ -330,7 +326,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
|
||||
|
||||
- name: Generate changelog
|
||||
run: |
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
|
||||
|
||||
- name: Refresh bundled model capabilities snapshot
|
||||
run: bun run build:model-capabilities
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -611,6 +611,8 @@ Hashline IDs use characters from `ZPMQVRWSNKTXJBYH`.
|
||||
|
||||
### AST-Grep Tools
|
||||
|
||||
These user-facing tool names are served by the built-in local `ast_grep` MCP backed by `packages/ast-grep-mcp/`.
|
||||
|
||||
| Tool | Description |
|
||||
| -------------------- | -------------------------------------------- |
|
||||
| **ast_grep_search** | AST-aware code pattern search (25 languages) |
|
||||
@@ -905,7 +907,7 @@ Disable specific hooks in config:
|
||||
|
||||
The plugin uses a three-tier MCP architecture:
|
||||
|
||||
1. Built-in remote MCPs from `src/mcp/`
|
||||
1. Built-in MCPs from `src/mcp/` (remote plus local stdio)
|
||||
2. Claude Code `.mcp.json` loader with `${VAR}` expansion
|
||||
3. Skill-embedded MCP servers declared in `SKILL.md` frontmatter
|
||||
|
||||
@@ -916,6 +918,8 @@ The plugin uses a three-tier MCP architecture:
|
||||
| **websearch** | Real-time web search powered by Exa AI |
|
||||
| **context7** | Official documentation lookup for any library/framework |
|
||||
| **grep_app** | Ultra-fast code search across public GitHub repos. Great for finding implementation examples. |
|
||||
| **lsp** | Local LSP tools for diagnostics, symbols, references, and renames |
|
||||
| **ast_grep** | Local AST-aware search and rewrite tools |
|
||||
|
||||
### Skill-Embedded MCPs
|
||||
|
||||
|
||||
+13
-4
@@ -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",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@oh-my-opencode/ast-grep-mcp",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"bin": {
|
||||
"ast-grep-mcp": "dist/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun build src/cli.ts --outdir dist --target node --format esm",
|
||||
"typecheck": "tsgo --noEmit -p tsconfig.json",
|
||||
"test": "bun test src/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ast-grep/cli": "^0.41.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "1.3.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from "node:child_process";
|
||||
import { Writable } from "node:stream";
|
||||
|
||||
type StdioMode = "pipe" | "inherit" | "ignore";
|
||||
type StdioTuple = [StdioMode, StdioMode, StdioMode];
|
||||
|
||||
export interface SpawnOptions {
|
||||
readonly cmd?: readonly string[];
|
||||
readonly cwd?: string;
|
||||
readonly env?: NodeJS.ProcessEnv;
|
||||
readonly stdin?: StdioMode;
|
||||
readonly stdout?: StdioMode;
|
||||
readonly stderr?: StdioMode;
|
||||
readonly stdio?: StdioTuple;
|
||||
readonly detached?: boolean;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface SpawnedProcess {
|
||||
readonly exitCode: number | null;
|
||||
readonly exited: Promise<number>;
|
||||
readonly stdout: ReadableStream<Uint8Array<ArrayBuffer>>;
|
||||
readonly stderr: ReadableStream<Uint8Array<ArrayBuffer>>;
|
||||
readonly stdin: NodeJS.WritableStream;
|
||||
readonly pid: number | undefined;
|
||||
kill(signal?: NodeJS.Signals): void;
|
||||
ref(): void;
|
||||
unref(): void;
|
||||
}
|
||||
|
||||
export interface SpawnSyncResult {
|
||||
readonly exitCode: number;
|
||||
readonly stdout: Buffer | undefined;
|
||||
readonly stderr: Buffer | undefined;
|
||||
readonly success: boolean;
|
||||
readonly pid: number;
|
||||
}
|
||||
|
||||
type BunSpawnRuntime = {
|
||||
spawn(command: readonly string[], options?: SpawnOptions): BunSpawnedProcess;
|
||||
spawn(options: SpawnOptions & { readonly cmd: readonly string[] }): BunSpawnedProcess;
|
||||
spawnSync(command: readonly string[], options?: SpawnOptions): SpawnSyncResult;
|
||||
spawnSync(options: SpawnOptions & { readonly cmd: readonly string[] }): SpawnSyncResult;
|
||||
};
|
||||
|
||||
type BunSpawnedProcess = Omit<SpawnedProcess, "stdout" | "stderr"> & {
|
||||
readonly stdout?: ReadableStream<Uint8Array<ArrayBuffer>>;
|
||||
readonly stderr?: ReadableStream<Uint8Array<ArrayBuffer>>;
|
||||
};
|
||||
|
||||
const runtime = globalThis as typeof globalThis & { readonly Bun?: BunSpawnRuntime };
|
||||
const IS_BUN = typeof runtime.Bun !== "undefined";
|
||||
|
||||
function emptyReadableStream(): ReadableStream<Uint8Array<ArrayBuffer>> {
|
||||
return new ReadableStream<Uint8Array<ArrayBuffer>>({
|
||||
start(controller) {
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toReadableStream(stream: NodeJS.ReadableStream | null): ReadableStream<Uint8Array<ArrayBuffer>> {
|
||||
if (!stream) return emptyReadableStream();
|
||||
return new ReadableStream<Uint8Array<ArrayBuffer>>({
|
||||
async start(controller) {
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
controller.enqueue(toUint8Array(chunk));
|
||||
}
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toUint8Array(chunk: unknown): Uint8Array<ArrayBuffer> {
|
||||
if (chunk instanceof Uint8Array) return new Uint8Array(chunk);
|
||||
return new TextEncoder().encode(String(chunk));
|
||||
}
|
||||
|
||||
function emptyWritableStream(): Writable {
|
||||
return new Writable({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isOptionsWithCommand(value: unknown): value is SpawnOptions & { readonly cmd: readonly string[] } {
|
||||
return typeof value === "object" && value !== null && "cmd" in value && Array.isArray(value.cmd);
|
||||
}
|
||||
|
||||
function resolveCommand(cmdOrOpts: readonly string[] | (SpawnOptions & { readonly cmd: readonly string[] }), optsArg?: SpawnOptions): { readonly cmd: readonly string[]; readonly opts: SpawnOptions } {
|
||||
if (isOptionsWithCommand(cmdOrOpts)) return { cmd: cmdOrOpts.cmd, opts: cmdOrOpts };
|
||||
return { cmd: cmdOrOpts, opts: optsArg ?? {} };
|
||||
}
|
||||
|
||||
function resolveStdio(options: SpawnOptions): StdioTuple {
|
||||
if (options.stdio) return options.stdio;
|
||||
return [options.stdin ?? "ignore", options.stdout ?? "pipe", options.stderr ?? "inherit"];
|
||||
}
|
||||
|
||||
function wrapNodeProcess(proc: ReturnType<typeof nodeSpawn>): SpawnedProcess {
|
||||
let exitCode: number | null = null;
|
||||
const exited = new Promise<number>((resolve, reject) => {
|
||||
proc.on("exit", (code) => {
|
||||
exitCode = code ?? 1;
|
||||
resolve(exitCode);
|
||||
});
|
||||
proc.on("error", (error) => {
|
||||
if (exitCode === null) {
|
||||
exitCode = 1;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
get exitCode() {
|
||||
return exitCode;
|
||||
},
|
||||
exited,
|
||||
stdout: toReadableStream(proc.stdout),
|
||||
stderr: toReadableStream(proc.stderr),
|
||||
stdin: proc.stdin ?? emptyWritableStream(),
|
||||
pid: proc.pid,
|
||||
kill(signal?: NodeJS.Signals) {
|
||||
if (proc.killed || exitCode !== null) return;
|
||||
proc.kill(signal);
|
||||
},
|
||||
ref() {
|
||||
proc.ref();
|
||||
},
|
||||
unref() {
|
||||
proc.unref();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function wrapBunProcess(proc: BunSpawnedProcess): SpawnedProcess {
|
||||
return {
|
||||
...proc,
|
||||
stdout: proc.stdout ?? emptyReadableStream(),
|
||||
stderr: proc.stderr ?? emptyReadableStream(),
|
||||
};
|
||||
}
|
||||
|
||||
export function spawn(command: readonly string[], options?: SpawnOptions): SpawnedProcess;
|
||||
export function spawn(options: SpawnOptions & { readonly cmd: readonly string[] }): SpawnedProcess;
|
||||
export function spawn(cmdOrOpts: readonly string[] | (SpawnOptions & { readonly cmd: readonly string[] }), opts?: SpawnOptions): SpawnedProcess {
|
||||
const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts);
|
||||
if (IS_BUN) return wrapBunProcess(runtime.Bun.spawn(cmd, options));
|
||||
const [bin, ...args] = cmd;
|
||||
if (!bin) throw new Error("spawn requires a command");
|
||||
return wrapNodeProcess(nodeSpawn(bin, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: resolveStdio(options),
|
||||
detached: options.detached,
|
||||
signal: options.signal,
|
||||
}));
|
||||
}
|
||||
|
||||
export function spawnSync(command: readonly string[], options?: SpawnOptions): SpawnSyncResult;
|
||||
export function spawnSync(options: SpawnOptions & { readonly cmd: readonly string[] }): SpawnSyncResult;
|
||||
export function spawnSync(cmdOrOpts: readonly string[] | (SpawnOptions & { readonly cmd: readonly string[] }), opts?: SpawnOptions): SpawnSyncResult {
|
||||
const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts);
|
||||
if (IS_BUN) return runtime.Bun.spawnSync(cmd, options);
|
||||
const [bin, ...args] = cmd;
|
||||
if (!bin) throw new Error("spawnSync requires a command");
|
||||
const result = nodeSpawnSync(bin, args, { cwd: options.cwd, env: options.env, stdio: resolveStdio(options) });
|
||||
return {
|
||||
exitCode: result.status ?? 1,
|
||||
stdout: result.stdout ?? undefined,
|
||||
stderr: result.stderr ?? undefined,
|
||||
success: (result.status ?? 1) === 0,
|
||||
pid: result.pid ?? -1,
|
||||
};
|
||||
}
|
||||
-8
@@ -1,7 +1,6 @@
|
||||
import { existsSync } from "fs"
|
||||
|
||||
import { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./constants"
|
||||
import { ensureAstGrepBinary } from "./downloader"
|
||||
|
||||
let resolvedCliPath: string | null = null
|
||||
let initPromise: Promise<string | null> | null = null
|
||||
@@ -23,13 +22,6 @@ export async function getAstGrepPath(): Promise<string | null> {
|
||||
return syncPath
|
||||
}
|
||||
|
||||
const downloadedPath = await ensureAstGrepBinary()
|
||||
if (downloadedPath) {
|
||||
resolvedCliPath = downloadedPath
|
||||
setSgCliPath(downloadedPath)
|
||||
return downloadedPath
|
||||
}
|
||||
|
||||
return null
|
||||
})()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
import { argv, stderr } from "node:process";
|
||||
import { runMcpStdioServer } from "./mcp";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [command = "mcp"] = argv.slice(2);
|
||||
if (command === "mcp") {
|
||||
await runMcpStdioServer();
|
||||
return;
|
||||
}
|
||||
stderr.write("Usage: ast-grep-mcp [mcp]\n");
|
||||
process.exitCode = 2;
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CLI_LANGUAGES, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_MAX_MATCHES } from "./language-support"
|
||||
export { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./sg-cli-path"
|
||||
@@ -0,0 +1,4 @@
|
||||
export { handleAstGrepMcpRequest, runMcpStdioServer } from "./mcp";
|
||||
export type { AstGrepMcpOptions, JsonRpcId, JsonRpcResponse, JsonRpcResult, McpToolDescriptor, TextContent } from "./mcp";
|
||||
export { runSg } from "./runner";
|
||||
export type { RunOptions } from "./runner";
|
||||
@@ -0,0 +1,31 @@
|
||||
export const CLI_LANGUAGES = [
|
||||
"bash",
|
||||
"c",
|
||||
"cpp",
|
||||
"csharp",
|
||||
"css",
|
||||
"elixir",
|
||||
"go",
|
||||
"haskell",
|
||||
"html",
|
||||
"java",
|
||||
"javascript",
|
||||
"json",
|
||||
"kotlin",
|
||||
"lua",
|
||||
"nix",
|
||||
"php",
|
||||
"python",
|
||||
"ruby",
|
||||
"rust",
|
||||
"scala",
|
||||
"solidity",
|
||||
"swift",
|
||||
"typescript",
|
||||
"tsx",
|
||||
"yaml",
|
||||
] as const
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 300_000
|
||||
export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024
|
||||
export const DEFAULT_MAX_MATCHES = 500
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { handleAstGrepMcpRequest } from "./mcp";
|
||||
import type { RunOptions } from "./runner";
|
||||
import type { SgResult } from "./types";
|
||||
|
||||
const emptyResult: SgResult = {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
function createTemporaryDirectory(prefix: string): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), prefix));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("ast-grep MCP", () => {
|
||||
it("#given initialize request #when handled #then advertises tools capability", async () => {
|
||||
const response = await handleAstGrepMcpRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: { protocolVersion: "2024-11-05" },
|
||||
});
|
||||
|
||||
expect(response).toEqual({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: {
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: "ast_grep", version: "0.1.0" },
|
||||
protocolVersion: "2024-11-05",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("#given tools list request #when handled #then exposes search and replace tools", async () => {
|
||||
const response = await handleAstGrepMcpRequest({ jsonrpc: "2.0", id: "tools", method: "tools/list" });
|
||||
|
||||
expect(response?.result?.tools?.map((tool) => tool.name)).toEqual(["search", "replace"]);
|
||||
});
|
||||
|
||||
it("#given search call without paths #when handled #then defaults paths to workspace directory", async () => {
|
||||
const captured: { value?: RunOptions } = {};
|
||||
const workspaceDirectory = createTemporaryDirectory("omo-ast-grep-workspace-");
|
||||
const response = await handleAstGrepMcpRequest(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: "search",
|
||||
method: "tools/call",
|
||||
params: { name: "search", arguments: { pattern: "console.log($$$)", lang: "typescript" } },
|
||||
},
|
||||
{
|
||||
workspaceDirectory,
|
||||
runSg: async (options) => {
|
||||
captured.value = options;
|
||||
return emptyResult;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(captured.value).toEqual({ pattern: "console.log($$$)", lang: "typescript", cwd: realpathSync(workspaceDirectory), paths: ["."], globs: undefined, context: undefined });
|
||||
expect(response?.result?.content?.[0]?.text).toContain("No matches found");
|
||||
});
|
||||
|
||||
it("#given replace call without dryRun #when handled #then keeps dry-run default", async () => {
|
||||
const captured: { value?: RunOptions } = {};
|
||||
const workspaceDirectory = createTemporaryDirectory("omo-ast-grep-replace-workspace-");
|
||||
mkdirSync(join(workspaceDirectory, "src"));
|
||||
await handleAstGrepMcpRequest(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: "replace",
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "replace",
|
||||
arguments: { pattern: "console.log($MSG)", rewrite: "logger.info($MSG)", lang: "typescript", paths: ["src"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
workspaceDirectory,
|
||||
runSg: async (options) => {
|
||||
captured.value = options;
|
||||
return emptyResult;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(captured.value).toEqual({
|
||||
pattern: "console.log($MSG)",
|
||||
rewrite: "logger.info($MSG)",
|
||||
lang: "typescript",
|
||||
cwd: realpathSync(workspaceDirectory),
|
||||
paths: ["src"],
|
||||
globs: undefined,
|
||||
updateAll: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("#given disabled replace tool #when listed and called #then hides and rejects it", async () => {
|
||||
const listResponse = await handleAstGrepMcpRequest({ jsonrpc: "2.0", id: "tools", method: "tools/list" }, { disabledTools: ["replace"] });
|
||||
|
||||
expect(listResponse?.result?.tools?.map((tool) => tool.name)).toEqual(["search"]);
|
||||
|
||||
const callResponse = await handleAstGrepMcpRequest(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: "replace",
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "replace",
|
||||
arguments: { pattern: "console.log($MSG)", rewrite: "logger.info($MSG)", lang: "typescript", paths: ["src"] },
|
||||
},
|
||||
},
|
||||
{ disabledTools: ["replace"] },
|
||||
);
|
||||
|
||||
expect(callResponse?.result?.isError).toBe(true);
|
||||
expect(callResponse?.result?.content?.[0]?.text).toContain("ast-grep tool is disabled: replace");
|
||||
});
|
||||
|
||||
it("#given unsafe paths #when search is called #then rejects before running ast-grep", async () => {
|
||||
const workspaceDirectory = createTemporaryDirectory("omo-ast-grep-sandbox-");
|
||||
const outsideDirectory = createTemporaryDirectory("omo-ast-grep-outside-");
|
||||
symlinkSync(outsideDirectory, join(workspaceDirectory, "outside-link"));
|
||||
let didRun = false;
|
||||
|
||||
for (const path of ["../outside", "/tmp", "--update-all", "outside-link"]) {
|
||||
const response = await handleAstGrepMcpRequest(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: path,
|
||||
method: "tools/call",
|
||||
params: { name: "search", arguments: { pattern: "console.log($$$)", lang: "typescript", paths: [path] } },
|
||||
},
|
||||
{
|
||||
workspaceDirectory,
|
||||
runSg: async () => {
|
||||
didRun = true;
|
||||
return emptyResult;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(response?.result?.isError).toBe(true);
|
||||
}
|
||||
|
||||
expect(didRun).toBe(false);
|
||||
});
|
||||
|
||||
it("#given tools list request #when handled #then preserves detailed ast-grep guidance", async () => {
|
||||
const response = await handleAstGrepMcpRequest({ jsonrpc: "2.0", id: "tools", method: "tools/list" });
|
||||
const searchTool = response?.result?.tools?.find((tool) => tool.name === "search");
|
||||
|
||||
expect(searchTool?.description).toContain("This is NOT regex");
|
||||
expect(searchTool?.description).toContain("Meta-variables");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import { CLI_LANGUAGES } from "./constants";
|
||||
import { getPatternHint } from "./pattern-hints";
|
||||
import { formatReplaceResult, formatSearchResult } from "./result-formatter";
|
||||
import { runSg, type RunOptions } from "./runner";
|
||||
import { AST_GREP_REPLACE_DESCRIPTION, AST_GREP_SEARCH_DESCRIPTION, AST_GREP_SEARCH_PATTERN_PARAM } from "./tool-descriptions";
|
||||
import type { CliLanguage, SgResult } from "./types";
|
||||
import { normalizeWorkspaceDirectory, resolveWorkspacePaths } from "./workspace-paths";
|
||||
|
||||
export type JsonRpcId = string | number | null;
|
||||
|
||||
export interface TextContent {
|
||||
readonly type: "text";
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface McpToolDescriptor {
|
||||
readonly name: string;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly inputSchema: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcError {
|
||||
readonly code: number;
|
||||
readonly message: string;
|
||||
readonly data?: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcResult {
|
||||
readonly capabilities?: Record<string, unknown>;
|
||||
readonly serverInfo?: Record<string, unknown>;
|
||||
readonly protocolVersion?: string;
|
||||
readonly tools?: readonly McpToolDescriptor[];
|
||||
readonly content?: readonly TextContent[];
|
||||
readonly isError?: boolean;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
readonly jsonrpc: "2.0";
|
||||
readonly id: JsonRpcId;
|
||||
readonly result?: JsonRpcResult;
|
||||
readonly error?: JsonRpcError;
|
||||
}
|
||||
|
||||
export interface AstGrepMcpOptions {
|
||||
readonly workspaceDirectory?: string;
|
||||
readonly disabledTools?: readonly string[];
|
||||
readonly runSg?: (options: RunOptions) => Promise<SgResult>;
|
||||
}
|
||||
|
||||
type ToolCallResult = {
|
||||
readonly content: readonly TextContent[];
|
||||
readonly isError?: boolean;
|
||||
};
|
||||
|
||||
const SERVER_NAME = "ast_grep";
|
||||
const SERVER_VERSION = "0.1.0";
|
||||
const LANGUAGE_VALUES: readonly string[] = CLI_LANGUAGES;
|
||||
const DISABLED_TOOLS_ENV = "OMO_AST_GREP_DISABLED_TOOLS";
|
||||
|
||||
const AST_GREP_MCP_TOOLS = [
|
||||
{
|
||||
name: "search",
|
||||
title: "AST grep search",
|
||||
description: AST_GREP_SEARCH_DESCRIPTION,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pattern: { type: "string", description: AST_GREP_SEARCH_PATTERN_PARAM },
|
||||
lang: { type: "string", enum: CLI_LANGUAGES, description: "Target language" },
|
||||
paths: { type: "array", items: { type: "string" }, description: "Paths to search" },
|
||||
globs: { type: "array", items: { type: "string" }, description: "Include/exclude globs" },
|
||||
context: { type: "number", description: "Context lines around each match" },
|
||||
},
|
||||
required: ["pattern", "lang"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "replace",
|
||||
title: "AST grep replace",
|
||||
description: AST_GREP_REPLACE_DESCRIPTION,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pattern: { type: "string", description: "AST pattern to match" },
|
||||
rewrite: { type: "string", description: "Replacement pattern" },
|
||||
lang: { type: "string", enum: CLI_LANGUAGES, description: "Target language" },
|
||||
paths: { type: "array", items: { type: "string" }, description: "Paths to search" },
|
||||
globs: { type: "array", items: { type: "string" }, description: "Include/exclude globs" },
|
||||
dryRun: { type: "boolean", description: "Preview changes without applying. Defaults to true." },
|
||||
},
|
||||
required: ["pattern", "rewrite", "lang"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly McpToolDescriptor[];
|
||||
|
||||
export async function handleAstGrepMcpRequest(input: unknown, options: AstGrepMcpOptions = {}): Promise<JsonRpcResponse | undefined> {
|
||||
if (!isRecord(input)) return errorResponse(null, -32600, "Invalid Request");
|
||||
const id = jsonRpcId(input.id);
|
||||
if (input.method === "notifications/initialized") return undefined;
|
||||
if (input.method === "ping") return successResponse(id, {});
|
||||
if (input.method === "initialize") {
|
||||
return successResponse(id, {
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
||||
protocolVersion: requestedProtocolVersion(input.params),
|
||||
});
|
||||
}
|
||||
if (input.method === "tools/list") return successResponse(id, { tools: enabledTools(options) });
|
||||
if (input.method === "tools/call") return handleToolCall(id, input.params, options);
|
||||
return errorResponse(id, -32601, `Method not found: ${String(input.method)}`);
|
||||
}
|
||||
|
||||
export async function runMcpStdioServer(
|
||||
input: NodeJS.ReadableStream = process.stdin,
|
||||
output: NodeJS.WritableStream = process.stdout,
|
||||
options: AstGrepMcpOptions = {},
|
||||
): Promise<void> {
|
||||
const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
|
||||
for await (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (error) {
|
||||
output.write(`${JSON.stringify(errorResponse(null, -32700, "Parse error", messageFromError(error)))}\n`);
|
||||
continue;
|
||||
}
|
||||
const response = await handleAstGrepMcpRequest(parsed, options);
|
||||
if (response) output.write(`${JSON.stringify(response)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToolCall(id: JsonRpcId, params: unknown, options: AstGrepMcpOptions): Promise<JsonRpcResponse> {
|
||||
if (!isRecord(params) || typeof params.name !== "string") return errorResponse(id, -32602, "tools/call requires params.name");
|
||||
try {
|
||||
const result = await executeAstGrepTool(params.name, params.arguments, options);
|
||||
return successResponse(id, { content: result.content, isError: result.isError ?? false });
|
||||
} catch (error) {
|
||||
return successResponse(id, { content: [{ type: "text", text: messageFromError(error) }], isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function executeAstGrepTool(name: string, args: unknown, options: AstGrepMcpOptions): Promise<ToolCallResult> {
|
||||
if (disabledToolNames(options).has(name)) throw new Error(`ast-grep tool is disabled: ${name}`);
|
||||
const runner = options.runSg ?? runSg;
|
||||
const workspaceDirectory = normalizeWorkspaceDirectory(options.workspaceDirectory ?? process.env.OMO_AST_GREP_WORKSPACE ?? process.cwd());
|
||||
if (name === "search") {
|
||||
const input = parseSearchArgs(args, workspaceDirectory);
|
||||
const result = await runner(input);
|
||||
let output = formatSearchResult(result);
|
||||
if (result.matches.length === 0 && !result.error) {
|
||||
const hint = getPatternHint(input.pattern, input.lang);
|
||||
if (hint) output += `\n\n${hint}`;
|
||||
}
|
||||
return { content: [{ type: "text", text: output }], isError: Boolean(result.error) };
|
||||
}
|
||||
if (name === "replace") {
|
||||
const input = parseReplaceArgs(args, workspaceDirectory);
|
||||
const result = await runner(input.options);
|
||||
return { content: [{ type: "text", text: formatReplaceResult(result, input.dryRun) }], isError: Boolean(result.error) };
|
||||
}
|
||||
throw new Error(`Unknown ast-grep tool: ${name}`);
|
||||
}
|
||||
|
||||
function parseSearchArgs(args: unknown, workspaceDirectory: string): RunOptions {
|
||||
const input = requireRecord(args);
|
||||
return {
|
||||
pattern: requireString(input, "pattern"),
|
||||
lang: requireLanguage(input, "lang"),
|
||||
cwd: workspaceDirectory,
|
||||
paths: resolveWorkspacePaths(optionalStringArray(input, "paths"), workspaceDirectory),
|
||||
globs: optionalStringArray(input, "globs"),
|
||||
context: optionalNumber(input, "context"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReplaceArgs(args: unknown, workspaceDirectory: string): { readonly options: RunOptions; readonly dryRun: boolean } {
|
||||
const input = requireRecord(args);
|
||||
const dryRun = optionalBoolean(input, "dryRun") ?? true;
|
||||
return {
|
||||
dryRun,
|
||||
options: {
|
||||
pattern: requireString(input, "pattern"),
|
||||
rewrite: requireString(input, "rewrite"),
|
||||
lang: requireLanguage(input, "lang"),
|
||||
cwd: workspaceDirectory,
|
||||
paths: resolveWorkspacePaths(optionalStringArray(input, "paths"), workspaceDirectory),
|
||||
globs: optionalStringArray(input, "globs"),
|
||||
updateAll: !dryRun,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new Error("Tool arguments must be an object");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireString(input: Record<string, unknown>, key: string): string {
|
||||
const value = input[key];
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} must be a non-empty string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireLanguage(input: Record<string, unknown>, key: string): CliLanguage {
|
||||
const value = requireString(input, key);
|
||||
if (!isCliLanguage(value)) throw new Error(`${key} must be one of: ${LANGUAGE_VALUES.join(", ")}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function isCliLanguage(value: string): value is CliLanguage {
|
||||
return LANGUAGE_VALUES.includes(value);
|
||||
}
|
||||
|
||||
function optionalStringArray(input: Record<string, unknown>, key: string): string[] | undefined {
|
||||
const value = input[key];
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new Error(`${key} must be an array of strings`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function enabledTools(options: AstGrepMcpOptions): readonly McpToolDescriptor[] {
|
||||
const disabled = disabledToolNames(options);
|
||||
return AST_GREP_MCP_TOOLS.filter((tool) => !disabled.has(tool.name));
|
||||
}
|
||||
|
||||
function disabledToolNames(options: AstGrepMcpOptions): ReadonlySet<string> {
|
||||
const fromOptions = options.disabledTools ?? [];
|
||||
const fromEnv = process.env[DISABLED_TOOLS_ENV]?.split(",") ?? [];
|
||||
return new Set([...fromOptions, ...fromEnv].map((tool) => tool.trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
function optionalNumber(input: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = input[key];
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "number") throw new Error(`${key} must be a number`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBoolean(input: Record<string, unknown>, key: string): boolean | undefined {
|
||||
const value = input[key];
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "boolean") throw new Error(`${key} must be a boolean`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function successResponse(id: JsonRpcId, result: JsonRpcResult): JsonRpcResponse {
|
||||
return { jsonrpc: "2.0", id, result };
|
||||
}
|
||||
|
||||
function errorResponse(id: JsonRpcId, code: number, message: string, data?: unknown): JsonRpcResponse {
|
||||
return { jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } };
|
||||
}
|
||||
|
||||
function requestedProtocolVersion(params: unknown): string {
|
||||
if (!isRecord(params) || typeof params.protocolVersion !== "string") return "2024-11-05";
|
||||
return params.protocolVersion;
|
||||
}
|
||||
|
||||
function jsonRpcId(value: unknown): JsonRpcId {
|
||||
return typeof value === "string" || typeof value === "number" || value === null ? value : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function messageFromError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
+1
-33
@@ -1,4 +1,4 @@
|
||||
import type { AnalyzeResult, SgResult } from "./types"
|
||||
import type { SgResult } from "./types"
|
||||
|
||||
export function formatSearchResult(result: SgResult): string {
|
||||
if (result.error) {
|
||||
@@ -68,35 +68,3 @@ export function formatReplaceResult(result: SgResult, isDryRun: boolean): string
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatAnalyzeResult(results: AnalyzeResult[], extractedMetaVars: boolean): string {
|
||||
if (results.length === 0) {
|
||||
return "No matches found"
|
||||
}
|
||||
|
||||
const lines: string[] = [`Found ${results.length} match(es):\n`]
|
||||
|
||||
for (const result of results) {
|
||||
const loc = `L${result.range.start.line + 1}:${result.range.start.column + 1}`
|
||||
lines.push(`[${loc}] (${result.kind})`)
|
||||
lines.push(` ${result.text}`)
|
||||
|
||||
if (extractedMetaVars && result.metaVariables.length > 0) {
|
||||
lines.push(" Meta-variables:")
|
||||
for (const mv of result.metaVariables) {
|
||||
lines.push(` $${mv.name} = "${mv.text}" (${mv.kind})`)
|
||||
}
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatTransformResult(_original: string, transformed: string, editCount: number): string {
|
||||
if (editCount === 0) {
|
||||
return "No matches found to transform"
|
||||
}
|
||||
|
||||
return `Transformed (${editCount} edit(s)):\n\`\`\`\n${transformed}\n\`\`\``
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { spawn } from "../../shared/bun-spawn-shim"
|
||||
import { spawn } from "./bun-spawn-shim"
|
||||
import { existsSync } from "fs"
|
||||
import {
|
||||
getSgCliPath,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
} from "./constants"
|
||||
import { ensureAstGrepBinary } from "./downloader"
|
||||
import type { CliLanguage, SgResult } from "./types"
|
||||
|
||||
import { getAstGrepPath } from "./cli-binary-path-resolution"
|
||||
@@ -21,8 +20,9 @@ export {
|
||||
export interface RunOptions {
|
||||
pattern: string
|
||||
lang: CliLanguage
|
||||
paths?: string[]
|
||||
globs?: string[]
|
||||
cwd?: string
|
||||
paths?: readonly string[]
|
||||
globs?: readonly string[]
|
||||
rewrite?: string
|
||||
context?: number
|
||||
updateAll?: boolean
|
||||
@@ -35,34 +35,14 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
|
||||
// another with --update-all to perform the actual file writes.
|
||||
const shouldSeparateWritePass = !!(options.rewrite && options.updateAll)
|
||||
|
||||
const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
|
||||
|
||||
if (options.rewrite) {
|
||||
args.push("-r", options.rewrite)
|
||||
if (options.updateAll && !shouldSeparateWritePass) {
|
||||
args.push("--update-all")
|
||||
}
|
||||
}
|
||||
|
||||
if (options.context && options.context > 0) {
|
||||
args.push("-C", String(options.context))
|
||||
}
|
||||
|
||||
if (options.globs) {
|
||||
for (const glob of options.globs) {
|
||||
args.push("--globs", glob)
|
||||
}
|
||||
}
|
||||
|
||||
const paths = options.paths && options.paths.length > 0 ? options.paths : ["."]
|
||||
args.push(...paths)
|
||||
const args = createSgArgs(options, { includeJson: true, includeUpdateAll: false })
|
||||
|
||||
let cliPath = getSgCliPath()
|
||||
|
||||
if (!cliPath || !existsSync(cliPath)) {
|
||||
const downloadedPath = await getAstGrepPath()
|
||||
if (downloadedPath) {
|
||||
cliPath = downloadedPath
|
||||
const resolvedPath = await getAstGrepPath()
|
||||
if (resolvedPath) {
|
||||
cliPath = resolvedPath
|
||||
} else {
|
||||
return {
|
||||
matches: [],
|
||||
@@ -81,6 +61,7 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
|
||||
const timeout = DEFAULT_TIMEOUT_MS
|
||||
|
||||
const proc = spawn([cliPath, ...args], {
|
||||
cwd: options.cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
@@ -106,31 +87,23 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const errorCode =
|
||||
typeof error === "object" && error !== null && "code" in error
|
||||
? (error as { code?: unknown }).code
|
||||
: undefined
|
||||
const errorCode = errorCodeFrom(error)
|
||||
const isNoEntry =
|
||||
errorCode === "ENOENT" || errorMessage.includes("ENOENT") || errorMessage.includes("not found")
|
||||
|
||||
if (isNoEntry) {
|
||||
const downloadedPath = await ensureAstGrepBinary()
|
||||
if (downloadedPath) {
|
||||
return runSg(options)
|
||||
} else {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
error:
|
||||
`ast-grep CLI binary not found.\n\n` +
|
||||
`Auto-download failed. Manual install options:\n` +
|
||||
`Install options:\n` +
|
||||
` bun add -D @ast-grep/cli\n` +
|
||||
` cargo install ast-grep --locked\n` +
|
||||
` brew install ast-grep`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matches: [],
|
||||
@@ -153,10 +126,10 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
|
||||
const jsonResult = createSgResultFromStdout(stdout)
|
||||
|
||||
if (shouldSeparateWritePass && jsonResult.matches.length > 0) {
|
||||
const writeArgs = args.filter(a => a !== "--json=compact")
|
||||
writeArgs.push("--update-all")
|
||||
const writeArgs = createSgArgs(options, { includeJson: false, includeUpdateAll: true })
|
||||
|
||||
const writeProc = spawn([cliPath, ...writeArgs], {
|
||||
cwd: options.cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
@@ -175,3 +148,37 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
|
||||
|
||||
return jsonResult
|
||||
}
|
||||
|
||||
function createSgArgs(options: RunOptions, flags: { readonly includeJson: boolean; readonly includeUpdateAll: boolean }): string[] {
|
||||
const args = ["run", "-p", options.pattern, "--lang", options.lang]
|
||||
|
||||
if (flags.includeJson) {
|
||||
args.push("--json=compact")
|
||||
}
|
||||
|
||||
if (options.rewrite) {
|
||||
args.push("-r", options.rewrite)
|
||||
if (flags.includeUpdateAll) {
|
||||
args.push("--update-all")
|
||||
}
|
||||
}
|
||||
|
||||
if (options.context && options.context > 0) {
|
||||
args.push("-C", String(options.context))
|
||||
}
|
||||
|
||||
if (options.globs) {
|
||||
for (const glob of options.globs) {
|
||||
args.push("--globs", glob)
|
||||
}
|
||||
}
|
||||
|
||||
const paths = options.paths && options.paths.length > 0 ? options.paths : ["."]
|
||||
args.push("--", ...paths)
|
||||
return args
|
||||
}
|
||||
|
||||
function errorCodeFrom(error: unknown): unknown {
|
||||
if (typeof error !== "object" || error === null || !("code" in error)) return undefined
|
||||
return Reflect.get(error, "code")
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import { createRequire } from "module"
|
||||
import { dirname, join } from "path"
|
||||
import { existsSync, statSync } from "fs"
|
||||
|
||||
import { getCachedBinaryPath } from "./downloader"
|
||||
|
||||
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
||||
|
||||
function isValidBinary(filePath: string): boolean {
|
||||
@@ -34,11 +32,6 @@ function getPlatformPackageName(): string | null {
|
||||
export function findSgCliPathSync(): string | null {
|
||||
const binaryName = process.platform === "win32" ? "sg.exe" : "sg"
|
||||
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath && isValidBinary(cachedPath)) {
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const cliPackageJsonPath = require.resolve("@ast-grep/cli/package.json")
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { CLI_LANGUAGES, NAPI_LANGUAGES } from "./constants"
|
||||
import type { CLI_LANGUAGES } from "./constants"
|
||||
|
||||
export type CliLanguage = (typeof CLI_LANGUAGES)[number]
|
||||
export type NapiLanguage = (typeof NAPI_LANGUAGES)[number]
|
||||
|
||||
export interface Position {
|
||||
line: number
|
||||
@@ -26,31 +25,6 @@ export interface CliMatch {
|
||||
language: string
|
||||
}
|
||||
|
||||
export interface SearchMatch {
|
||||
file: string
|
||||
text: string
|
||||
range: Range
|
||||
lines: string
|
||||
}
|
||||
|
||||
export interface MetaVariable {
|
||||
name: string
|
||||
text: string
|
||||
kind: string
|
||||
}
|
||||
|
||||
export interface AnalyzeResult {
|
||||
text: string
|
||||
range: Range
|
||||
kind: string
|
||||
metaVariables: MetaVariable[]
|
||||
}
|
||||
|
||||
export interface TransformResult {
|
||||
original: string
|
||||
transformed: string
|
||||
editCount: number
|
||||
}
|
||||
|
||||
export interface SgResult {
|
||||
matches: CliMatch[]
|
||||
@@ -0,0 +1,36 @@
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
export function normalizeWorkspaceDirectory(workspaceDirectory: string): string {
|
||||
return realpathSync(resolve(workspaceDirectory));
|
||||
}
|
||||
|
||||
export function resolveWorkspacePaths(rawPaths: readonly string[] | undefined, workspaceDirectory: string): readonly string[] {
|
||||
const workspace = normalizeWorkspaceDirectory(workspaceDirectory);
|
||||
const requestedPaths = rawPaths && rawPaths.length > 0 ? rawPaths : ["."];
|
||||
return requestedPaths.map((rawPath) => resolveWorkspacePath(rawPath, workspace));
|
||||
}
|
||||
|
||||
function resolveWorkspacePath(rawPath: string, workspaceDirectory: string): string {
|
||||
if (rawPath.length === 0) throw new Error("paths entries must be non-empty strings");
|
||||
if (rawPath.startsWith("-")) throw new Error(`paths entries must not start with '-': ${rawPath}`);
|
||||
if (rawPath.includes("\0")) throw new Error("paths entries must not contain null bytes");
|
||||
if (isAbsolute(rawPath)) throw new Error(`paths entries must be relative to the workspace: ${rawPath}`);
|
||||
|
||||
const absolutePath = resolve(workspaceDirectory, rawPath);
|
||||
assertInsideWorkspace(absolutePath, workspaceDirectory, rawPath);
|
||||
|
||||
if (existsSync(absolutePath)) {
|
||||
const realPath = realpathSync(absolutePath);
|
||||
assertInsideWorkspace(realPath, workspaceDirectory, rawPath);
|
||||
}
|
||||
|
||||
const normalizedPath = relative(workspaceDirectory, absolutePath);
|
||||
return normalizedPath === "" ? "." : normalizedPath;
|
||||
}
|
||||
|
||||
function assertInsideWorkspace(candidatePath: string, workspaceDirectory: string, rawPath: string): void {
|
||||
const workspaceRelativePath = relative(workspaceDirectory, candidatePath);
|
||||
if (workspaceRelativePath === "" || (!workspaceRelativePath.startsWith("..") && !isAbsolute(workspaceRelativePath))) return;
|
||||
throw new Error(`paths entries must stay inside the workspace: ${rawPath}`);
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export * from "./src/index";
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { AGENTS_FILENAME } from "./constants";
|
||||
import type { AgentsMdCache } from "./types";
|
||||
|
||||
export interface FindAgentsMdUpInput {
|
||||
readonly startDir: string;
|
||||
readonly rootDir: string;
|
||||
readonly skipRoot?: boolean;
|
||||
readonly cache?: AgentsMdCache;
|
||||
}
|
||||
|
||||
export async function findAgentsMdUp(input: FindAgentsMdUpInput): Promise<string[]> {
|
||||
const startDir = resolve(input.startDir);
|
||||
const rootDir = resolve(input.rootDir);
|
||||
const skipRoot = input.skipRoot ?? true;
|
||||
const cacheKey = [startDir, rootDir, skipRoot ? "1" : "0"].join("\0");
|
||||
const cached = input.cache?.get(cacheKey);
|
||||
if (cached) return [...cached];
|
||||
const found: string[] = [];
|
||||
let current = startDir;
|
||||
while (true) {
|
||||
const isRootDir = current === rootDir;
|
||||
if (!(skipRoot && isRootDir)) {
|
||||
const agentsPath = join(current, AGENTS_FILENAME);
|
||||
if (isFile(agentsPath)) found.push(agentsPath);
|
||||
}
|
||||
if (isRootDir) break;
|
||||
const parent = dirname(current);
|
||||
if (parent === current || !isSameOrChildPath(parent, rootDir)) break;
|
||||
current = parent;
|
||||
}
|
||||
const result = found.reverse();
|
||||
input.cache?.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function isFile(path: string): boolean {
|
||||
if (!existsSync(path)) return false;
|
||||
try {
|
||||
return statSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
|
||||
const relativePath = relative(parentPath, childPath);
|
||||
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { AgentsMdCache, DirectoryScanEntry, RuleFileCandidate, RuleScanCache, RuleScanCacheStats } from "./types";
|
||||
|
||||
export function createRuleScanCache(): RuleScanCache {
|
||||
const candidateCache = new Map<string, readonly RuleFileCandidate[]>();
|
||||
const directoryCache = new Map<string, readonly DirectoryScanEntry[]>();
|
||||
return {
|
||||
get: (key) => candidateCache.get(key),
|
||||
set: (key, value) => candidateCache.set(key, value),
|
||||
getDirScan: (dir) => directoryCache.get(dir),
|
||||
setDirScan: (dir, entries) => directoryCache.set(dir, entries),
|
||||
stats: (): RuleScanCacheStats => ({ candidateEntries: candidateCache.size, directoryEntries: directoryCache.size }),
|
||||
clear: () => {
|
||||
candidateCache.clear();
|
||||
directoryCache.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createAgentsMdCache(): AgentsMdCache {
|
||||
const cache = new Map<string, readonly string[]>();
|
||||
return {
|
||||
get: (key) => cache.get(key),
|
||||
set: (key, value) => cache.set(key, value),
|
||||
clear: () => cache.clear(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { RuleSource } from "./types";
|
||||
|
||||
export const PROJECT_MARKERS = [".git", "pyproject.toml", "package.json", "Cargo.toml", "go.mod", ".venv"] as const;
|
||||
|
||||
export const PROJECT_RULE_SUBDIRS = [
|
||||
[".omo", "rules"],
|
||||
[".sisyphus", "rules"],
|
||||
[".claude", "rules"],
|
||||
[".cursor", "rules"],
|
||||
[".github", "instructions"],
|
||||
] as const;
|
||||
|
||||
export const PROJECT_RULE_FILES = [".github/copilot-instructions.md"] as const;
|
||||
export const OPENCODE_USER_RULE_DIRS = [".omo/rules", ".sisyphus/rules", ".opencode/rules"] as const;
|
||||
export const USER_RULE_DIR = ".claude/rules";
|
||||
export const RULE_EXTENSIONS = [".md", ".mdc"] as const;
|
||||
export const GITHUB_INSTRUCTIONS_PATTERN = /\.instructions\.md$/;
|
||||
export const AGENTS_FILENAME = "AGENTS.md";
|
||||
export const GLOBAL_DISTANCE = 9999;
|
||||
export const EXCLUDED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", ".next", "coverage"]);
|
||||
|
||||
export const SOURCE_PRIORITY: ReadonlyMap<RuleSource, number> = new Map([
|
||||
[".omo/rules", 0],
|
||||
[".sisyphus/rules", 1],
|
||||
[".claude/rules", 2],
|
||||
[".cursor/rules", 3],
|
||||
[".github/instructions", 4],
|
||||
[".github/copilot-instructions.md", 5],
|
||||
["~/.omo/rules", 100],
|
||||
["~/.sisyphus/rules", 101],
|
||||
["~/.opencode/rules", 102],
|
||||
["~/.claude/rules", 103],
|
||||
]);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { GLOBAL_DISTANCE, OPENCODE_USER_RULE_DIRS, PROJECT_RULE_FILES, PROJECT_RULE_SUBDIRS, USER_RULE_DIR } from "./constants";
|
||||
import { sortCandidates } from "./ordering";
|
||||
import { findRuleFilesRecursive, safeRealpathSync } from "./scanner";
|
||||
import type { DirectoryScanEntry, FindRuleFilesOptions, RuleFileCandidate, RuleScanCache, RuleSource } from "./types";
|
||||
|
||||
export function findRuleFiles(
|
||||
projectRoot: string | null,
|
||||
homeDir: string,
|
||||
currentFile: string,
|
||||
options?: FindRuleFilesOptions,
|
||||
cache?: RuleScanCache,
|
||||
): RuleFileCandidate[] {
|
||||
const startDir = dirname(resolve(currentFile));
|
||||
const skipClaudeUserRules = options?.skipClaudeUserRules ?? false;
|
||||
const cacheKey = [projectRoot ?? "", startDir, skipClaudeUserRules ? "1" : "0"].join("\0");
|
||||
const cached = cache?.get(cacheKey);
|
||||
if (cached) return [...cached];
|
||||
const candidates: RuleFileCandidate[] = [];
|
||||
const seenRealPaths = new Set<string>();
|
||||
if (projectRoot) {
|
||||
addProjectRuleCandidates(projectRoot, startDir, candidates, seenRealPaths, cache);
|
||||
addProjectSingleFileCandidates(projectRoot, candidates, seenRealPaths);
|
||||
}
|
||||
addUserRuleCandidates(homeDir || homedir(), skipClaudeUserRules, candidates, seenRealPaths, cache);
|
||||
const sorted = sortCandidates(candidates);
|
||||
cache?.set(cacheKey, sorted);
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function addProjectRuleCandidates(
|
||||
projectRoot: string,
|
||||
startDir: string,
|
||||
candidates: RuleFileCandidate[],
|
||||
seenRealPaths: Set<string>,
|
||||
cache: RuleScanCache | undefined,
|
||||
): void {
|
||||
let currentDir = startDir;
|
||||
let distance = 0;
|
||||
while (true) {
|
||||
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
|
||||
const source = `${parent}/${subdir}` as RuleSource;
|
||||
const ruleDir = join(currentDir, parent, subdir);
|
||||
for (const entry of scanDirectoryWithCache(ruleDir, cache)) {
|
||||
if (seenRealPaths.has(entry.realPath)) continue;
|
||||
seenRealPaths.add(entry.realPath);
|
||||
candidates.push({
|
||||
path: entry.path,
|
||||
realPath: entry.realPath,
|
||||
source,
|
||||
isGlobal: false,
|
||||
distance,
|
||||
relativePath: normalizePath(relative(projectRoot, entry.path)),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (currentDir === projectRoot) break;
|
||||
const parentDir = dirname(currentDir);
|
||||
if (parentDir === currentDir || !isSameOrChildPath(parentDir, projectRoot)) break;
|
||||
currentDir = parentDir;
|
||||
distance += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function addProjectSingleFileCandidates(
|
||||
projectRoot: string,
|
||||
candidates: RuleFileCandidate[],
|
||||
seenRealPaths: Set<string>,
|
||||
): void {
|
||||
for (const ruleFile of PROJECT_RULE_FILES) {
|
||||
const filePath = join(projectRoot, ruleFile);
|
||||
const realPath = validFileRealPath(filePath);
|
||||
if (realPath === null || seenRealPaths.has(realPath)) continue;
|
||||
seenRealPaths.add(realPath);
|
||||
candidates.push({
|
||||
path: filePath,
|
||||
realPath,
|
||||
source: ruleFile as RuleSource,
|
||||
isGlobal: false,
|
||||
distance: 0,
|
||||
isSingleFile: true,
|
||||
relativePath: normalizePath(ruleFile),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addUserRuleCandidates(
|
||||
homeDir: string,
|
||||
skipClaudeUserRules: boolean,
|
||||
candidates: RuleFileCandidate[],
|
||||
seenRealPaths: Set<string>,
|
||||
cache: RuleScanCache | undefined,
|
||||
): void {
|
||||
const userRuleDirs: Array<readonly [string, RuleSource]> = OPENCODE_USER_RULE_DIRS.map((dir) => [join(homeDir, dir), `~/${dir}` as RuleSource]);
|
||||
if (!skipClaudeUserRules) userRuleDirs.push([join(homeDir, USER_RULE_DIR), "~/.claude/rules"]);
|
||||
for (const [userRuleDir, source] of userRuleDirs) {
|
||||
for (const entry of scanDirectoryWithCache(userRuleDir, cache)) {
|
||||
if (seenRealPaths.has(entry.realPath)) continue;
|
||||
seenRealPaths.add(entry.realPath);
|
||||
candidates.push({
|
||||
path: entry.path,
|
||||
realPath: entry.realPath,
|
||||
source,
|
||||
isGlobal: true,
|
||||
distance: GLOBAL_DISTANCE,
|
||||
relativePath: normalizePath(relative(homeDir, entry.path)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scanDirectoryWithCache(dir: string, cache: RuleScanCache | undefined): readonly DirectoryScanEntry[] {
|
||||
const cached = cache?.getDirScan(dir);
|
||||
if (cached) return cached;
|
||||
const entries: DirectoryScanEntry[] = [];
|
||||
findRuleFilesRecursive(dir, entries);
|
||||
cache?.setDirScan(dir, entries);
|
||||
return entries;
|
||||
}
|
||||
|
||||
function validFileRealPath(filePath: string): string | null {
|
||||
if (!existsSync(filePath)) return null;
|
||||
try {
|
||||
if (!statSync(filePath).isFile()) return null;
|
||||
return safeRealpathSync(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
|
||||
const relativePath = relative(parentPath, childPath);
|
||||
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.replaceAll("\\", "/");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { basename, relative } from "node:path";
|
||||
import picomatch from "picomatch";
|
||||
import type { MatchResult, RuleMetadata } from "./types";
|
||||
|
||||
const matcherCache = new Map<string, (path: string) => boolean>();
|
||||
const MAX_MATCHER_CACHE_ENTRIES = 256;
|
||||
const PICOMATCH_OPTIONS = { dot: true, bash: true } as const;
|
||||
|
||||
export function resetMatcherCache(): void {
|
||||
matcherCache.clear();
|
||||
}
|
||||
|
||||
export function getMatcherCacheStats(): { readonly entries: number } {
|
||||
return { entries: matcherCache.size };
|
||||
}
|
||||
|
||||
export function shouldApplyRule(metadata: RuleMetadata, currentFilePath: string, projectRoot: string | null): MatchResult {
|
||||
if (metadata.alwaysApply === true) return { applies: true, reason: "alwaysApply" };
|
||||
const patterns = normalizeGlobs(metadata);
|
||||
if (patterns.length === 0) return { applies: false };
|
||||
const pathBases = [
|
||||
toPosix(projectRoot ? relative(projectRoot, currentFilePath) : currentFilePath),
|
||||
toPosix(basename(currentFilePath)),
|
||||
];
|
||||
const negativeMatchers = patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => matcherFor(pattern.slice(1)));
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.startsWith("!")) continue;
|
||||
const isMatch = matcherFor(pattern);
|
||||
if (!pathBases.some((pathBase) => isMatch(pathBase))) continue;
|
||||
if (pathBases.some((pathBase) => negativeMatchers.some((isExcluded) => isExcluded(pathBase)))) return { applies: false };
|
||||
return { applies: true, reason: `glob: ${pattern}` };
|
||||
}
|
||||
return { applies: false };
|
||||
}
|
||||
|
||||
export function createContentHash(content: string): string {
|
||||
return createHash("sha256").update(content).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
export function isDuplicateByRealPath(realPath: string, cache: ReadonlySet<string>): boolean {
|
||||
return cache.has(realPath);
|
||||
}
|
||||
|
||||
export function isDuplicateByContentHash(hash: string, cache: ReadonlySet<string>): boolean {
|
||||
return cache.has(hash);
|
||||
}
|
||||
|
||||
function normalizeGlobs(metadata: RuleMetadata): string[] {
|
||||
const patterns = [...normalizePatternList(metadata.globs), ...normalizePatternList(metadata.paths), ...normalizePatternList(metadata.applyTo)];
|
||||
return [...new Set(patterns.map(toPosix))];
|
||||
}
|
||||
|
||||
function normalizePatternList(patterns: string | readonly string[] | undefined): string[] {
|
||||
if (patterns === undefined) return [];
|
||||
return typeof patterns === "string" ? [patterns] : [...patterns];
|
||||
}
|
||||
|
||||
function matcherFor(pattern: string): (path: string) => boolean {
|
||||
const cached = matcherCache.get(pattern);
|
||||
if (cached) {
|
||||
matcherCache.delete(pattern);
|
||||
matcherCache.set(pattern, cached);
|
||||
return cached;
|
||||
}
|
||||
const matcher = picomatch(pattern, PICOMATCH_OPTIONS);
|
||||
if (matcherCache.size >= MAX_MATCHER_CACHE_ENTRIES) {
|
||||
const oldest = matcherCache.keys().next().value;
|
||||
if (oldest !== undefined) matcherCache.delete(oldest);
|
||||
}
|
||||
matcherCache.set(pattern, matcher);
|
||||
return matcher;
|
||||
}
|
||||
|
||||
function toPosix(path: string): string {
|
||||
return path.replaceAll("\\", "/");
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SOURCE_PRIORITY } from "./constants";
|
||||
import type { RuleFileCandidate } from "./types";
|
||||
|
||||
export function sortCandidates<T extends RuleFileCandidate>(candidates: readonly T[]): T[] {
|
||||
return candidates
|
||||
.map((candidate, index) => ({ candidate, index }))
|
||||
.sort((left, right) => compareCandidates(left.candidate, right.candidate) || left.index - right.index)
|
||||
.map(({ candidate }) => candidate);
|
||||
}
|
||||
|
||||
function compareCandidates(left: RuleFileCandidate, right: RuleFileCandidate): number {
|
||||
return (
|
||||
Number(left.isGlobal) - Number(right.isGlobal) ||
|
||||
left.distance - right.distance ||
|
||||
(SOURCE_PRIORITY.get(left.source) ?? Number.POSITIVE_INFINITY) -
|
||||
(SOURCE_PRIORITY.get(right.source) ?? Number.POSITIVE_INFINITY) ||
|
||||
compareString(left.relativePath, right.relativePath) ||
|
||||
compareString(left.realPath, right.realPath)
|
||||
);
|
||||
}
|
||||
|
||||
function compareString(left: string, right: string): number {
|
||||
if (left < right) return -1;
|
||||
if (left > right) return 1;
|
||||
return 0;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { PROJECT_MARKERS } from "./constants";
|
||||
|
||||
const projectRootCache = new Map<string, string | null>();
|
||||
|
||||
export function clearProjectRootCache(): void {
|
||||
projectRootCache.clear();
|
||||
}
|
||||
|
||||
export function findProjectRoot(startPath: string): string | null {
|
||||
const cached = projectRootCache.get(startPath);
|
||||
if (cached !== undefined) return cached;
|
||||
const startDir = resolveStartDir(startPath);
|
||||
const cachedStartDir = projectRootCache.get(startDir);
|
||||
if (cachedStartDir !== undefined) {
|
||||
projectRootCache.set(startPath, cachedStartDir);
|
||||
return cachedStartDir;
|
||||
}
|
||||
|
||||
const visited: string[] = [];
|
||||
let current = startDir;
|
||||
let resolved: string | null = null;
|
||||
while (true) {
|
||||
const cachedAncestor = projectRootCache.get(current);
|
||||
if (cachedAncestor !== undefined) {
|
||||
resolved = cachedAncestor;
|
||||
break;
|
||||
}
|
||||
visited.push(current);
|
||||
if (hasProjectMarker(current)) {
|
||||
resolved = current;
|
||||
break;
|
||||
}
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
for (const directory of visited) projectRootCache.set(directory, resolved);
|
||||
projectRootCache.set(startPath, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveStartDir(startPath: string): string {
|
||||
try {
|
||||
return statSync(startPath).isDirectory() ? startPath : dirname(startPath);
|
||||
} catch {
|
||||
return dirname(startPath);
|
||||
}
|
||||
}
|
||||
|
||||
function hasProjectMarker(directory: string): boolean {
|
||||
return PROJECT_MARKERS.some((marker) => existsSync(join(directory, marker)));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { EXCLUDED_DIRS, GITHUB_INSTRUCTIONS_PATTERN, RULE_EXTENSIONS } from "./constants";
|
||||
import type { DirectoryScanEntry } from "./types";
|
||||
|
||||
function isGitHubInstructionsDir(dir: string): boolean {
|
||||
return dir.includes(".github/instructions") || dir.endsWith(".github/instructions");
|
||||
}
|
||||
|
||||
function isRuleFile(fileName: string, dir: string): boolean {
|
||||
if (isGitHubInstructionsDir(dir)) return GITHUB_INSTRUCTIONS_PATTERN.test(fileName);
|
||||
return RULE_EXTENSIONS.some((extension) => fileName.endsWith(extension));
|
||||
}
|
||||
|
||||
export function safeRealpathSync(filePath: string): string {
|
||||
try {
|
||||
return realpathSync.native(filePath);
|
||||
} catch {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
export function findRuleFilesRecursive(dir: string, results: DirectoryScanEntry[], visited = new Set<string>()): void {
|
||||
if (!existsSync(dir)) return;
|
||||
const realDir = safeRealpathSync(dir);
|
||||
if (visited.has(realDir)) return;
|
||||
visited.add(realDir);
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" }).sort((left, right) => left.name.localeCompare(right.name));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (!EXCLUDED_DIRS.has(entry.name)) findRuleFilesRecursive(fullPath, results, visited);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && isRuleFile(entry.name, dir)) {
|
||||
results.push({ path: fullPath, realPath: safeRealpathSync(fullPath), relativePath: entry.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -67,6 +67,8 @@ async function main() {
|
||||
console.log(` Entry point: ${ENTRY_POINT}`);
|
||||
console.log(` Platforms: ${PLATFORMS.length}`);
|
||||
|
||||
await $`bun run build:ast-grep-mcp`;
|
||||
|
||||
// Verify entry point exists
|
||||
if (!existsSync(ENTRY_POINT)) {
|
||||
console.error(`\n❌ Entry point not found: ${ENTRY_POINT}`);
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@ import { join } from "node:path"
|
||||
import type { McpServerInfo } from "../types"
|
||||
import { parseJsonc } from "../../../shared"
|
||||
|
||||
const BUILTIN_MCP_SERVERS = ["context7", "grep_app"]
|
||||
const BUILTIN_MCP_SERVERS = ["websearch", "context7", "grep_app", "lsp", "ast_grep"]
|
||||
|
||||
interface McpConfigShape {
|
||||
mcpServers?: Record<string, unknown>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { constants, promises as fsPromises } from "node:fs";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
import { AGENTS_FILENAME } from "./constants";
|
||||
import { findAgentsMdUp as findAgentsMdUpCore } from "@oh-my-opencode/rules-core";
|
||||
import type { AgentsMdCache } from "@oh-my-opencode/rules-core";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
|
||||
export function resolveFilePath(rootDirectory: string, path: string): string | null {
|
||||
if (!path) return null;
|
||||
@@ -10,33 +9,9 @@ export function resolveFilePath(rootDirectory: string, path: string): string | n
|
||||
}
|
||||
|
||||
export async function findAgentsMdUp(input: {
|
||||
startDir: string;
|
||||
rootDir: string;
|
||||
readonly startDir: string;
|
||||
readonly rootDir: string;
|
||||
readonly cache?: AgentsMdCache;
|
||||
}): Promise<string[]> {
|
||||
const found: string[] = [];
|
||||
let current = input.startDir;
|
||||
|
||||
while (true) {
|
||||
// Skip root AGENTS.md - OpenCode's system.ts already loads it via custom()
|
||||
// See: https://github.com/code-yeongyu/oh-my-openagent/issues/379
|
||||
const isRootDir = current === input.rootDir;
|
||||
if (!isRootDir) {
|
||||
const agentsPath = join(current, AGENTS_FILENAME);
|
||||
const exists = await fsPromises
|
||||
.access(agentsPath, constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (exists) {
|
||||
found.push(agentsPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (isRootDir) break;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
if (!parent.startsWith(input.rootDir)) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return found.reverse();
|
||||
return findAgentsMdUpCore({ startDir: input.startDir, rootDir: input.rootDir, cache: input.cache });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentsMdCache } from "@oh-my-opencode/rules-core";
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
@@ -35,6 +36,7 @@ export function createDirectoryAgentsInjectorHook(
|
||||
modelCacheState?: { anthropicContext1MEnabled: boolean },
|
||||
): DirectoryAgentsInjectorHook {
|
||||
const sessionCaches = new Map<string, Set<string>>();
|
||||
const agentsMdCache = createAgentsMdCache();
|
||||
const truncator = createDynamicTruncator(ctx, modelCacheState);
|
||||
|
||||
const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
|
||||
@@ -45,6 +47,7 @@ export function createDirectoryAgentsInjectorHook(
|
||||
ctx,
|
||||
truncator,
|
||||
sessionCaches,
|
||||
agentsMdCache,
|
||||
filePath: output.title,
|
||||
sessionID: input.sessionID,
|
||||
output,
|
||||
@@ -54,21 +57,21 @@ export function createDirectoryAgentsInjectorHook(
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
const sessionID = resolveSessionEventID(event.properties);
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedPaths(sessionID);
|
||||
agentsMdCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
const sessionID = resolveSessionEventID(event.properties);
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedPaths(sessionID);
|
||||
agentsMdCache.clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import type { AgentsMdCache } from "@oh-my-opencode/rules-core";
|
||||
import { promises as fsPromises } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
@@ -15,13 +16,18 @@ function getSessionCache(
|
||||
if (!sessionCaches.has(sessionID)) {
|
||||
sessionCaches.set(sessionID, loadInjectedPaths(sessionID));
|
||||
}
|
||||
return sessionCaches.get(sessionID)!;
|
||||
const cache = sessionCaches.get(sessionID);
|
||||
if (cache) return cache;
|
||||
const loaded = loadInjectedPaths(sessionID);
|
||||
sessionCaches.set(sessionID, loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
export async function processFilePathForAgentsInjection(input: {
|
||||
ctx: PluginInput;
|
||||
truncator: DynamicTruncator;
|
||||
sessionCaches: Map<string, Set<string>>;
|
||||
agentsMdCache?: AgentsMdCache;
|
||||
filePath: string;
|
||||
sessionID: string;
|
||||
output: { title: string; output: string; metadata: unknown };
|
||||
@@ -35,26 +41,29 @@ export async function processFilePathForAgentsInjection(input: {
|
||||
|
||||
const dir = dirname(resolved);
|
||||
const cache = getSessionCache(input.sessionCaches, input.sessionID);
|
||||
const agentsPaths = await findAgentsMdUp({ startDir: dir, rootDir: input.ctx.directory });
|
||||
const agentsPaths = await findAgentsMdUp({
|
||||
startDir: dir,
|
||||
rootDir: input.ctx.directory,
|
||||
cache: input.agentsMdCache,
|
||||
});
|
||||
|
||||
let dirty = false;
|
||||
for (const agentsPath of agentsPaths) {
|
||||
const agentsDir = dirname(agentsPath);
|
||||
if (cache.has(agentsDir)) continue;
|
||||
|
||||
try {
|
||||
const content = await fsPromises.readFile(agentsPath, "utf-8");
|
||||
cache.add(agentsDir);
|
||||
const { result, truncated } = await input.truncator.truncate(
|
||||
input.sessionID,
|
||||
content,
|
||||
);
|
||||
const truncationNotice = truncated
|
||||
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]`
|
||||
: "";
|
||||
input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`;
|
||||
dirty = true;
|
||||
} catch {}
|
||||
const content = await fsPromises.readFile(agentsPath, "utf-8").catch(() => null);
|
||||
if (content === null) continue;
|
||||
cache.add(agentsDir);
|
||||
const { result, truncated } = await input.truncator.truncate(
|
||||
input.sessionID,
|
||||
content,
|
||||
);
|
||||
const truncationNotice = truncated
|
||||
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]`
|
||||
: "";
|
||||
input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
|
||||
@@ -1,100 +1,13 @@
|
||||
import { createHash } from "crypto"
|
||||
import { relative } from "node:path"
|
||||
import picomatch from "picomatch"
|
||||
import type { RuleMetadata } from "./types"
|
||||
|
||||
type PathMatcher = (path: string) => boolean
|
||||
|
||||
export interface MatchResult {
|
||||
applies: boolean
|
||||
reason?: string
|
||||
}
|
||||
export {
|
||||
createContentHash,
|
||||
getMatcherCacheStats,
|
||||
isDuplicateByContentHash,
|
||||
isDuplicateByRealPath,
|
||||
resetMatcherCache,
|
||||
shouldApplyRule,
|
||||
} from "@oh-my-opencode/rules-core";
|
||||
export type { MatchResult } from "@oh-my-opencode/rules-core";
|
||||
|
||||
export interface MatcherCacheStats {
|
||||
entries: number
|
||||
}
|
||||
|
||||
const PICOMATCH_OPTIONS = { dot: true, bash: true } as const
|
||||
const MAX_MATCHER_CACHE_ENTRIES = 256
|
||||
const matcherCache = new Map<string, PathMatcher>()
|
||||
|
||||
function matcherFor(pattern: string): PathMatcher {
|
||||
const cached = matcherCache.get(pattern)
|
||||
if (cached) {
|
||||
matcherCache.delete(pattern)
|
||||
matcherCache.set(pattern, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
const matcher = picomatch(pattern, PICOMATCH_OPTIONS)
|
||||
if (matcherCache.size >= MAX_MATCHER_CACHE_ENTRIES) {
|
||||
const oldestPattern = matcherCache.keys().next().value
|
||||
if (oldestPattern !== undefined) {
|
||||
matcherCache.delete(oldestPattern)
|
||||
}
|
||||
}
|
||||
matcherCache.set(pattern, matcher)
|
||||
return matcher
|
||||
}
|
||||
|
||||
export function resetMatcherCache(): void {
|
||||
matcherCache.clear()
|
||||
}
|
||||
|
||||
export function getMatcherCacheStats(): MatcherCacheStats {
|
||||
return { entries: matcherCache.size }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a rule should apply to the current file based on metadata
|
||||
*/
|
||||
export function shouldApplyRule(
|
||||
metadata: RuleMetadata,
|
||||
currentFilePath: string,
|
||||
projectRoot: string | null
|
||||
): MatchResult {
|
||||
if (metadata.alwaysApply === true) {
|
||||
return { applies: true, reason: "alwaysApply" }
|
||||
}
|
||||
|
||||
const globs = metadata.globs
|
||||
if (!globs) {
|
||||
return { applies: false }
|
||||
}
|
||||
|
||||
const patterns = Array.isArray(globs) ? globs : [globs]
|
||||
if (patterns.length === 0) {
|
||||
return { applies: false }
|
||||
}
|
||||
|
||||
const relativePath = projectRoot ? relative(projectRoot, currentFilePath) : currentFilePath
|
||||
|
||||
for (const pattern of patterns) {
|
||||
if (matcherFor(pattern)(relativePath)) {
|
||||
return { applies: true, reason: `glob: ${pattern}` }
|
||||
}
|
||||
}
|
||||
|
||||
return { applies: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if realPath already exists in cache (symlink deduplication)
|
||||
*/
|
||||
export function isDuplicateByRealPath(realPath: string, cache: Set<string>): boolean {
|
||||
return cache.has(realPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SHA-256 hash of content, truncated to 16 chars
|
||||
*/
|
||||
export function createContentHash(content: string): string {
|
||||
return createHash("sha256").update(content).digest("hex").slice(0, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content hash already exists in cache
|
||||
*/
|
||||
export function isDuplicateByContentHash(hash: string, cache: Set<string>): boolean {
|
||||
return cache.has(hash)
|
||||
readonly entries: number;
|
||||
}
|
||||
|
||||
@@ -1,211 +1,2 @@
|
||||
import type { RuleMetadata } from "./types";
|
||||
|
||||
export interface RuleFrontmatterResult {
|
||||
metadata: RuleMetadata;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from rule file content
|
||||
* Supports:
|
||||
* - Single string: globs: "**\/*.py"
|
||||
* - Inline array: globs: ["**\/*.py", "src/**\/*.ts"]
|
||||
* - Multi-line array:
|
||||
* globs:
|
||||
* - "**\/*.py"
|
||||
* - "src/**\/*.ts"
|
||||
* - Comma-separated: globs: "**\/*.py, src/**\/*.ts"
|
||||
* - Claude Code 'paths' field (alias for globs)
|
||||
*/
|
||||
export function parseRuleFrontmatter(content: string): RuleFrontmatterResult {
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return { metadata: {}, body: content };
|
||||
}
|
||||
|
||||
const yamlContent = match[1];
|
||||
const body = match[2];
|
||||
|
||||
try {
|
||||
const metadata = parseYamlContent(yamlContent);
|
||||
return { metadata, body };
|
||||
} catch {
|
||||
return { metadata: {}, body: content };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse YAML content without external library
|
||||
*/
|
||||
function parseYamlContent(yamlContent: string): RuleMetadata {
|
||||
const lines = yamlContent.split("\n");
|
||||
const metadata: RuleMetadata = {};
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const colonIndex = line.indexOf(":");
|
||||
|
||||
if (colonIndex === -1) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.slice(0, colonIndex).trim();
|
||||
const rawValue = line.slice(colonIndex + 1).trim();
|
||||
|
||||
if (key === "description") {
|
||||
metadata.description = parseStringValue(rawValue);
|
||||
} else if (key === "alwaysApply") {
|
||||
metadata.alwaysApply = rawValue === "true";
|
||||
} else if (key === "globs" || key === "paths" || key === "applyTo") {
|
||||
const { value, consumed } = parseArrayOrStringValue(rawValue, lines, i);
|
||||
// Merge paths into globs (Claude Code compatibility)
|
||||
if (key === "paths") {
|
||||
metadata.globs = mergeGlobs(metadata.globs, value);
|
||||
} else {
|
||||
metadata.globs = mergeGlobs(metadata.globs, value);
|
||||
}
|
||||
i += consumed;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string value, removing surrounding quotes
|
||||
*/
|
||||
function parseStringValue(value: string): string {
|
||||
if (!value) return "";
|
||||
|
||||
// Remove surrounding quotes
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse array or string value from YAML
|
||||
* Returns the parsed value and number of lines consumed
|
||||
*/
|
||||
function parseArrayOrStringValue(
|
||||
rawValue: string,
|
||||
lines: string[],
|
||||
currentIndex: number
|
||||
): { value: string | string[]; consumed: number } {
|
||||
// Case 1: Inline array ["a", "b", "c"]
|
||||
if (rawValue.startsWith("[")) {
|
||||
return { value: parseInlineArray(rawValue), consumed: 1 };
|
||||
}
|
||||
|
||||
// Case 2: Multi-line array (value is empty, next lines start with " - ")
|
||||
if (!rawValue || rawValue === "") {
|
||||
const arrayItems: string[] = [];
|
||||
let consumed = 1;
|
||||
|
||||
for (let j = currentIndex + 1; j < lines.length; j++) {
|
||||
const nextLine = lines[j];
|
||||
|
||||
// Check if this is an array item (starts with whitespace + dash)
|
||||
const arrayMatch = nextLine.match(/^\s+-\s*(.*)$/);
|
||||
if (arrayMatch) {
|
||||
const itemValue = parseStringValue(arrayMatch[1].trim());
|
||||
if (itemValue) {
|
||||
arrayItems.push(itemValue);
|
||||
}
|
||||
consumed++;
|
||||
} else if (nextLine.trim() === "") {
|
||||
// Skip empty lines within array
|
||||
consumed++;
|
||||
} else {
|
||||
// Not an array item, stop
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (arrayItems.length > 0) {
|
||||
return { value: arrayItems, consumed };
|
||||
}
|
||||
}
|
||||
|
||||
// Case 3: Comma-separated patterns in single string
|
||||
const stringValue = parseStringValue(rawValue);
|
||||
if (stringValue.includes(",")) {
|
||||
const items = stringValue
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
return { value: items, consumed: 1 };
|
||||
}
|
||||
|
||||
// Case 4: Single string value
|
||||
return { value: stringValue, consumed: 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse inline JSON-like array: ["a", "b", "c"]
|
||||
*/
|
||||
function parseInlineArray(value: string): string[] {
|
||||
// Remove brackets
|
||||
const content = value.slice(1, value.lastIndexOf("]")).trim();
|
||||
if (!content) return [];
|
||||
|
||||
const items: string[] = [];
|
||||
let current = "";
|
||||
let inQuote = false;
|
||||
let quoteChar = "";
|
||||
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const char = content[i];
|
||||
|
||||
if (!inQuote && (char === '"' || char === "'")) {
|
||||
inQuote = true;
|
||||
quoteChar = char;
|
||||
} else if (inQuote && char === quoteChar) {
|
||||
inQuote = false;
|
||||
quoteChar = "";
|
||||
} else if (!inQuote && char === ",") {
|
||||
const trimmed = current.trim();
|
||||
if (trimmed) {
|
||||
items.push(parseStringValue(trimmed));
|
||||
}
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last item
|
||||
const trimmed = current.trim();
|
||||
if (trimmed) {
|
||||
items.push(parseStringValue(trimmed));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two globs values (for combining paths and globs)
|
||||
*/
|
||||
function mergeGlobs(
|
||||
existing: string | string[] | undefined,
|
||||
newValue: string | string[]
|
||||
): string | string[] {
|
||||
if (!existing) return newValue;
|
||||
|
||||
const existingArray = Array.isArray(existing) ? existing : [existing];
|
||||
const newArray = Array.isArray(newValue) ? newValue : [newValue];
|
||||
|
||||
return [...existingArray, ...newArray];
|
||||
}
|
||||
export { parseRuleFrontmatter } from "@oh-my-opencode/rules-core";
|
||||
export type { RuleFrontmatterResult } from "@oh-my-opencode/rules-core";
|
||||
|
||||
@@ -1,85 +1 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { PROJECT_MARKERS } from "./constants";
|
||||
|
||||
const projectRootCache = new Map<string, string | null>();
|
||||
|
||||
export function clearProjectRootCache(): void {
|
||||
projectRootCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find project root by walking up from startPath.
|
||||
* Checks for PROJECT_MARKERS (.git, pyproject.toml, package.json, etc.)
|
||||
*
|
||||
* Memoizes every directory visited during the walk so subsequent lookups for
|
||||
* any descendant path resolve in O(1) without re-running marker existsSync
|
||||
* probes.
|
||||
*
|
||||
* @param startPath - Starting path to search from (file or directory)
|
||||
* @returns Project root path or null if not found
|
||||
*/
|
||||
export function findProjectRoot(startPath: string): string | null {
|
||||
const cached = projectRootCache.get(startPath);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const startDir = resolveStartDir(startPath);
|
||||
const cachedFromStartDir = projectRootCache.get(startDir);
|
||||
if (cachedFromStartDir !== undefined) {
|
||||
projectRootCache.set(startPath, cachedFromStartDir);
|
||||
return cachedFromStartDir;
|
||||
}
|
||||
|
||||
const visited: string[] = [];
|
||||
let current = startDir;
|
||||
let resolved: string | null = null;
|
||||
|
||||
while (true) {
|
||||
const cachedAncestor = projectRootCache.get(current);
|
||||
if (cachedAncestor !== undefined) {
|
||||
resolved = cachedAncestor;
|
||||
break;
|
||||
}
|
||||
|
||||
visited.push(current);
|
||||
|
||||
if (hasProjectMarker(current)) {
|
||||
resolved = current;
|
||||
break;
|
||||
}
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) {
|
||||
resolved = null;
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
for (const dir of visited) {
|
||||
projectRootCache.set(dir, resolved);
|
||||
}
|
||||
projectRootCache.set(startPath, resolved);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveStartDir(startPath: string): string {
|
||||
try {
|
||||
const stat = statSync(startPath);
|
||||
return stat.isDirectory() ? startPath : dirname(startPath);
|
||||
} catch {
|
||||
return dirname(startPath);
|
||||
}
|
||||
}
|
||||
|
||||
function hasProjectMarker(dir: string): boolean {
|
||||
for (const marker of PROJECT_MARKERS) {
|
||||
if (existsSync(join(dir, marker))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
export { clearProjectRootCache, findProjectRoot } from "@oh-my-opencode/rules-core";
|
||||
|
||||
@@ -1,53 +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";
|
||||
|
||||
@@ -1,148 +1,2 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
OPENCODE_USER_RULE_DIRS,
|
||||
PROJECT_RULE_FILES,
|
||||
PROJECT_RULE_SUBDIRS,
|
||||
USER_RULE_DIR,
|
||||
} from "./constants";
|
||||
import type { DirectoryScanEntry, RuleScanCache } from "./rule-scan-cache";
|
||||
import { findRuleFilesRecursive, safeRealpathSync } from "./rule-file-scanner";
|
||||
import type { RuleFileCandidate } from "./types";
|
||||
|
||||
export interface FindRuleFilesOptions {
|
||||
skipClaudeUserRules?: boolean;
|
||||
}
|
||||
|
||||
function scanDirectoryWithCache(
|
||||
dir: string,
|
||||
cache: RuleScanCache | undefined,
|
||||
): DirectoryScanEntry[] {
|
||||
const cached = cache?.getDirScan(dir);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const files: string[] = [];
|
||||
findRuleFilesRecursive(dir, files);
|
||||
const entries: DirectoryScanEntry[] = files.map((filePath) => ({
|
||||
path: filePath,
|
||||
realPath: safeRealpathSync(filePath),
|
||||
}));
|
||||
|
||||
cache?.setDirScan(dir, entries);
|
||||
return entries;
|
||||
}
|
||||
|
||||
function getUserRuleDirs(homeDir: string, skipClaudeUserRules: boolean): string[] {
|
||||
const userRuleDirs = OPENCODE_USER_RULE_DIRS.map((dir) => join(homeDir, dir));
|
||||
if (!skipClaudeUserRules) {
|
||||
userRuleDirs.push(join(homeDir, USER_RULE_DIR));
|
||||
}
|
||||
return userRuleDirs;
|
||||
}
|
||||
|
||||
function createCacheKey(
|
||||
projectRoot: string | null,
|
||||
startDir: string,
|
||||
skipClaudeUserRules: boolean,
|
||||
): string {
|
||||
return `${projectRoot ?? ""}|${startDir}|${skipClaudeUserRules ? "1" : "0"}`;
|
||||
}
|
||||
|
||||
export function findRuleFiles(
|
||||
projectRoot: string | null,
|
||||
homeDir: string,
|
||||
currentFile: string,
|
||||
options?: FindRuleFilesOptions,
|
||||
cache?: RuleScanCache,
|
||||
): RuleFileCandidate[] {
|
||||
const startDir = dirname(currentFile);
|
||||
const skipClaudeUserRules = options?.skipClaudeUserRules ?? false;
|
||||
const userRuleDirs = getUserRuleDirs(homeDir, skipClaudeUserRules);
|
||||
const cacheKey = createCacheKey(projectRoot, startDir, skipClaudeUserRules);
|
||||
const cachedCandidates = cache?.get(cacheKey);
|
||||
|
||||
if (cachedCandidates) {
|
||||
return cachedCandidates;
|
||||
}
|
||||
|
||||
const candidates: RuleFileCandidate[] = [];
|
||||
const seenRealPaths = new Set<string>();
|
||||
let currentDir = startDir;
|
||||
let distance = 0;
|
||||
|
||||
while (true) {
|
||||
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
|
||||
const ruleDir = join(currentDir, parent, subdir);
|
||||
const entries = scanDirectoryWithCache(ruleDir, cache);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (seenRealPaths.has(entry.realPath)) continue;
|
||||
seenRealPaths.add(entry.realPath);
|
||||
candidates.push({
|
||||
path: entry.path,
|
||||
realPath: entry.realPath,
|
||||
isGlobal: false,
|
||||
distance,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (projectRoot && currentDir === projectRoot) break;
|
||||
const parentDir = dirname(currentDir);
|
||||
if (parentDir === currentDir) break;
|
||||
currentDir = parentDir;
|
||||
distance += 1;
|
||||
}
|
||||
|
||||
if (projectRoot) {
|
||||
for (const ruleFile of PROJECT_RULE_FILES) {
|
||||
const filePath = join(projectRoot, ruleFile);
|
||||
if (!existsSync(filePath)) continue;
|
||||
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
if (!stat.isFile()) continue;
|
||||
const realPath = safeRealpathSync(filePath);
|
||||
if (seenRealPaths.has(realPath)) continue;
|
||||
seenRealPaths.add(realPath);
|
||||
candidates.push({
|
||||
path: filePath,
|
||||
realPath,
|
||||
isGlobal: false,
|
||||
distance: 0,
|
||||
isSingleFile: true,
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const userRuleDir of userRuleDirs) {
|
||||
const entries = scanDirectoryWithCache(userRuleDir, cache);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (seenRealPaths.has(entry.realPath)) continue;
|
||||
seenRealPaths.add(entry.realPath);
|
||||
candidates.push({
|
||||
path: entry.path,
|
||||
realPath: entry.realPath,
|
||||
isGlobal: true,
|
||||
distance: 9999,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
if (left.isGlobal !== right.isGlobal) {
|
||||
return left.isGlobal ? 1 : -1;
|
||||
}
|
||||
return left.distance - right.distance;
|
||||
});
|
||||
|
||||
cache?.set(cacheKey, candidates);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
export { findRuleFiles } from "@oh-my-opencode/rules-core";
|
||||
export type { FindRuleFilesOptions } from "@oh-my-opencode/rules-core";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -1,38 +1,2 @@
|
||||
import type { RuleFileCandidate } from "./types";
|
||||
|
||||
export type DirectoryScanEntry = {
|
||||
path: string;
|
||||
realPath: string;
|
||||
};
|
||||
|
||||
export type RuleScanCache = {
|
||||
get: (key: string) => RuleFileCandidate[] | undefined;
|
||||
set: (key: string, value: RuleFileCandidate[]) => void;
|
||||
getDirScan: (dir: string) => DirectoryScanEntry[] | undefined;
|
||||
setDirScan: (dir: string, entries: DirectoryScanEntry[]) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
export function createRuleScanCache(): RuleScanCache {
|
||||
const finalResultCache = new Map<string, RuleFileCandidate[]>();
|
||||
const directoryScanCache = new Map<string, DirectoryScanEntry[]>();
|
||||
|
||||
return {
|
||||
get(key: string): RuleFileCandidate[] | undefined {
|
||||
return finalResultCache.get(key);
|
||||
},
|
||||
set(key: string, value: RuleFileCandidate[]): void {
|
||||
finalResultCache.set(key, value);
|
||||
},
|
||||
getDirScan(dir: string): DirectoryScanEntry[] | undefined {
|
||||
return directoryScanCache.get(dir);
|
||||
},
|
||||
setDirScan(dir: string, entries: DirectoryScanEntry[]): void {
|
||||
directoryScanCache.set(dir, entries);
|
||||
},
|
||||
clear(): void {
|
||||
finalResultCache.clear();
|
||||
directoryScanCache.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
export { createRuleScanCache } from "@oh-my-opencode/rules-core";
|
||||
export type { DirectoryScanEntry, RuleScanCache } from "@oh-my-opencode/rules-core";
|
||||
|
||||
@@ -1,57 +1,21 @@
|
||||
/**
|
||||
* Rule file metadata (Claude Code style frontmatter)
|
||||
* Supports both Claude Code format (globs, paths) and GitHub Copilot format (applyTo)
|
||||
* @see https://docs.anthropic.com/en/docs/claude-code/settings#rule-files
|
||||
* @see https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot
|
||||
*/
|
||||
export interface RuleMetadata {
|
||||
description?: string;
|
||||
globs?: string | string[];
|
||||
alwaysApply?: boolean;
|
||||
}
|
||||
import type { RuleFileCandidate, RuleMetadata } from "@oh-my-opencode/rules-core";
|
||||
|
||||
export type { RuleFileCandidate, RuleMetadata };
|
||||
|
||||
/**
|
||||
* Rule information with path context and content
|
||||
*/
|
||||
export interface RuleInfo {
|
||||
/** Absolute path to the rule file */
|
||||
path: string;
|
||||
/** Path relative to project root */
|
||||
relativePath: string;
|
||||
/** Directory distance from target file (0 = same dir) */
|
||||
distance: number;
|
||||
/** Rule file content (without frontmatter) */
|
||||
content: string;
|
||||
/** SHA-256 hash of content for deduplication */
|
||||
contentHash: string;
|
||||
/** Parsed frontmatter metadata */
|
||||
metadata: RuleMetadata;
|
||||
/** Why this rule matched (e.g., "alwaysApply", "glob: *.ts", "path match") */
|
||||
matchReason: string;
|
||||
/** Real path after symlink resolution (for duplicate detection) */
|
||||
realPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule file candidate with discovery context
|
||||
*/
|
||||
export interface RuleFileCandidate {
|
||||
path: string;
|
||||
realPath: string;
|
||||
isGlobal: boolean;
|
||||
distance: number;
|
||||
/** Single-file rules (e.g., .github/copilot-instructions.md) always apply without frontmatter */
|
||||
isSingleFile?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session storage for injected rules tracking
|
||||
*/
|
||||
export interface InjectedRulesData {
|
||||
sessionID: string;
|
||||
/** Content hashes of already injected rules */
|
||||
injectedHashes: string[];
|
||||
/** Real paths of already injected rules (for symlink deduplication) */
|
||||
injectedRealPaths: string[];
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
+7
-5
@@ -1,10 +1,10 @@
|
||||
# src/mcp/ — 4 Built-in MCPs
|
||||
# src/mcp/ — 5 Built-in MCPs
|
||||
|
||||
**Generated:** 2026-05-18
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Tier 1 of the three-tier MCP system. Built-ins are created by `createBuiltinMcps(disabledMcps, config)` and now include both remote MCPs and one local stdio MCP.
|
||||
Tier 1 of the three-tier MCP system. Built-ins are created by `createBuiltinMcps(disabledMcps, config, options)` and now include both remote MCPs and local stdio MCPs.
|
||||
|
||||
## BUILT-IN MCPs
|
||||
|
||||
@@ -14,19 +14,20 @@ Tier 1 of the three-tier MCP system. Built-ins are created by `createBuiltinMcps
|
||||
| **context7** | remote | `mcp.context7.com/mcp` | `CONTEXT7_API_KEY` (optional) | Library documentation |
|
||||
| **grep_app** | remote | `mcp.grep.app` | None | GitHub code search |
|
||||
| **lsp** | local (stdio, node/bun) | `node packages/lsp-tools-mcp/dist/cli.js mcp` or `bun packages/lsp-tools-mcp/src/cli.ts mcp` | `LSP_TOOLS_MCP_PROJECT_CONFIG=.opencode/lsp.json` | `status`, diagnostics, goto definition, references, symbols, prepare_rename, rename |
|
||||
| **ast_grep** | local (stdio, node/bun) | `node packages/ast-grep-mcp/dist/cli.js mcp` or `bun packages/ast-grep-mcp/src/cli.ts mcp` | `OMO_AST_GREP_WORKSPACE=<project>` | `search`, `replace` |
|
||||
|
||||
## SUBMODULE ARCHITECTURE
|
||||
|
||||
- The local `lsp` MCP is a git submodule at `packages/lsp-tools-mcp/`.
|
||||
- Upstream project: https://github.com/code-yeongyu/lsp-tools-mcp
|
||||
- OMO resolves the CLI path dynamically in `src/mcp/lsp.ts` so both `src/` and `dist/` runtime layouts work.
|
||||
- `lsp` is registered whenever it is not listed in `disabled_mcps`, even if the CLI artifact has not been built yet. Source checkouts fall back to the Bun source CLI; packaged builds prefer the Node dist CLI.
|
||||
- `lsp` and `ast_grep` are registered whenever they are not listed in `disabled_mcps`, even if their CLI artifacts have not been built yet. Source checkouts fall back to the Bun source CLI; packaged builds prefer the Node dist CLI.
|
||||
|
||||
## THREE-TIER SYSTEM
|
||||
|
||||
| Tier | Source | Mechanism |
|
||||
|------|--------|-----------|
|
||||
| 1. Built-in | `src/mcp/` | 3 remote HTTP MCPs + 1 local stdio MCP (`lsp`) via `createBuiltinMcps()` |
|
||||
| 1. Built-in | `src/mcp/` | 3 remote HTTP MCPs + 2 local stdio MCPs (`lsp`, `ast_grep`) via `createBuiltinMcps()` |
|
||||
| 2. Claude Code | `.mcp.json` | `${VAR}` expansion via `claude-code-mcp-loader` |
|
||||
| 3. Skill-embedded | SKILL.md YAML | Managed by `SkillMcpManager` (stdio + HTTP) |
|
||||
|
||||
@@ -35,8 +36,9 @@ Tier 1 of the three-tier MCP system. Built-ins are created by `createBuiltinMcps
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `index.ts` | `createBuiltinMcps()` registry for built-in MCPs |
|
||||
| `types.ts` | `McpNameSchema`: `"websearch" \| "context7" \| "grep_app" \| "lsp"` |
|
||||
| `types.ts` | `McpNameSchema`: `"websearch" \| "context7" \| "grep_app" \| "lsp" \| "ast_grep"` |
|
||||
| `websearch.ts` | Exa/Tavily provider with config |
|
||||
| `context7.ts` | Context7 with optional auth header |
|
||||
| `grep-app.ts` | Grep.app (no auth) |
|
||||
| `lsp.ts` | Local stdio MCP config for packaged `lsp-tools-mcp` |
|
||||
| `ast-grep.ts` | Local stdio MCP config for packaged `ast-grep-mcp` |
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { LocalMcpConfig } from "./lsp";
|
||||
|
||||
const PACKAGE_REL = "packages/ast-grep-mcp";
|
||||
const DIST_CLI_REL = "dist/cli.js";
|
||||
const SOURCE_CLI_REL = "src/cli.ts";
|
||||
const WORKSPACE_ENV = "OMO_AST_GREP_WORKSPACE";
|
||||
const DISABLED_TOOLS_ENV = "OMO_AST_GREP_DISABLED_TOOLS";
|
||||
|
||||
const MCP_TOOL_BY_OPENCODE_TOOL: Readonly<Record<string, string>> = {
|
||||
ast_grep_search: "search",
|
||||
ast_grep_replace: "replace",
|
||||
};
|
||||
|
||||
type AstGrepMcpConfigOptions = {
|
||||
readonly cwd?: string;
|
||||
readonly disabledTools?: readonly string[];
|
||||
readonly moduleUrl?: string;
|
||||
readonly exists?: (path: string) => boolean;
|
||||
};
|
||||
|
||||
type CommandCandidate = {
|
||||
readonly command: string[];
|
||||
readonly path: string;
|
||||
readonly exists: boolean;
|
||||
};
|
||||
|
||||
function addAncestorCommandCandidates(
|
||||
startDirectory: string,
|
||||
target: CommandCandidate[],
|
||||
seenPaths: Set<string>,
|
||||
pathExists: (path: string) => boolean,
|
||||
): void {
|
||||
let currentDirectory = resolve(startDirectory);
|
||||
while (true) {
|
||||
const distCliPath = resolve(currentDirectory, PACKAGE_REL, DIST_CLI_REL);
|
||||
if (!seenPaths.has(distCliPath)) {
|
||||
seenPaths.add(distCliPath);
|
||||
target.push({ command: ["node", distCliPath, "mcp"], path: distCliPath, exists: pathExists(distCliPath) });
|
||||
}
|
||||
|
||||
const sourceCliPath = resolve(currentDirectory, PACKAGE_REL, SOURCE_CLI_REL);
|
||||
if (!seenPaths.has(sourceCliPath)) {
|
||||
seenPaths.add(sourceCliPath);
|
||||
target.push({ command: ["bun", sourceCliPath, "mcp"], path: sourceCliPath, exists: pathExists(sourceCliPath) });
|
||||
}
|
||||
|
||||
const parentDirectory = resolve(currentDirectory, "..");
|
||||
if (parentDirectory === currentDirectory) return;
|
||||
currentDirectory = parentDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
function getModuleDirectory(moduleUrl: string): string | null {
|
||||
try {
|
||||
return dirname(fileURLToPath(moduleUrl));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAstGrepCommand(options: AstGrepMcpConfigOptions = {}): string[] {
|
||||
const pathExists = options.exists ?? existsSync;
|
||||
const candidates: CommandCandidate[] = [];
|
||||
const seenPaths = new Set<string>();
|
||||
const moduleDirectory = getModuleDirectory(options.moduleUrl ?? import.meta.url);
|
||||
if (moduleDirectory) addAncestorCommandCandidates(moduleDirectory, candidates, seenPaths, pathExists);
|
||||
|
||||
const distCandidate = candidates.find((candidate) => candidate.path.endsWith(DIST_CLI_REL) && candidate.exists);
|
||||
if (distCandidate) return distCandidate.command;
|
||||
const sourceCandidate = candidates.find((candidate) => candidate.path.endsWith(SOURCE_CLI_REL) && candidate.exists);
|
||||
if (sourceCandidate) return sourceCandidate.command;
|
||||
return candidates[0]?.command ?? ["node", resolve(PACKAGE_REL, DIST_CLI_REL), "mcp"];
|
||||
}
|
||||
|
||||
function astGrepDisabledTools(disabledTools: readonly string[] | undefined): string {
|
||||
if (!disabledTools) return "";
|
||||
return disabledTools
|
||||
.map((toolName) => MCP_TOOL_BY_OPENCODE_TOOL[toolName])
|
||||
.filter((toolName): toolName is string => typeof toolName === "string")
|
||||
.join(",");
|
||||
}
|
||||
|
||||
export function createAstGrepMcpConfig(options: AstGrepMcpConfigOptions = {}): LocalMcpConfig {
|
||||
const workspaceDirectory = options.cwd ?? process.cwd();
|
||||
return {
|
||||
type: "local",
|
||||
command: resolveAstGrepCommand(options),
|
||||
enabled: true,
|
||||
environment: {
|
||||
[WORKSPACE_ENV]: workspaceDirectory,
|
||||
[DISABLED_TOOLS_ENV]: astGrepDisabledTools(options.disabledTools),
|
||||
},
|
||||
};
|
||||
}
|
||||
+10
-1
@@ -1,6 +1,7 @@
|
||||
import { createWebsearchConfig } from "./websearch"
|
||||
import { context7 } from "./context7"
|
||||
import { grep_app } from "./grep-app"
|
||||
import { createAstGrepMcpConfig } from "./ast-grep"
|
||||
import { createLspMcpConfig, type LocalMcpConfig } from "./lsp"
|
||||
import type { OhMyOpenCodeConfig } from "../config/schema"
|
||||
|
||||
@@ -16,7 +17,11 @@ type RemoteMcpConfig = {
|
||||
|
||||
type BuiltinMcpConfig = RemoteMcpConfig | LocalMcpConfig
|
||||
|
||||
export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpenCodeConfig) {
|
||||
type BuiltinMcpOptions = {
|
||||
readonly cwd?: string
|
||||
}
|
||||
|
||||
export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpenCodeConfig, options: BuiltinMcpOptions = {}) {
|
||||
const mcps: Record<string, BuiltinMcpConfig> = {}
|
||||
|
||||
if (!disabledMcps.includes("websearch")) {
|
||||
@@ -38,5 +43,9 @@ export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpen
|
||||
mcps.lsp = createLspMcpConfig()
|
||||
}
|
||||
|
||||
if (!disabledMcps.includes("ast_grep")) {
|
||||
mcps.ast_grep = createAstGrepMcpConfig({ cwd: options.cwd, disabledTools: config?.disabled_tools })
|
||||
}
|
||||
|
||||
return mcps
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const McpNameSchema = z.enum(["websearch", "context7", "grep_app", "lsp"])
|
||||
export const McpNameSchema = z.enum(["websearch", "context7", "grep_app", "lsp", "ast_grep"])
|
||||
|
||||
export type McpName = z.infer<typeof McpNameSchema>
|
||||
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,7 +38,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
});
|
||||
|
||||
applyToolConfig({ config, pluginConfig, agentResult });
|
||||
await applyMcpConfig({ config, pluginConfig, pluginComponents });
|
||||
await applyMcpConfig({ config, pluginConfig, ctx, pluginComponents });
|
||||
await applyCommandConfig({ config, pluginConfig, ctx, pluginComponents });
|
||||
|
||||
config.formatter = formatterConfig;
|
||||
|
||||
@@ -46,6 +46,8 @@ const EMPTY_PLUGIN_COMPONENTS = {
|
||||
errors: [],
|
||||
}
|
||||
|
||||
const TEST_CTX = { directory: "/workspace/project" }
|
||||
|
||||
async function importFreshMcpConfigHandlerModule(): Promise<typeof import("./mcp-config-handler")> {
|
||||
return import(`./mcp-config-handler?test=${Date.now()}-${Math.random()}`)
|
||||
}
|
||||
@@ -69,7 +71,7 @@ describe("applyMcpConfig collision handling", () => {
|
||||
|
||||
//#when
|
||||
const { applyMcpConfig } = await importFreshMcpConfigHandlerModule()
|
||||
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
||||
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
||||
|
||||
//#then
|
||||
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
|
||||
@@ -98,7 +100,7 @@ describe("applyMcpConfig collision handling", () => {
|
||||
|
||||
//#when
|
||||
const { applyMcpConfig } = await importFreshMcpConfigHandlerModule()
|
||||
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
||||
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
||||
|
||||
//#then
|
||||
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
|
||||
@@ -126,7 +128,7 @@ describe("applyMcpConfig collision handling", () => {
|
||||
|
||||
//#when
|
||||
const { applyMcpConfig } = await importFreshMcpConfigHandlerModule()
|
||||
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
||||
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
||||
|
||||
//#then
|
||||
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
|
||||
|
||||
@@ -42,6 +42,8 @@ const EMPTY_PLUGIN_COMPONENTS = {
|
||||
errors: [],
|
||||
}
|
||||
|
||||
const TEST_CTX = { directory: "/workspace/project" }
|
||||
|
||||
describe("applyMcpConfig", () => {
|
||||
test("preserves enabled:false from user config after merge with .mcp.json MCPs", async () => {
|
||||
//#given
|
||||
@@ -62,7 +64,7 @@ describe("applyMcpConfig", () => {
|
||||
|
||||
//#when
|
||||
const { applyMcpConfig } = await import("./mcp-config-handler")
|
||||
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
||||
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
||||
|
||||
//#then
|
||||
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
|
||||
@@ -89,6 +91,7 @@ describe("applyMcpConfig", () => {
|
||||
const { applyMcpConfig } = await import("./mcp-config-handler")
|
||||
await applyMcpConfig({
|
||||
config,
|
||||
ctx: TEST_CTX,
|
||||
pluginConfig,
|
||||
pluginComponents: {
|
||||
...EMPTY_PLUGIN_COMPONENTS,
|
||||
@@ -112,7 +115,7 @@ describe("applyMcpConfig", () => {
|
||||
|
||||
//#when
|
||||
const { applyMcpConfig } = await import("./mcp-config-handler")
|
||||
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
||||
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
||||
|
||||
//#then
|
||||
expect(loadMcpConfigsSpy).toHaveBeenCalledWith(["firecrawl", "exa"])
|
||||
@@ -135,7 +138,7 @@ describe("applyMcpConfig", () => {
|
||||
|
||||
//#when
|
||||
const { applyMcpConfig } = await import("./mcp-config-handler")
|
||||
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
||||
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
||||
|
||||
//#then
|
||||
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
|
||||
@@ -152,6 +155,7 @@ describe("applyMcpConfig", () => {
|
||||
const { applyMcpConfig } = await import("./mcp-config-handler")
|
||||
await applyMcpConfig({
|
||||
config,
|
||||
ctx: TEST_CTX,
|
||||
pluginConfig,
|
||||
pluginComponents: {
|
||||
...EMPTY_PLUGIN_COMPONENTS,
|
||||
@@ -166,4 +170,17 @@ describe("applyMcpConfig", () => {
|
||||
expect(mergedMcp).not.toHaveProperty("plugin:custom")
|
||||
})
|
||||
|
||||
test("passes the OpenCode workspace directory into built-in MCP config", async () => {
|
||||
//#given
|
||||
const config: Record<string, unknown> = { mcp: {} }
|
||||
const pluginConfig = createPluginConfig()
|
||||
|
||||
//#when
|
||||
const { applyMcpConfig } = await import("./mcp-config-handler")
|
||||
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
||||
|
||||
//#then
|
||||
expect(createBuiltinMcpsSpy).toHaveBeenCalledWith([], pluginConfig, { cwd: TEST_CTX.directory })
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ function captureUserDisabledMcps(
|
||||
|
||||
export async function applyMcpConfig(params: {
|
||||
config: Record<string, unknown>;
|
||||
ctx: { directory: string };
|
||||
pluginConfig: OhMyOpenCodeConfig;
|
||||
pluginComponents: PluginComponents;
|
||||
}): Promise<void> {
|
||||
@@ -47,7 +48,7 @@ export async function applyMcpConfig(params: {
|
||||
}
|
||||
|
||||
const merged = {
|
||||
...createBuiltinMcps(disabledMcps, params.pluginConfig),
|
||||
...createBuiltinMcps(disabledMcps, params.pluginConfig, { cwd: params.ctx.directory }),
|
||||
...mcpResult.servers,
|
||||
...(userMcp ?? {}),
|
||||
...params.pluginComponents.mcpServers,
|
||||
|
||||
@@ -62,7 +62,6 @@ const interactiveBashTool = interactiveBashEnabled ? { interactive_bash } : {}
|
||||
const allTools = {
|
||||
...createGrepTools(ctx),
|
||||
...createGlobTools(ctx),
|
||||
...createAstGrepTools(ctx),
|
||||
...createSessionManagerTools(ctx),
|
||||
...backgroundTools, // 2 background_*
|
||||
call_omo_agent, task,
|
||||
@@ -74,7 +73,7 @@ const allTools = {
|
||||
...hashlineToolsRecord, // +1 conditional
|
||||
}
|
||||
|
||||
// lsp_* tools are now supplied by built-in MCP server "lsp"
|
||||
// lsp_* and ast_grep_* tools are supplied by built-in MCP servers "lsp" and "ast_grep"
|
||||
```
|
||||
|
||||
## KEY PATTERNS
|
||||
|
||||
@@ -21,7 +21,7 @@ function getNestedRecord(record: Record<string, unknown>, key: string): Record<s
|
||||
|
||||
async function loadSeparateHostZodModule(): Promise<typeof import("zod")> {
|
||||
const pluginPackageDirectory = dirname(Bun.resolveSync("@opencode-ai/plugin/package.json", import.meta.dir))
|
||||
const sourceZodDirectory = join(pluginPackageDirectory, "node_modules", "zod")
|
||||
const sourceZodDirectory = dirname(Bun.resolveSync("zod/package.json", pluginPackageDirectory))
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "omo-host-zod-"))
|
||||
const copiedZodDirectory = join(tempDirectory, "zod")
|
||||
|
||||
|
||||
@@ -90,7 +90,6 @@ describe("team-mode tool registry wiring", () => {
|
||||
createSkillTool: mock(() => fakeTool),
|
||||
createGrepTools: mock(() => ({})),
|
||||
createGlobTools: mock(() => ({})),
|
||||
createAstGrepTools: mock(() => ({})),
|
||||
createSessionManagerTools: mock(() => ({})),
|
||||
createDelegateTask: mock(() => fakeTool),
|
||||
discoverCommandsSync: mock(() => []),
|
||||
@@ -164,7 +163,6 @@ describe("team-mode tool registry wiring", () => {
|
||||
createSkillTool: mock(() => fakeTool),
|
||||
createGrepTools: mock(() => ({})),
|
||||
createGlobTools: mock(() => ({})),
|
||||
createAstGrepTools: mock(() => ({})),
|
||||
createSessionManagerTools: mock(() => ({})),
|
||||
createDelegateTask: mock(() => fakeTool),
|
||||
discoverCommandsSync: mock(() => []),
|
||||
|
||||
@@ -53,7 +53,6 @@ const toolFactories: NonNullable<Parameters<typeof createToolRegistry>[0]["toolF
|
||||
createSkillTool: mock(() => fakeTool),
|
||||
createGrepTools: mock(() => ({})),
|
||||
createGlobTools: mock(() => ({})),
|
||||
createAstGrepTools: mock(() => ({})),
|
||||
createSessionManagerTools: mock(() => ({})),
|
||||
createDelegateTask: mock((options: { onSyncSessionCreated?: typeof syncSessionCreatedCallbacks[number] }) => {
|
||||
syncSessionCreatedCallbacks.push(options.onSyncSessionCreated)
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
createSkillTool,
|
||||
createGrepTools,
|
||||
createGlobTools,
|
||||
createAstGrepTools,
|
||||
createSessionManagerTools,
|
||||
createDelegateTask,
|
||||
discoverCommandsSync,
|
||||
@@ -59,7 +58,6 @@ type ToolRegistryFactories = {
|
||||
createSkillTool: typeof createSkillTool
|
||||
createGrepTools: typeof createGrepTools
|
||||
createGlobTools: typeof createGlobTools
|
||||
createAstGrepTools: typeof createAstGrepTools
|
||||
createSessionManagerTools: typeof createSessionManagerTools
|
||||
createDelegateTask: typeof createDelegateTask
|
||||
discoverCommandsSync: typeof discoverCommandsSync
|
||||
@@ -91,7 +89,6 @@ const defaultToolRegistryFactories: ToolRegistryFactories = {
|
||||
createSkillTool,
|
||||
createGrepTools,
|
||||
createGlobTools,
|
||||
createAstGrepTools,
|
||||
createSessionManagerTools,
|
||||
createDelegateTask,
|
||||
discoverCommandsSync,
|
||||
@@ -338,7 +335,6 @@ export function createToolRegistry(args: {
|
||||
const allTools: Record<string, ToolDefinition> = {
|
||||
...factories.createGrepTools(ctx),
|
||||
...factories.createGlobTools(ctx),
|
||||
...factories.createAstGrepTools(ctx),
|
||||
...factories.createSessionManagerTools(ctx),
|
||||
...backgroundTools,
|
||||
call_omo_agent: callOmoAgent,
|
||||
|
||||
@@ -6,11 +6,6 @@ import ts from "typescript"
|
||||
const SOURCE_ROOT = path.resolve(import.meta.dir, "..")
|
||||
const MOCK_MODULE_TOKEN = "mock.module"
|
||||
const MOCK_MODULE_LIFECYCLE_ALLOWLIST = new Map<string, string>([
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for ast-grep tool module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "tools", "ast-grep", "tools.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for team mailbox inbox module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "features", "team-mode", "team-mailbox", "inbox.test.ts"),
|
||||
@@ -195,7 +190,6 @@ describe("mock.module lifecycle hygiene", () => {
|
||||
if (!contents.includes(MOCK_MODULE_TOKEN)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const sourceFile = ts.createSourceFile(filePath, contents, ts.ScriptTarget.Latest, true)
|
||||
if (hasMockModuleCall(sourceFile) && !hasCleanupPattern(sourceFile)) {
|
||||
offenders.push(relativeSourcePath(filePath))
|
||||
|
||||
+5
-6
@@ -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
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# src/tools/ast-grep/ -- AST-Aware Search and Rewrite
|
||||
|
||||
**Generated:** 2026-05-18
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Two always-on tools: `ast_grep_search` (find AST patterns) and `ast_grep_replace` (rewrite AST patterns). 25 languages supported via `@ast-grep/napi` as primary backend with fallback to `sg` CLI.
|
||||
|
||||
Pattern syntax uses AST meta-variables, not regex. `$VAR` matches one AST node. `$$$` matches zero or more nodes. `$$$VAR` captures a named list. Patterns must be complete, parseable source code.
|
||||
|
||||
`ast_grep_replace` defaults to dry-run. Pass `dryRun=false` to apply changes.
|
||||
|
||||
## FILE CATALOG
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `tools.ts` | `createAstGrepTools` factory -- returns Record with 2 tool entries |
|
||||
| `cli.ts` | `runSg` -- spawns sg process, handles two-pass rewrite |
|
||||
| `cli-binary-path-resolution.ts` | Async init wrapper with singleton promise dedup |
|
||||
| `sg-cli-path.ts` | Resolve sg via node_modules, platform subpackages, Homebrew, or cache |
|
||||
| `downloader.ts` | Auto-download from GitHub releases if missing |
|
||||
| `environment-check.ts` | Verify CLI + NAPI availability at startup |
|
||||
| `language-support.ts` | 25 CLI languages + 5 NAPI languages + extension map |
|
||||
| `pattern-hints.ts` | Detect regex misuse and language-specific mistakes |
|
||||
| `result-formatter.ts` | Format matches with file:line:column for LLM |
|
||||
| `sg-compact-json-output.ts` | Parse `sg --json=compact` into `SgResult` |
|
||||
| `tool-descriptions.ts` | Tool description constants |
|
||||
| `process-output-timeout.ts` | 300s timeout wrapper for spawn |
|
||||
| `types.ts` | `CliMatch`, `SgResult`, `AnalyzeResult`, etc. |
|
||||
| `constants.ts` | Re-exports from language-support, environment-check, sg-cli-path |
|
||||
| `index.ts` | Barrel |
|
||||
|
||||
## KEY BEHAVIORS
|
||||
|
||||
- Dual binary detection: NAPI primary, CLI fallback
|
||||
- Fallback chain: node_modules → platform subpackage → Homebrew → cached download
|
||||
- Dry-run protection: `ast_grep_replace` defaults to preview; pass `dryRun=false` to apply
|
||||
- Two-pass rewrite: when rewrite + apply both requested, cli.ts runs `--json=compact` first, then `--update-all`
|
||||
- Output limits: 1MB max output or 500 matches, whichever comes first
|
||||
- Timeout: 300s cap via `process-output-timeout.ts`; kills process and returns truncated result
|
||||
|
||||
## LANGUAGES
|
||||
|
||||
25 CLI languages: bash, c, cpp, csharp, css, elixir, go, haskell, html, java, javascript, json, kotlin, lua, nix, php, python, ruby, rust, scala, solidity, swift, typescript, tsx, yaml.
|
||||
|
||||
5 NAPI languages (native bindings): html, javascript, tsx, css, typescript.
|
||||
|
||||
## PATTERN HINTS
|
||||
|
||||
When a search returns zero matches, `pattern-hints.ts` scans for regex-style misuse (`|`, `.*`, `\w`, `[a-z]`) and returns a corrective hint redirecting to ast-grep meta-variable syntax. Also catches language-specific mistakes like trailing colons in Python def/class patterns or incomplete function signatures in JS/Go/Rust.
|
||||
|
||||
## RELATED
|
||||
|
||||
Doctor check at `src/cli/doctor/checks/tools.ts` verifies both NAPI and CLI availability.
|
||||
@@ -1,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"
|
||||
@@ -1,119 +0,0 @@
|
||||
import { existsSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { homedir } from "os"
|
||||
import { createRequire } from "module"
|
||||
import {
|
||||
cleanupArchive,
|
||||
downloadArchive,
|
||||
ensureCacheDir,
|
||||
ensureExecutable,
|
||||
extractZipArchive,
|
||||
getCachedBinaryPath as getCachedBinaryPathShared,
|
||||
} from "../../shared/binary-downloader"
|
||||
import { log } from "../../shared/logger"
|
||||
import { CACHE_DIR_NAME, PUBLISHED_PACKAGE_NAME } from "../../shared/plugin-identity"
|
||||
|
||||
const REPO = "ast-grep/ast-grep"
|
||||
|
||||
// IMPORTANT: Update this when bumping @ast-grep/cli in package.json
|
||||
// This is only used as fallback when @ast-grep/cli package.json cannot be read
|
||||
const DEFAULT_VERSION = "0.41.1"
|
||||
|
||||
function getAstGrepVersion(): string {
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const pkg = require("@ast-grep/cli/package.json")
|
||||
return pkg.version
|
||||
} catch {
|
||||
return DEFAULT_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
interface PlatformInfo {
|
||||
arch: string
|
||||
os: string
|
||||
}
|
||||
|
||||
const PLATFORM_MAP: Record<string, PlatformInfo> = {
|
||||
"darwin-arm64": { arch: "aarch64", os: "apple-darwin" },
|
||||
"darwin-x64": { arch: "x86_64", os: "apple-darwin" },
|
||||
"linux-arm64": { arch: "aarch64", os: "unknown-linux-gnu" },
|
||||
"linux-x64": { arch: "x86_64", os: "unknown-linux-gnu" },
|
||||
"win32-x64": { arch: "x86_64", os: "pc-windows-msvc" },
|
||||
"win32-arm64": { arch: "aarch64", os: "pc-windows-msvc" },
|
||||
"win32-ia32": { arch: "i686", os: "pc-windows-msvc" },
|
||||
}
|
||||
|
||||
export function getCacheDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA
|
||||
const base = localAppData || join(homedir(), "AppData", "Local")
|
||||
return join(base, CACHE_DIR_NAME, "bin")
|
||||
}
|
||||
|
||||
const xdgCache = process.env.XDG_CACHE_HOME
|
||||
const base = xdgCache || join(homedir(), ".cache")
|
||||
return join(base, CACHE_DIR_NAME, "bin")
|
||||
}
|
||||
|
||||
export function getBinaryName(): string {
|
||||
return process.platform === "win32" ? "sg.exe" : "sg"
|
||||
}
|
||||
|
||||
export function getCachedBinaryPath(): string | null {
|
||||
return getCachedBinaryPathShared(getCacheDir(), getBinaryName())
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promise<string | null> {
|
||||
const platformKey = `${process.platform}-${process.arch}`
|
||||
const platformInfo = PLATFORM_MAP[platformKey]
|
||||
|
||||
if (!platformInfo) {
|
||||
log(`[${PUBLISHED_PACKAGE_NAME}] Unsupported platform for ast-grep: ${platformKey}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const cacheDir = getCacheDir()
|
||||
const binaryName = getBinaryName()
|
||||
const binaryPath = join(cacheDir, binaryName)
|
||||
|
||||
if (existsSync(binaryPath)) {
|
||||
return binaryPath
|
||||
}
|
||||
|
||||
const { arch, os } = platformInfo
|
||||
const assetName = `app-${arch}-${os}.zip`
|
||||
const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/${assetName}`
|
||||
|
||||
log(`[${PUBLISHED_PACKAGE_NAME}] Downloading ast-grep binary...`)
|
||||
|
||||
try {
|
||||
const archivePath = join(cacheDir, assetName)
|
||||
ensureCacheDir(cacheDir)
|
||||
await downloadArchive(downloadUrl, archivePath)
|
||||
await extractZipArchive(archivePath, cacheDir)
|
||||
cleanupArchive(archivePath)
|
||||
ensureExecutable(binaryPath)
|
||||
|
||||
log(`[${PUBLISHED_PACKAGE_NAME}] ast-grep binary ready.`)
|
||||
|
||||
return binaryPath
|
||||
} catch (err) {
|
||||
log(
|
||||
`[${PUBLISHED_PACKAGE_NAME}] Failed to download ast-grep: ${err instanceof Error ? err.message : err}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureAstGrepBinary(): Promise<string | null> {
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath) {
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
const version = getAstGrepVersion()
|
||||
return downloadAstGrep(version)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"
|
||||
@@ -1,63 +0,0 @@
|
||||
// CLI supported languages (25 total)
|
||||
export const CLI_LANGUAGES = [
|
||||
"bash",
|
||||
"c",
|
||||
"cpp",
|
||||
"csharp",
|
||||
"css",
|
||||
"elixir",
|
||||
"go",
|
||||
"haskell",
|
||||
"html",
|
||||
"java",
|
||||
"javascript",
|
||||
"json",
|
||||
"kotlin",
|
||||
"lua",
|
||||
"nix",
|
||||
"php",
|
||||
"python",
|
||||
"ruby",
|
||||
"rust",
|
||||
"scala",
|
||||
"solidity",
|
||||
"swift",
|
||||
"typescript",
|
||||
"tsx",
|
||||
"yaml",
|
||||
] as const
|
||||
|
||||
// NAPI supported languages (5 total - native bindings)
|
||||
export const NAPI_LANGUAGES = ["html", "javascript", "tsx", "css", "typescript"] as const
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 300_000
|
||||
export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024
|
||||
export const DEFAULT_MAX_MATCHES = 500
|
||||
|
||||
export const LANG_EXTENSIONS: Record<string, string[]> = {
|
||||
bash: [".bash", ".sh", ".zsh", ".bats"],
|
||||
c: [".c", ".h"],
|
||||
cpp: [".cpp", ".cc", ".cxx", ".hpp", ".hxx", ".h"],
|
||||
csharp: [".cs"],
|
||||
css: [".css"],
|
||||
elixir: [".ex", ".exs"],
|
||||
go: [".go"],
|
||||
haskell: [".hs", ".lhs"],
|
||||
html: [".html", ".htm"],
|
||||
java: [".java"],
|
||||
javascript: [".js", ".jsx", ".mjs", ".cjs"],
|
||||
json: [".json"],
|
||||
kotlin: [".kt", ".kts"],
|
||||
lua: [".lua"],
|
||||
nix: [".nix"],
|
||||
php: [".php"],
|
||||
python: [".py", ".pyi"],
|
||||
ruby: [".rb", ".rake"],
|
||||
rust: [".rs"],
|
||||
scala: [".scala", ".sc"],
|
||||
solidity: [".sol"],
|
||||
swift: [".swift"],
|
||||
typescript: [".ts", ".cts", ".mts"],
|
||||
tsx: [".tsx"],
|
||||
yaml: [".yml", ".yaml"],
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
detectLanguageSpecificMistake,
|
||||
detectRegexMisuse,
|
||||
getPatternHint,
|
||||
} from "./pattern-hints"
|
||||
|
||||
describe("detectRegexMisuse", () => {
|
||||
describe("#given pure regex alternation", () => {
|
||||
it("#when pattern is lowercase alternation #then returns alternation hint", () => {
|
||||
// given
|
||||
const pattern = "watch|WatchMode|--watch"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).not.toBeNull()
|
||||
expect(hint).toContain("|")
|
||||
expect(hint).toContain("alternation")
|
||||
expect(hint).toContain("grep")
|
||||
})
|
||||
|
||||
it("#when pattern is camelCase alternation #then returns alternation hint", () => {
|
||||
// given
|
||||
const pattern = "noEmit|NoEmit"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toContain("alternation")
|
||||
})
|
||||
|
||||
it("#when pattern mixes wildcard and alternation #then returns a hint", () => {
|
||||
// given
|
||||
const pattern = "func.*build|BuildMode|projectReferences"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given valid AST patterns using |", () => {
|
||||
it("#when pattern uses meta-vars around pipe (bitwise OR) #then returns null", () => {
|
||||
// given
|
||||
const pattern = "$A | $B"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
|
||||
it("#when pattern is a Rust closure #then returns null", () => {
|
||||
// given
|
||||
const pattern = "|x| x + 1"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given regex escape sequences", () => {
|
||||
it("#when pattern contains \\w #then returns regex-escape hint", () => {
|
||||
// given
|
||||
const pattern = "\\w+Mode"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toContain("regex escape")
|
||||
expect(hint).toContain("grep")
|
||||
})
|
||||
|
||||
it("#when pattern contains \\d #then returns regex-escape hint", () => {
|
||||
// given
|
||||
const pattern = "id\\d+"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toContain("regex escape")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given character class ranges", () => {
|
||||
it("#when pattern contains [a-z] #then returns character-class hint", () => {
|
||||
// given
|
||||
const pattern = "[a-z]+Mode"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toContain("character classes")
|
||||
expect(hint).toContain("grep")
|
||||
})
|
||||
|
||||
it("#when pattern contains [0-9] #then returns character-class hint", () => {
|
||||
// given
|
||||
const pattern = "v[0-9]+"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toContain("character classes")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given regex wildcards embedded in identifiers", () => {
|
||||
it("#when pattern uses foo.*bar without meta-vars #then returns wildcard hint", () => {
|
||||
// given
|
||||
const pattern = "func.*build"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toContain("regex wildcards")
|
||||
expect(hint).toContain("$$$")
|
||||
})
|
||||
|
||||
it("#when pattern uses $$$ (proper AST) #then returns null", () => {
|
||||
// given
|
||||
const pattern = "func $NAME($$$) { $$$ }"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given legitimate AST patterns", () => {
|
||||
it("#when pattern is a JS function #then returns null", () => {
|
||||
// given
|
||||
const pattern = "function $NAME($$$) { $$$ }"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
|
||||
it("#when pattern is console.log call #then returns null", () => {
|
||||
// given
|
||||
const pattern = "console.log($$$)"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
|
||||
it("#when pattern is a Python def #then returns null", () => {
|
||||
// given
|
||||
const pattern = "def $FUNC($$$)"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
|
||||
it("#when pattern is array access a[0] #then returns null (not character class)", () => {
|
||||
// given
|
||||
const pattern = "$A[0]"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("detectLanguageSpecificMistake", () => {
|
||||
describe("#given a Python def with trailing colon", () => {
|
||||
it("#when lang is python #then suggests removing the colon", () => {
|
||||
// given
|
||||
const pattern = "def $FUNC($$$):"
|
||||
|
||||
// when
|
||||
const hint = detectLanguageSpecificMistake(pattern, "python")
|
||||
|
||||
// then
|
||||
expect(hint).toContain("Remove trailing colon")
|
||||
expect(hint).toContain("def $FUNC($$$)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a Python class with trailing colon", () => {
|
||||
it("#when lang is python #then suggests removing the colon", () => {
|
||||
// given
|
||||
const pattern = "class $C:"
|
||||
|
||||
// when
|
||||
const hint = detectLanguageSpecificMistake(pattern, "python")
|
||||
|
||||
// then
|
||||
expect(hint).toContain("Remove trailing colon")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a TypeScript function with no body", () => {
|
||||
it("#when lang is typescript #then suggests adding params and body", () => {
|
||||
// given
|
||||
const pattern = "function $NAME"
|
||||
|
||||
// when
|
||||
const hint = detectLanguageSpecificMistake(pattern, "typescript")
|
||||
|
||||
// then
|
||||
expect(hint).toContain("params and body")
|
||||
expect(hint).toContain("function $NAME($$$) { $$$ }")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a Go function with no body", () => {
|
||||
it("#when lang is go #then suggests Go function template", () => {
|
||||
// given
|
||||
const pattern = "func $NAME"
|
||||
|
||||
// when
|
||||
const hint = detectLanguageSpecificMistake(pattern, "go")
|
||||
|
||||
// then
|
||||
expect(hint).not.toBeNull()
|
||||
expect(hint).toContain("func $NAME($$$) { $$$ }")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a Rust fn with no body", () => {
|
||||
it("#when lang is rust #then suggests Rust fn template", () => {
|
||||
// given
|
||||
const pattern = "fn $NAME"
|
||||
|
||||
// when
|
||||
const hint = detectLanguageSpecificMistake(pattern, "rust")
|
||||
|
||||
// then
|
||||
expect(hint).not.toBeNull()
|
||||
expect(hint).toContain("fn $NAME($$$) { $$$ }")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPatternHint", () => {
|
||||
it("#given regex alternation #when composing #then regex hint wins over language check", () => {
|
||||
// given
|
||||
const pattern = "foo|bar"
|
||||
|
||||
// when
|
||||
const hint = getPatternHint(pattern, "typescript")
|
||||
|
||||
// then
|
||||
expect(hint).toContain("alternation")
|
||||
})
|
||||
|
||||
it("#given a clean AST pattern #when composing #then returns null", () => {
|
||||
// given
|
||||
const pattern = "function $NAME($$$) { $$$ }"
|
||||
|
||||
// when
|
||||
const hint = getPatternHint(pattern, "typescript")
|
||||
|
||||
// then
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
|
||||
it("#given a Python def with trailing colon #when composing #then returns the colon hint", () => {
|
||||
// given
|
||||
const pattern = "def $FUNC($$$):"
|
||||
|
||||
// when
|
||||
const hint = getPatternHint(pattern, "python")
|
||||
|
||||
// then
|
||||
expect(hint).toContain("Remove trailing colon")
|
||||
})
|
||||
})
|
||||
@@ -1,171 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
AST_GREP_REPLACE_DESCRIPTION,
|
||||
AST_GREP_SEARCH_DESCRIPTION,
|
||||
AST_GREP_SEARCH_PATTERN_PARAM,
|
||||
} from "./tool-descriptions"
|
||||
|
||||
describe("AST_GREP_SEARCH_DESCRIPTION", () => {
|
||||
it("#given the description #when inspecting #then asserts it is NOT regex", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("NOT regex")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then explains meta-variables $VAR and $$$", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("$VAR")
|
||||
expect(description).toContain("$$$")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then warns against regex alternation", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("alternation")
|
||||
expect(description).toContain("|")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then warns against regex wildcards", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain(".*")
|
||||
expect(description).toContain("wildcards")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then warns against regex escapes", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("\\w")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then warns against character classes", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("[a-z]")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then tells LLM to use grep as fallback", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description.toLowerCase()).toContain("grep")
|
||||
})
|
||||
|
||||
it("#given the description #when showing Python example #then omits the trailing colon bug", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).not.toContain("def $FUNC($$$):")
|
||||
expect(description).toContain("def $FUNC($$$)")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then shows TypeScript example", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("typescript")
|
||||
expect(description).toContain("function $NAME($$$) { $$$ }")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then shows Go example", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("go")
|
||||
expect(description).toContain("func $NAME($$$) { $$$ }")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then shows Rust example", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("rust")
|
||||
expect(description).toContain("fn $NAME(")
|
||||
})
|
||||
|
||||
it("#given the description #when measuring #then stays within a token-reasonable length", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description.length).toBeLessThan(2000)
|
||||
expect(description.length).toBeGreaterThan(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe("AST_GREP_SEARCH_PATTERN_PARAM", () => {
|
||||
it("#given the param description #when inspecting #then states meta-var rules", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_PATTERN_PARAM
|
||||
|
||||
// then
|
||||
expect(description).toContain("$VAR")
|
||||
expect(description).toContain("$$$")
|
||||
})
|
||||
|
||||
it("#given the param description #when inspecting #then forbids regex syntax", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_PATTERN_PARAM
|
||||
|
||||
// then
|
||||
expect(description).toContain("NOT regex")
|
||||
expect(description).toContain("|")
|
||||
expect(description).toContain(".*")
|
||||
})
|
||||
|
||||
it("#given the param description #when inspecting #then directs to grep for fallback", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_PATTERN_PARAM
|
||||
|
||||
// then
|
||||
expect(description.toLowerCase()).toContain("grep")
|
||||
})
|
||||
})
|
||||
|
||||
describe("AST_GREP_REPLACE_DESCRIPTION", () => {
|
||||
it("#given the description #when inspecting #then mentions AST meta-variables", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_REPLACE_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("$VAR")
|
||||
expect(description).toContain("$$$")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then warns against regex", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_REPLACE_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description.toLowerCase()).toContain("regex does not work")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then provides an example", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_REPLACE_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("console.log($MSG)")
|
||||
expect(description).toContain("logger.info($MSG)")
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { AST_GREP_REPLACE_DESCRIPTION, AST_GREP_SEARCH_DESCRIPTION } from "./tool-descriptions"
|
||||
|
||||
const runSgMock = mock(async () => ({
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
}))
|
||||
|
||||
mock.module("./cli", () => ({
|
||||
runSg: runSgMock,
|
||||
}))
|
||||
|
||||
import { createAstGrepTools } from "./tools"
|
||||
|
||||
describe("createAstGrepTools", () => {
|
||||
beforeEach(() => {
|
||||
runSgMock.mockClear()
|
||||
})
|
||||
|
||||
it("#given the production tool factory #when creating tools #then exposes shared ast-grep descriptions", () => {
|
||||
// given / when
|
||||
const tools = createAstGrepTools({ directory: "/repo" } as never)
|
||||
|
||||
// then
|
||||
expect(tools.ast_grep_search.description).toBe(AST_GREP_SEARCH_DESCRIPTION)
|
||||
expect(tools.ast_grep_replace.description).toBe(AST_GREP_REPLACE_DESCRIPTION)
|
||||
expect(tools.ast_grep_search.description).toContain("NOT regex")
|
||||
})
|
||||
|
||||
it("#given empty search results from a regex-shaped pattern #when executing #then appends the pattern hint", async () => {
|
||||
// given
|
||||
const tools = createAstGrepTools({ directory: "/repo" } as never)
|
||||
|
||||
// when
|
||||
const output = await tools.ast_grep_search.execute(
|
||||
{ pattern: "foo|bar", lang: "typescript" },
|
||||
{},
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output).toContain("No matches found")
|
||||
expect(output).toContain("alternation")
|
||||
expect(output).toContain("grep")
|
||||
expect(runSgMock).toHaveBeenCalledWith({
|
||||
pattern: "foo|bar",
|
||||
lang: "typescript",
|
||||
paths: ["/repo"],
|
||||
globs: undefined,
|
||||
context: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { CLI_LANGUAGES } from "./constants"
|
||||
import { runSg } from "./cli"
|
||||
import { formatSearchResult, formatReplaceResult } from "./result-formatter"
|
||||
import { getPatternHint } from "./pattern-hints"
|
||||
import {
|
||||
AST_GREP_REPLACE_DESCRIPTION,
|
||||
AST_GREP_SEARCH_DESCRIPTION,
|
||||
AST_GREP_SEARCH_PATTERN_PARAM,
|
||||
} from "./tool-descriptions"
|
||||
import type { CliLanguage } from "./types"
|
||||
|
||||
async function showOutputToUser(context: unknown, output: string): Promise<void> {
|
||||
const ctx = context as {
|
||||
metadata?: (input: { metadata: { output: string } }) => void | Promise<void>
|
||||
}
|
||||
await ctx.metadata?.({ metadata: { output } })
|
||||
}
|
||||
|
||||
export function createAstGrepTools(ctx: PluginInput): Record<string, ToolDefinition> {
|
||||
const ast_grep_search: ToolDefinition = tool({
|
||||
description: AST_GREP_SEARCH_DESCRIPTION,
|
||||
args: {
|
||||
pattern: tool.schema.string().describe(AST_GREP_SEARCH_PATTERN_PARAM),
|
||||
lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
|
||||
paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search (default: ['.'])"),
|
||||
globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs (prefix ! to exclude)"),
|
||||
context: tool.schema.number().optional().describe("Context lines around match"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const result = await runSg({
|
||||
pattern: args.pattern,
|
||||
lang: args.lang as CliLanguage,
|
||||
paths: args.paths ?? [ctx.directory],
|
||||
globs: args.globs,
|
||||
context: args.context,
|
||||
})
|
||||
|
||||
let output = formatSearchResult(result)
|
||||
|
||||
if (result.matches.length === 0 && !result.error) {
|
||||
const hint = getPatternHint(args.pattern, args.lang as CliLanguage)
|
||||
if (hint) {
|
||||
output += `\n\n${hint}`
|
||||
}
|
||||
}
|
||||
|
||||
await showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
await showOutputToUser(context, output)
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const ast_grep_replace: ToolDefinition = tool({
|
||||
description: AST_GREP_REPLACE_DESCRIPTION,
|
||||
args: {
|
||||
pattern: tool.schema.string().describe("AST pattern to match"),
|
||||
rewrite: tool.schema.string().describe("Replacement pattern (can use $VAR from pattern)"),
|
||||
lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
|
||||
paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search"),
|
||||
globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs"),
|
||||
dryRun: tool.schema.boolean().optional().describe("Preview changes without applying (default: true)"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const result = await runSg({
|
||||
pattern: args.pattern,
|
||||
rewrite: args.rewrite,
|
||||
lang: args.lang as CliLanguage,
|
||||
paths: args.paths ?? [ctx.directory],
|
||||
globs: args.globs,
|
||||
updateAll: args.dryRun === false,
|
||||
})
|
||||
const output = formatReplaceResult(result, args.dryRun !== false)
|
||||
await showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
await showOutputToUser(context, output)
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return { ast_grep_search, ast_grep_replace }
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export { createAstGrepTools } from "./ast-grep"
|
||||
export { createGrepTools } from "./grep"
|
||||
export { createGlobTools } from "./glob"
|
||||
export { createSkillTool } from "./skill"
|
||||
|
||||
Reference in New Issue
Block a user