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
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: bun test
|
run: bun test
|
||||||
@@ -83,7 +83,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: bun run typecheck
|
run: bun run typecheck
|
||||||
@@ -118,7 +118,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: bun run build
|
run: bun run build
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
|
||||||
|
|
||||||
- name: Validate release inputs
|
- name: Validate release inputs
|
||||||
id: validate
|
id: validate
|
||||||
@@ -187,6 +187,7 @@ jobs:
|
|||||||
retry_wait_seconds: 10
|
retry_wait_seconds: 10
|
||||||
shell: bash
|
shell: bash
|
||||||
command: |
|
command: |
|
||||||
|
bun run build:ast-grep-mcp
|
||||||
PLATFORM="${{ matrix.platform }}"
|
PLATFORM="${{ matrix.platform }}"
|
||||||
PACKAGE_DIR="packages/oh-my-opencode-${PLATFORM}"
|
PACKAGE_DIR="packages/oh-my-opencode-${PLATFORM}"
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: bun test
|
run: bun test
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: bun run typecheck
|
run: bun run typecheck
|
||||||
@@ -162,7 +162,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
|
||||||
|
|
||||||
- name: Calculate version
|
- name: Calculate version
|
||||||
id: version
|
id: version
|
||||||
@@ -232,11 +232,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build main package
|
- name: Build main package
|
||||||
if: steps.check.outputs.skip != 'true'
|
if: steps.check.outputs.skip != 'true'
|
||||||
run: |
|
run: bun run build
|
||||||
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
|
|
||||||
|
|
||||||
- name: Strip token auth from .npmrc to force OIDC
|
- name: Strip token auth from .npmrc to force OIDC
|
||||||
if: steps.check.outputs.skip != 'true'
|
if: steps.check.outputs.skip != 'true'
|
||||||
@@ -330,7 +326,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
|
||||||
|
|
||||||
- name: Generate changelog
|
- name: Generate changelog
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
|
||||||
|
|
||||||
- name: Refresh bundled model capabilities snapshot
|
- name: Refresh bundled model capabilities snapshot
|
||||||
run: bun run build:model-capabilities
|
run: bun run build:model-capabilities
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ oh-my-opencode/
|
|||||||
│ ├── create-hooks.ts # 5-tier hook composition
|
│ ├── create-hooks.ts # 5-tier hook composition
|
||||||
│ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior)
|
│ ├── 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)
|
│ ├── 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.)
|
│ ├── 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)
|
│ ├── 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)
|
│ ├── config/ # Zod v4 schema system (30 schema files)
|
||||||
│ ├── cli/ # CLI: install, run, doctor, mcp-oauth, refresh-model-capabilities, get-local-version, boulder
|
│ ├── 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/ # 10 OpenCode hook handlers + 5-tier hook composition
|
||||||
│ ├── plugin-handlers/ # 6-phase config loading pipeline
|
│ ├── plugin-handlers/ # 6-phase config loading pipeline
|
||||||
│ ├── openclaw/ # Bidirectional external integration (Discord/Telegram/HTTP/shell + reply listener daemon)
|
│ ├── 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`.
|
**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`).
|
**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 |
|
| 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`) |
|
| 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 |
|
| 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",
|
"vscode-jsonrpc": "^8.2.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@oh-my-opencode/ast-grep-mcp": "workspace:*",
|
||||||
|
"@oh-my-opencode/rules-core": "workspace:*",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/picomatch": "^4.0.3",
|
"@types/picomatch": "^4.0.3",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20260518.1",
|
"@typescript/native-preview": "7.0.0-dev.20260518.1",
|
||||||
@@ -48,6 +50,26 @@
|
|||||||
"zod": "^4.0.0",
|
"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": [
|
"trustedDependencies": [
|
||||||
"@ast-grep/cli",
|
"@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=="],
|
"@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-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=="],
|
"@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
|
### 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
|
```json
|
||||||
{ "disabled_mcps": ["websearch", "context7", "grep_app"] }
|
{ "disabled_mcps": ["websearch", "context7", "grep_app", "lsp", "ast_grep"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
### LSP
|
### LSP
|
||||||
|
|||||||
@@ -611,6 +611,8 @@ Hashline IDs use characters from `ZPMQVRWSNKTXJBYH`.
|
|||||||
|
|
||||||
### AST-Grep Tools
|
### 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 |
|
| Tool | Description |
|
||||||
| -------------------- | -------------------------------------------- |
|
| -------------------- | -------------------------------------------- |
|
||||||
| **ast_grep_search** | AST-aware code pattern search (25 languages) |
|
| **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:
|
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
|
2. Claude Code `.mcp.json` loader with `${VAR}` expansion
|
||||||
3. Skill-embedded MCP servers declared in `SKILL.md` frontmatter
|
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 |
|
| **websearch** | Real-time web search powered by Exa AI |
|
||||||
| **context7** | Official documentation lookup for any library/framework |
|
| **context7** | Official documentation lookup for any library/framework |
|
||||||
| **grep_app** | Ultra-fast code search across public GitHub repos. Great for finding implementation examples. |
|
| **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
|
### Skill-Embedded MCPs
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -5,6 +5,10 @@
|
|||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"workspaces": [
|
||||||
|
"packages/rules-core",
|
||||||
|
"packages/ast-grep-mcp"
|
||||||
|
],
|
||||||
"bin": {
|
"bin": {
|
||||||
"oh-my-opencode": "bin/oh-my-opencode.js",
|
"oh-my-opencode": "bin/oh-my-opencode.js",
|
||||||
"oh-my-openagent": "bin/oh-my-opencode.js"
|
"oh-my-openagent": "bin/oh-my-opencode.js"
|
||||||
@@ -13,7 +17,8 @@
|
|||||||
"dist",
|
"dist",
|
||||||
"bin",
|
"bin",
|
||||||
"postinstall.mjs",
|
"postinstall.mjs",
|
||||||
"packages/lsp-tools-mcp/dist"
|
"packages/lsp-tools-mcp/dist",
|
||||||
|
"packages/ast-grep-mcp/dist"
|
||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
@@ -23,7 +28,7 @@
|
|||||||
"./schema.json": "./dist/oh-my-opencode.schema.json"
|
"./schema.json": "./dist/oh-my-opencode.schema.json"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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: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:node-require-shim": "bun run script/patch-node-require-shim.ts",
|
||||||
"build:all": "bun run build && bun run build:binaries",
|
"build:all": "bun run build && bun run build:binaries",
|
||||||
@@ -35,9 +40,11 @@
|
|||||||
"postinstall": "node postinstall.mjs",
|
"postinstall": "node postinstall.mjs",
|
||||||
"prepublishOnly": "bun run clean && bun run build:lsp-tools-mcp && bun run build",
|
"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",
|
"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",
|
"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": [
|
"keywords": [
|
||||||
"opencode",
|
"opencode",
|
||||||
@@ -78,6 +85,8 @@
|
|||||||
"vscode-jsonrpc": "^8.2.1"
|
"vscode-jsonrpc": "^8.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@oh-my-opencode/ast-grep-mcp": "workspace:*",
|
||||||
|
"@oh-my-opencode/rules-core": "workspace:*",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20260518.1",
|
"@typescript/native-preview": "7.0.0-dev.20260518.1",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/picomatch": "^4.0.3",
|
"@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 { existsSync } from "fs"
|
||||||
|
|
||||||
import { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./constants"
|
import { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./constants"
|
||||||
import { ensureAstGrepBinary } from "./downloader"
|
|
||||||
|
|
||||||
let resolvedCliPath: string | null = null
|
let resolvedCliPath: string | null = null
|
||||||
let initPromise: Promise<string | null> | null = null
|
let initPromise: Promise<string | null> | null = null
|
||||||
@@ -23,13 +22,6 @@ export async function getAstGrepPath(): Promise<string | null> {
|
|||||||
return syncPath
|
return syncPath
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadedPath = await ensureAstGrepBinary()
|
|
||||||
if (downloadedPath) {
|
|
||||||
resolvedCliPath = downloadedPath
|
|
||||||
setSgCliPath(downloadedPath)
|
|
||||||
return downloadedPath
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
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 {
|
export function formatSearchResult(result: SgResult): string {
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
@@ -68,35 +68,3 @@ export function formatReplaceResult(result: SgResult, isDryRun: boolean): string
|
|||||||
|
|
||||||
return lines.join("\n")
|
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 { existsSync } from "fs"
|
||||||
import {
|
import {
|
||||||
getSgCliPath,
|
getSgCliPath,
|
||||||
DEFAULT_TIMEOUT_MS,
|
DEFAULT_TIMEOUT_MS,
|
||||||
} from "./constants"
|
} from "./constants"
|
||||||
import { ensureAstGrepBinary } from "./downloader"
|
|
||||||
import type { CliLanguage, SgResult } from "./types"
|
import type { CliLanguage, SgResult } from "./types"
|
||||||
|
|
||||||
import { getAstGrepPath } from "./cli-binary-path-resolution"
|
import { getAstGrepPath } from "./cli-binary-path-resolution"
|
||||||
@@ -21,8 +20,9 @@ export {
|
|||||||
export interface RunOptions {
|
export interface RunOptions {
|
||||||
pattern: string
|
pattern: string
|
||||||
lang: CliLanguage
|
lang: CliLanguage
|
||||||
paths?: string[]
|
cwd?: string
|
||||||
globs?: string[]
|
paths?: readonly string[]
|
||||||
|
globs?: readonly string[]
|
||||||
rewrite?: string
|
rewrite?: string
|
||||||
context?: number
|
context?: number
|
||||||
updateAll?: boolean
|
updateAll?: boolean
|
||||||
@@ -35,34 +35,14 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
|
|||||||
// another with --update-all to perform the actual file writes.
|
// another with --update-all to perform the actual file writes.
|
||||||
const shouldSeparateWritePass = !!(options.rewrite && options.updateAll)
|
const shouldSeparateWritePass = !!(options.rewrite && options.updateAll)
|
||||||
|
|
||||||
const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
|
const args = createSgArgs(options, { includeJson: true, includeUpdateAll: false })
|
||||||
|
|
||||||
if (options.rewrite) {
|
|
||||||
args.push("-r", options.rewrite)
|
|
||||||
if (options.updateAll && !shouldSeparateWritePass) {
|
|
||||||
args.push("--update-all")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.context && options.context > 0) {
|
|
||||||
args.push("-C", String(options.context))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.globs) {
|
|
||||||
for (const glob of options.globs) {
|
|
||||||
args.push("--globs", glob)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const paths = options.paths && options.paths.length > 0 ? options.paths : ["."]
|
|
||||||
args.push(...paths)
|
|
||||||
|
|
||||||
let cliPath = getSgCliPath()
|
let cliPath = getSgCliPath()
|
||||||
|
|
||||||
if (!cliPath || !existsSync(cliPath)) {
|
if (!cliPath || !existsSync(cliPath)) {
|
||||||
const downloadedPath = await getAstGrepPath()
|
const resolvedPath = await getAstGrepPath()
|
||||||
if (downloadedPath) {
|
if (resolvedPath) {
|
||||||
cliPath = downloadedPath
|
cliPath = resolvedPath
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
matches: [],
|
matches: [],
|
||||||
@@ -81,6 +61,7 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
|
|||||||
const timeout = DEFAULT_TIMEOUT_MS
|
const timeout = DEFAULT_TIMEOUT_MS
|
||||||
|
|
||||||
const proc = spawn([cliPath, ...args], {
|
const proc = spawn([cliPath, ...args], {
|
||||||
|
cwd: options.cwd,
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "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 errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
const errorCode =
|
const errorCode = errorCodeFrom(error)
|
||||||
typeof error === "object" && error !== null && "code" in error
|
|
||||||
? (error as { code?: unknown }).code
|
|
||||||
: undefined
|
|
||||||
const isNoEntry =
|
const isNoEntry =
|
||||||
errorCode === "ENOENT" || errorMessage.includes("ENOENT") || errorMessage.includes("not found")
|
errorCode === "ENOENT" || errorMessage.includes("ENOENT") || errorMessage.includes("not found")
|
||||||
|
|
||||||
if (isNoEntry) {
|
if (isNoEntry) {
|
||||||
const downloadedPath = await ensureAstGrepBinary()
|
|
||||||
if (downloadedPath) {
|
|
||||||
return runSg(options)
|
|
||||||
} else {
|
|
||||||
return {
|
return {
|
||||||
matches: [],
|
matches: [],
|
||||||
totalMatches: 0,
|
totalMatches: 0,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
error:
|
error:
|
||||||
`ast-grep CLI binary not found.\n\n` +
|
`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` +
|
` bun add -D @ast-grep/cli\n` +
|
||||||
` cargo install ast-grep --locked\n` +
|
` cargo install ast-grep --locked\n` +
|
||||||
` brew install ast-grep`,
|
` brew install ast-grep`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
matches: [],
|
matches: [],
|
||||||
@@ -153,10 +126,10 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
|
|||||||
const jsonResult = createSgResultFromStdout(stdout)
|
const jsonResult = createSgResultFromStdout(stdout)
|
||||||
|
|
||||||
if (shouldSeparateWritePass && jsonResult.matches.length > 0) {
|
if (shouldSeparateWritePass && jsonResult.matches.length > 0) {
|
||||||
const writeArgs = args.filter(a => a !== "--json=compact")
|
const writeArgs = createSgArgs(options, { includeJson: false, includeUpdateAll: true })
|
||||||
writeArgs.push("--update-all")
|
|
||||||
|
|
||||||
const writeProc = spawn([cliPath, ...writeArgs], {
|
const writeProc = spawn([cliPath, ...writeArgs], {
|
||||||
|
cwd: options.cwd,
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
})
|
})
|
||||||
@@ -175,3 +148,37 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
|
|||||||
|
|
||||||
return jsonResult
|
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 { dirname, join } from "path"
|
||||||
import { existsSync, statSync } from "fs"
|
import { existsSync, statSync } from "fs"
|
||||||
|
|
||||||
import { getCachedBinaryPath } from "./downloader"
|
|
||||||
|
|
||||||
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
||||||
|
|
||||||
function isValidBinary(filePath: string): boolean {
|
function isValidBinary(filePath: string): boolean {
|
||||||
@@ -34,11 +32,6 @@ function getPlatformPackageName(): string | null {
|
|||||||
export function findSgCliPathSync(): string | null {
|
export function findSgCliPathSync(): string | null {
|
||||||
const binaryName = process.platform === "win32" ? "sg.exe" : "sg"
|
const binaryName = process.platform === "win32" ? "sg.exe" : "sg"
|
||||||
|
|
||||||
const cachedPath = getCachedBinaryPath()
|
|
||||||
if (cachedPath && isValidBinary(cachedPath)) {
|
|
||||||
return cachedPath
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
const cliPackageJsonPath = require.resolve("@ast-grep/cli/package.json")
|
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 CliLanguage = (typeof CLI_LANGUAGES)[number]
|
||||||
export type NapiLanguage = (typeof NAPI_LANGUAGES)[number]
|
|
||||||
|
|
||||||
export interface Position {
|
export interface Position {
|
||||||
line: number
|
line: number
|
||||||
@@ -26,31 +25,6 @@ export interface CliMatch {
|
|||||||
language: string
|
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 {
|
export interface SgResult {
|
||||||
matches: CliMatch[]
|
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(` Entry point: ${ENTRY_POINT}`);
|
||||||
console.log(` Platforms: ${PLATFORMS.length}`);
|
console.log(` Platforms: ${PLATFORMS.length}`);
|
||||||
|
|
||||||
|
await $`bun run build:ast-grep-mcp`;
|
||||||
|
|
||||||
// Verify entry point exists
|
// Verify entry point exists
|
||||||
if (!existsSync(ENTRY_POINT)) {
|
if (!existsSync(ENTRY_POINT)) {
|
||||||
console.error(`\n❌ Entry point not found: ${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) |
|
| `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) |
|
| `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-*) |
|
| `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 |
|
| `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) |
|
| `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 |
|
| `plugin-handlers/` | 27 | ~6k | 6-phase config loading pipeline | yes |
|
||||||
| `openclaw/` | 26 | ~3k | Bidirectional Discord/Telegram/HTTP integration | yes |
|
| `openclaw/` | 26 | ~3k | Bidirectional Discord/Telegram/HTTP integration | yes |
|
||||||
| `__tests__/` | 22 | ~300 | Plugin-level integration tests + perf fixtures | — |
|
| `__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 | — |
|
| `testing/` | 3 | ~225 | Test utilities | — |
|
||||||
|
|
||||||
## NOTES
|
## NOTES
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { join } from "node:path"
|
|||||||
import type { McpServerInfo } from "../types"
|
import type { McpServerInfo } from "../types"
|
||||||
import { parseJsonc } from "../../../shared"
|
import { parseJsonc } from "../../../shared"
|
||||||
|
|
||||||
const BUILTIN_MCP_SERVERS = ["context7", "grep_app"]
|
const BUILTIN_MCP_SERVERS = ["websearch", "context7", "grep_app", "lsp", "ast_grep"]
|
||||||
|
|
||||||
interface McpConfigShape {
|
interface McpConfigShape {
|
||||||
mcpServers?: Record<string, unknown>
|
mcpServers?: Record<string, unknown>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { constants, promises as fsPromises } from "node:fs";
|
import { findAgentsMdUp as findAgentsMdUpCore } from "@oh-my-opencode/rules-core";
|
||||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
import type { AgentsMdCache } from "@oh-my-opencode/rules-core";
|
||||||
|
import { isAbsolute, resolve } from "node:path";
|
||||||
import { AGENTS_FILENAME } from "./constants";
|
|
||||||
|
|
||||||
export function resolveFilePath(rootDirectory: string, path: string): string | null {
|
export function resolveFilePath(rootDirectory: string, path: string): string | null {
|
||||||
if (!path) return null;
|
if (!path) return null;
|
||||||
@@ -10,33 +9,9 @@ export function resolveFilePath(rootDirectory: string, path: string): string | n
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function findAgentsMdUp(input: {
|
export async function findAgentsMdUp(input: {
|
||||||
startDir: string;
|
readonly startDir: string;
|
||||||
rootDir: string;
|
readonly rootDir: string;
|
||||||
|
readonly cache?: AgentsMdCache;
|
||||||
}): Promise<string[]> {
|
}): Promise<string[]> {
|
||||||
const found: string[] = [];
|
return findAgentsMdUpCore({ startDir: input.startDir, rootDir: input.rootDir, cache: input.cache });
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createAgentsMdCache } from "@oh-my-opencode/rules-core";
|
||||||
import type { PluginInput } from "@opencode-ai/plugin";
|
import type { PluginInput } from "@opencode-ai/plugin";
|
||||||
|
|
||||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||||
@@ -35,6 +36,7 @@ export function createDirectoryAgentsInjectorHook(
|
|||||||
modelCacheState?: { anthropicContext1MEnabled: boolean },
|
modelCacheState?: { anthropicContext1MEnabled: boolean },
|
||||||
): DirectoryAgentsInjectorHook {
|
): DirectoryAgentsInjectorHook {
|
||||||
const sessionCaches = new Map<string, Set<string>>();
|
const sessionCaches = new Map<string, Set<string>>();
|
||||||
|
const agentsMdCache = createAgentsMdCache();
|
||||||
const truncator = createDynamicTruncator(ctx, modelCacheState);
|
const truncator = createDynamicTruncator(ctx, modelCacheState);
|
||||||
|
|
||||||
const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
|
const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
|
||||||
@@ -45,6 +47,7 @@ export function createDirectoryAgentsInjectorHook(
|
|||||||
ctx,
|
ctx,
|
||||||
truncator,
|
truncator,
|
||||||
sessionCaches,
|
sessionCaches,
|
||||||
|
agentsMdCache,
|
||||||
filePath: output.title,
|
filePath: output.title,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
output,
|
output,
|
||||||
@@ -54,21 +57,21 @@ export function createDirectoryAgentsInjectorHook(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const eventHandler = async ({ event }: EventInput) => {
|
const eventHandler = async ({ event }: EventInput) => {
|
||||||
const props = event.properties as Record<string, unknown> | undefined;
|
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionID = resolveSessionEventID(props);
|
const sessionID = resolveSessionEventID(event.properties);
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
sessionCaches.delete(sessionID);
|
sessionCaches.delete(sessionID);
|
||||||
clearInjectedPaths(sessionID);
|
clearInjectedPaths(sessionID);
|
||||||
|
agentsMdCache.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.compacted") {
|
if (event.type === "session.compacted") {
|
||||||
const sessionID = resolveSessionEventID(props);
|
const sessionID = resolveSessionEventID(event.properties);
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
sessionCaches.delete(sessionID);
|
sessionCaches.delete(sessionID);
|
||||||
clearInjectedPaths(sessionID);
|
clearInjectedPaths(sessionID);
|
||||||
|
agentsMdCache.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin";
|
import type { PluginInput } from "@opencode-ai/plugin";
|
||||||
|
import type { AgentsMdCache } from "@oh-my-opencode/rules-core";
|
||||||
import { promises as fsPromises } from "node:fs";
|
import { promises as fsPromises } from "node:fs";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
@@ -15,13 +16,18 @@ function getSessionCache(
|
|||||||
if (!sessionCaches.has(sessionID)) {
|
if (!sessionCaches.has(sessionID)) {
|
||||||
sessionCaches.set(sessionID, loadInjectedPaths(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: {
|
export async function processFilePathForAgentsInjection(input: {
|
||||||
ctx: PluginInput;
|
ctx: PluginInput;
|
||||||
truncator: DynamicTruncator;
|
truncator: DynamicTruncator;
|
||||||
sessionCaches: Map<string, Set<string>>;
|
sessionCaches: Map<string, Set<string>>;
|
||||||
|
agentsMdCache?: AgentsMdCache;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
sessionID: string;
|
sessionID: string;
|
||||||
output: { title: string; output: string; metadata: unknown };
|
output: { title: string; output: string; metadata: unknown };
|
||||||
@@ -35,26 +41,29 @@ export async function processFilePathForAgentsInjection(input: {
|
|||||||
|
|
||||||
const dir = dirname(resolved);
|
const dir = dirname(resolved);
|
||||||
const cache = getSessionCache(input.sessionCaches, input.sessionID);
|
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;
|
let dirty = false;
|
||||||
for (const agentsPath of agentsPaths) {
|
for (const agentsPath of agentsPaths) {
|
||||||
const agentsDir = dirname(agentsPath);
|
const agentsDir = dirname(agentsPath);
|
||||||
if (cache.has(agentsDir)) continue;
|
if (cache.has(agentsDir)) continue;
|
||||||
|
|
||||||
try {
|
const content = await fsPromises.readFile(agentsPath, "utf-8").catch(() => null);
|
||||||
const content = await fsPromises.readFile(agentsPath, "utf-8");
|
if (content === null) continue;
|
||||||
cache.add(agentsDir);
|
cache.add(agentsDir);
|
||||||
const { result, truncated } = await input.truncator.truncate(
|
const { result, truncated } = await input.truncator.truncate(
|
||||||
input.sessionID,
|
input.sessionID,
|
||||||
content,
|
content,
|
||||||
);
|
);
|
||||||
const truncationNotice = truncated
|
const truncationNotice = truncated
|
||||||
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]`
|
? `\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}`;
|
input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`;
|
||||||
dirty = true;
|
dirty = true;
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dirty) {
|
if (dirty) {
|
||||||
|
|||||||
@@ -1,100 +1,13 @@
|
|||||||
import { createHash } from "crypto"
|
export {
|
||||||
import { relative } from "node:path"
|
createContentHash,
|
||||||
import picomatch from "picomatch"
|
getMatcherCacheStats,
|
||||||
import type { RuleMetadata } from "./types"
|
isDuplicateByContentHash,
|
||||||
|
isDuplicateByRealPath,
|
||||||
type PathMatcher = (path: string) => boolean
|
resetMatcherCache,
|
||||||
|
shouldApplyRule,
|
||||||
export interface MatchResult {
|
} from "@oh-my-opencode/rules-core";
|
||||||
applies: boolean
|
export type { MatchResult } from "@oh-my-opencode/rules-core";
|
||||||
reason?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MatcherCacheStats {
|
export interface MatcherCacheStats {
|
||||||
entries: number
|
readonly 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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,211 +1,2 @@
|
|||||||
import type { RuleMetadata } from "./types";
|
export { parseRuleFrontmatter } from "@oh-my-opencode/rules-core";
|
||||||
|
export type { RuleFrontmatterResult } from "@oh-my-opencode/rules-core";
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,85 +1 @@
|
|||||||
import { existsSync, statSync } from "node:fs";
|
export { clearProjectRootCache, findProjectRoot } from "@oh-my-opencode/rules-core";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,53 +1 @@
|
|||||||
import { dirname, relative } from "node:path";
|
export { calculateDistance } from "@oh-my-opencode/rules-core";
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,148 +1,2 @@
|
|||||||
import { existsSync, statSync } from "node:fs";
|
export { findRuleFiles } from "@oh-my-opencode/rules-core";
|
||||||
import { dirname, join } from "node:path";
|
export type { FindRuleFilesOptions } from "@oh-my-opencode/rules-core";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,57 +1,10 @@
|
|||||||
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
import { findRuleFilesRecursive as findRuleFileEntriesRecursive, safeRealpathSync } from "@oh-my-opencode/rules-core";
|
||||||
import { join } from "node:path";
|
import type { DirectoryScanEntry } from "@oh-my-opencode/rules-core";
|
||||||
import { EXCLUDED_DIRS } from "../../shared";
|
|
||||||
import { GITHUB_INSTRUCTIONS_PATTERN, RULE_EXTENSIONS } from "./constants";
|
|
||||||
|
|
||||||
function isGitHubInstructionsDir(dir: string): boolean {
|
export { safeRealpathSync };
|
||||||
return dir.includes(".github/instructions") || dir.endsWith(".github/instructions");
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
export function findRuleFilesRecursive(dir: string, results: string[]): void {
|
||||||
if (!existsSync(dir)) return;
|
const entries: DirectoryScanEntry[] = [];
|
||||||
|
findRuleFileEntriesRecursive(dir, entries);
|
||||||
try {
|
results.push(...entries.map((entry) => entry.path));
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,2 @@
|
|||||||
import type { RuleFileCandidate } from "./types";
|
export { createRuleScanCache } from "@oh-my-opencode/rules-core";
|
||||||
|
export type { DirectoryScanEntry, RuleScanCache } from "@oh-my-opencode/rules-core";
|
||||||
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();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,57 +1,21 @@
|
|||||||
/**
|
import type { RuleFileCandidate, RuleMetadata } from "@oh-my-opencode/rules-core";
|
||||||
* Rule file metadata (Claude Code style frontmatter)
|
|
||||||
* Supports both Claude Code format (globs, paths) and GitHub Copilot format (applyTo)
|
export type { RuleFileCandidate, RuleMetadata };
|
||||||
* @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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rule information with path context and content
|
|
||||||
*/
|
|
||||||
export interface RuleInfo {
|
export interface RuleInfo {
|
||||||
/** Absolute path to the rule file */
|
|
||||||
path: string;
|
path: string;
|
||||||
/** Path relative to project root */
|
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
/** Directory distance from target file (0 = same dir) */
|
|
||||||
distance: number;
|
distance: number;
|
||||||
/** Rule file content (without frontmatter) */
|
|
||||||
content: string;
|
content: string;
|
||||||
/** SHA-256 hash of content for deduplication */
|
|
||||||
contentHash: string;
|
contentHash: string;
|
||||||
/** Parsed frontmatter metadata */
|
|
||||||
metadata: RuleMetadata;
|
metadata: RuleMetadata;
|
||||||
/** Why this rule matched (e.g., "alwaysApply", "glob: *.ts", "path match") */
|
|
||||||
matchReason: string;
|
matchReason: string;
|
||||||
/** Real path after symlink resolution (for duplicate detection) */
|
|
||||||
realPath: string;
|
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 {
|
export interface InjectedRulesData {
|
||||||
sessionID: string;
|
sessionID: string;
|
||||||
/** Content hashes of already injected rules */
|
|
||||||
injectedHashes: string[];
|
injectedHashes: string[];
|
||||||
/** Real paths of already injected rules (for symlink deduplication) */
|
|
||||||
injectedRealPaths: string[];
|
injectedRealPaths: string[];
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-5
@@ -1,10 +1,10 @@
|
|||||||
# src/mcp/ — 4 Built-in MCPs
|
# src/mcp/ — 5 Built-in MCPs
|
||||||
|
|
||||||
**Generated:** 2026-05-18
|
**Generated:** 2026-05-18
|
||||||
|
|
||||||
## OVERVIEW
|
## 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
|
## 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 |
|
| **context7** | remote | `mcp.context7.com/mcp` | `CONTEXT7_API_KEY` (optional) | Library documentation |
|
||||||
| **grep_app** | remote | `mcp.grep.app` | None | GitHub code search |
|
| **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 |
|
| **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
|
## SUBMODULE ARCHITECTURE
|
||||||
|
|
||||||
- The local `lsp` MCP is a git submodule at `packages/lsp-tools-mcp/`.
|
- The local `lsp` MCP is a git submodule at `packages/lsp-tools-mcp/`.
|
||||||
- Upstream project: https://github.com/code-yeongyu/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.
|
- 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
|
## THREE-TIER SYSTEM
|
||||||
|
|
||||||
| Tier | Source | Mechanism |
|
| 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` |
|
| 2. Claude Code | `.mcp.json` | `${VAR}` expansion via `claude-code-mcp-loader` |
|
||||||
| 3. Skill-embedded | SKILL.md YAML | Managed by `SkillMcpManager` (stdio + HTTP) |
|
| 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 |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `index.ts` | `createBuiltinMcps()` registry for built-in MCPs |
|
| `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 |
|
| `websearch.ts` | Exa/Tavily provider with config |
|
||||||
| `context7.ts` | Context7 with optional auth header |
|
| `context7.ts` | Context7 with optional auth header |
|
||||||
| `grep-app.ts` | Grep.app (no auth) |
|
| `grep-app.ts` | Grep.app (no auth) |
|
||||||
| `lsp.ts` | Local stdio MCP config for packaged `lsp-tools-mcp` |
|
| `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 { createWebsearchConfig } from "./websearch"
|
||||||
import { context7 } from "./context7"
|
import { context7 } from "./context7"
|
||||||
import { grep_app } from "./grep-app"
|
import { grep_app } from "./grep-app"
|
||||||
|
import { createAstGrepMcpConfig } from "./ast-grep"
|
||||||
import { createLspMcpConfig, type LocalMcpConfig } from "./lsp"
|
import { createLspMcpConfig, type LocalMcpConfig } from "./lsp"
|
||||||
import type { OhMyOpenCodeConfig } from "../config/schema"
|
import type { OhMyOpenCodeConfig } from "../config/schema"
|
||||||
|
|
||||||
@@ -16,7 +17,11 @@ type RemoteMcpConfig = {
|
|||||||
|
|
||||||
type BuiltinMcpConfig = RemoteMcpConfig | LocalMcpConfig
|
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> = {}
|
const mcps: Record<string, BuiltinMcpConfig> = {}
|
||||||
|
|
||||||
if (!disabledMcps.includes("websearch")) {
|
if (!disabledMcps.includes("websearch")) {
|
||||||
@@ -38,5 +43,9 @@ export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpen
|
|||||||
mcps.lsp = createLspMcpConfig()
|
mcps.lsp = createLspMcpConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!disabledMcps.includes("ast_grep")) {
|
||||||
|
mcps.ast_grep = createAstGrepMcpConfig({ cwd: options.cwd, disabledTools: config?.disabled_tools })
|
||||||
|
}
|
||||||
|
|
||||||
return mcps
|
return mcps
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { z } from "zod"
|
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>
|
export type McpName = z.infer<typeof McpNameSchema>
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ afterEach(() => {
|
|||||||
mock.restore()
|
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", () => {
|
describe("createBuiltinMcps", () => {
|
||||||
test("should return all MCPs when disabled_mcps is empty", () => {
|
test("should return all MCPs when disabled_mcps is empty", () => {
|
||||||
// given
|
// given
|
||||||
mock.module("../lsp", () => ({
|
mockLocalMcps()
|
||||||
createLspMcpConfig: () => ({ type: "local", command: ["node", "dist/cli.js", "mcp"], enabled: true }),
|
|
||||||
}))
|
|
||||||
const { createBuiltinMcps } = require("../index") as typeof import("../index")
|
const { createBuiltinMcps } = require("../index") as typeof import("../index")
|
||||||
const disabledMcps: string[] = []
|
const disabledMcps: string[] = []
|
||||||
|
|
||||||
@@ -22,13 +29,12 @@ describe("createBuiltinMcps", () => {
|
|||||||
expect(result.context7).toBeDefined()
|
expect(result.context7).toBeDefined()
|
||||||
expect(result.grep_app).toBeDefined()
|
expect(result.grep_app).toBeDefined()
|
||||||
expect(result.lsp).toBeDefined()
|
expect(result.lsp).toBeDefined()
|
||||||
|
expect(result.ast_grep).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should filter out disabled MCPs", () => {
|
test("should filter out disabled MCPs", () => {
|
||||||
// given
|
// given
|
||||||
mock.module("../lsp", () => ({
|
mockLocalMcps()
|
||||||
createLspMcpConfig: () => ({ type: "local", command: ["node", "dist/cli.js", "mcp"], enabled: true }),
|
|
||||||
}))
|
|
||||||
const { createBuiltinMcps } = require("../index") as typeof import("../index")
|
const { createBuiltinMcps } = require("../index") as typeof import("../index")
|
||||||
const disabledMcps = ["websearch"]
|
const disabledMcps = ["websearch"]
|
||||||
|
|
||||||
@@ -40,6 +46,7 @@ describe("createBuiltinMcps", () => {
|
|||||||
expect(result.context7).toBeDefined()
|
expect(result.context7).toBeDefined()
|
||||||
expect(result.grep_app).toBeDefined()
|
expect(result.grep_app).toBeDefined()
|
||||||
expect(result.lsp).toBeDefined()
|
expect(result.lsp).toBeDefined()
|
||||||
|
expect(result.ast_grep).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should keep lsp when it uses a bootstrap command", () => {
|
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", () => {
|
test("should return empty array when all MCPs are disabled", () => {
|
||||||
// given - disable all known MCPs
|
// given
|
||||||
mock.module("../lsp", () => ({
|
mockLocalMcps()
|
||||||
createLspMcpConfig: () => ({ type: "local", command: ["node", "dist/cli.js", "mcp"], enabled: true }),
|
|
||||||
}))
|
|
||||||
const { createBuiltinMcps } = require("../index") as typeof import("../index")
|
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
|
// when
|
||||||
const result = createBuiltinMcps(disabledMcps)
|
const result = createBuiltinMcps(disabledMcps)
|
||||||
|
|
||||||
// then - may still have MCPs we didn't list
|
// then
|
||||||
const remainingMcpNames = Object.keys(result)
|
const remainingMcpNames = Object.keys(result)
|
||||||
expect(remainingMcpNames).not.toContain("websearch")
|
expect(remainingMcpNames).not.toContain("websearch")
|
||||||
expect(remainingMcpNames).not.toContain("context7")
|
expect(remainingMcpNames).not.toContain("context7")
|
||||||
expect(remainingMcpNames).not.toContain("grep_app")
|
expect(remainingMcpNames).not.toContain("grep_app")
|
||||||
expect(remainingMcpNames).not.toContain("lsp")
|
expect(remainingMcpNames).not.toContain("lsp")
|
||||||
|
expect(remainingMcpNames).not.toContain("ast_grep")
|
||||||
expect(remainingMcpNames).toEqual([])
|
expect(remainingMcpNames).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
applyToolConfig({ config, pluginConfig, agentResult });
|
applyToolConfig({ config, pluginConfig, agentResult });
|
||||||
await applyMcpConfig({ config, pluginConfig, pluginComponents });
|
await applyMcpConfig({ config, pluginConfig, ctx, pluginComponents });
|
||||||
await applyCommandConfig({ config, pluginConfig, ctx, pluginComponents });
|
await applyCommandConfig({ config, pluginConfig, ctx, pluginComponents });
|
||||||
|
|
||||||
config.formatter = formatterConfig;
|
config.formatter = formatterConfig;
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ const EMPTY_PLUGIN_COMPONENTS = {
|
|||||||
errors: [],
|
errors: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TEST_CTX = { directory: "/workspace/project" }
|
||||||
|
|
||||||
async function importFreshMcpConfigHandlerModule(): Promise<typeof import("./mcp-config-handler")> {
|
async function importFreshMcpConfigHandlerModule(): Promise<typeof import("./mcp-config-handler")> {
|
||||||
return import(`./mcp-config-handler?test=${Date.now()}-${Math.random()}`)
|
return import(`./mcp-config-handler?test=${Date.now()}-${Math.random()}`)
|
||||||
}
|
}
|
||||||
@@ -69,7 +71,7 @@ describe("applyMcpConfig collision handling", () => {
|
|||||||
|
|
||||||
//#when
|
//#when
|
||||||
const { applyMcpConfig } = await importFreshMcpConfigHandlerModule()
|
const { applyMcpConfig } = await importFreshMcpConfigHandlerModule()
|
||||||
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
||||||
|
|
||||||
//#then
|
//#then
|
||||||
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
|
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
|
||||||
@@ -98,7 +100,7 @@ describe("applyMcpConfig collision handling", () => {
|
|||||||
|
|
||||||
//#when
|
//#when
|
||||||
const { applyMcpConfig } = await importFreshMcpConfigHandlerModule()
|
const { applyMcpConfig } = await importFreshMcpConfigHandlerModule()
|
||||||
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
||||||
|
|
||||||
//#then
|
//#then
|
||||||
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
|
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
|
||||||
@@ -126,7 +128,7 @@ describe("applyMcpConfig collision handling", () => {
|
|||||||
|
|
||||||
//#when
|
//#when
|
||||||
const { applyMcpConfig } = await importFreshMcpConfigHandlerModule()
|
const { applyMcpConfig } = await importFreshMcpConfigHandlerModule()
|
||||||
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
|
||||||
|
|
||||||
//#then
|
//#then
|
||||||
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
|
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const EMPTY_PLUGIN_COMPONENTS = {
|
|||||||
errors: [],
|
errors: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TEST_CTX = { directory: "/workspace/project" }
|
||||||
|
|
||||||
describe("applyMcpConfig", () => {
|
describe("applyMcpConfig", () => {
|
||||||
test("preserves enabled:false from user config after merge with .mcp.json MCPs", async () => {
|
test("preserves enabled:false from user config after merge with .mcp.json MCPs", async () => {
|
||||||
//#given
|
//#given
|
||||||
@@ -62,7 +64,7 @@ describe("applyMcpConfig", () => {
|
|||||||
|
|
||||||
//#when
|
//#when
|
||||||
const { applyMcpConfig } = await import("./mcp-config-handler")
|
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
|
//#then
|
||||||
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
|
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
|
||||||
@@ -89,6 +91,7 @@ describe("applyMcpConfig", () => {
|
|||||||
const { applyMcpConfig } = await import("./mcp-config-handler")
|
const { applyMcpConfig } = await import("./mcp-config-handler")
|
||||||
await applyMcpConfig({
|
await applyMcpConfig({
|
||||||
config,
|
config,
|
||||||
|
ctx: TEST_CTX,
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
pluginComponents: {
|
pluginComponents: {
|
||||||
...EMPTY_PLUGIN_COMPONENTS,
|
...EMPTY_PLUGIN_COMPONENTS,
|
||||||
@@ -112,7 +115,7 @@ describe("applyMcpConfig", () => {
|
|||||||
|
|
||||||
//#when
|
//#when
|
||||||
const { applyMcpConfig } = await import("./mcp-config-handler")
|
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
|
//#then
|
||||||
expect(loadMcpConfigsSpy).toHaveBeenCalledWith(["firecrawl", "exa"])
|
expect(loadMcpConfigsSpy).toHaveBeenCalledWith(["firecrawl", "exa"])
|
||||||
@@ -135,7 +138,7 @@ describe("applyMcpConfig", () => {
|
|||||||
|
|
||||||
//#when
|
//#when
|
||||||
const { applyMcpConfig } = await import("./mcp-config-handler")
|
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
|
//#then
|
||||||
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
|
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
|
||||||
@@ -152,6 +155,7 @@ describe("applyMcpConfig", () => {
|
|||||||
const { applyMcpConfig } = await import("./mcp-config-handler")
|
const { applyMcpConfig } = await import("./mcp-config-handler")
|
||||||
await applyMcpConfig({
|
await applyMcpConfig({
|
||||||
config,
|
config,
|
||||||
|
ctx: TEST_CTX,
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
pluginComponents: {
|
pluginComponents: {
|
||||||
...EMPTY_PLUGIN_COMPONENTS,
|
...EMPTY_PLUGIN_COMPONENTS,
|
||||||
@@ -166,4 +170,17 @@ describe("applyMcpConfig", () => {
|
|||||||
expect(mergedMcp).not.toHaveProperty("plugin:custom")
|
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: {
|
export async function applyMcpConfig(params: {
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
|
ctx: { directory: string };
|
||||||
pluginConfig: OhMyOpenCodeConfig;
|
pluginConfig: OhMyOpenCodeConfig;
|
||||||
pluginComponents: PluginComponents;
|
pluginComponents: PluginComponents;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
@@ -47,7 +48,7 @@ export async function applyMcpConfig(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const merged = {
|
const merged = {
|
||||||
...createBuiltinMcps(disabledMcps, params.pluginConfig),
|
...createBuiltinMcps(disabledMcps, params.pluginConfig, { cwd: params.ctx.directory }),
|
||||||
...mcpResult.servers,
|
...mcpResult.servers,
|
||||||
...(userMcp ?? {}),
|
...(userMcp ?? {}),
|
||||||
...params.pluginComponents.mcpServers,
|
...params.pluginComponents.mcpServers,
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ const interactiveBashTool = interactiveBashEnabled ? { interactive_bash } : {}
|
|||||||
const allTools = {
|
const allTools = {
|
||||||
...createGrepTools(ctx),
|
...createGrepTools(ctx),
|
||||||
...createGlobTools(ctx),
|
...createGlobTools(ctx),
|
||||||
...createAstGrepTools(ctx),
|
|
||||||
...createSessionManagerTools(ctx),
|
...createSessionManagerTools(ctx),
|
||||||
...backgroundTools, // 2 background_*
|
...backgroundTools, // 2 background_*
|
||||||
call_omo_agent, task,
|
call_omo_agent, task,
|
||||||
@@ -74,7 +73,7 @@ const allTools = {
|
|||||||
...hashlineToolsRecord, // +1 conditional
|
...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
|
## KEY PATTERNS
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function getNestedRecord(record: Record<string, unknown>, key: string): Record<s
|
|||||||
|
|
||||||
async function loadSeparateHostZodModule(): Promise<typeof import("zod")> {
|
async function loadSeparateHostZodModule(): Promise<typeof import("zod")> {
|
||||||
const pluginPackageDirectory = dirname(Bun.resolveSync("@opencode-ai/plugin/package.json", import.meta.dir))
|
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 tempDirectory = mkdtempSync(join(tmpdir(), "omo-host-zod-"))
|
||||||
const copiedZodDirectory = join(tempDirectory, "zod")
|
const copiedZodDirectory = join(tempDirectory, "zod")
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ describe("team-mode tool registry wiring", () => {
|
|||||||
createSkillTool: mock(() => fakeTool),
|
createSkillTool: mock(() => fakeTool),
|
||||||
createGrepTools: mock(() => ({})),
|
createGrepTools: mock(() => ({})),
|
||||||
createGlobTools: mock(() => ({})),
|
createGlobTools: mock(() => ({})),
|
||||||
createAstGrepTools: mock(() => ({})),
|
|
||||||
createSessionManagerTools: mock(() => ({})),
|
createSessionManagerTools: mock(() => ({})),
|
||||||
createDelegateTask: mock(() => fakeTool),
|
createDelegateTask: mock(() => fakeTool),
|
||||||
discoverCommandsSync: mock(() => []),
|
discoverCommandsSync: mock(() => []),
|
||||||
@@ -164,7 +163,6 @@ describe("team-mode tool registry wiring", () => {
|
|||||||
createSkillTool: mock(() => fakeTool),
|
createSkillTool: mock(() => fakeTool),
|
||||||
createGrepTools: mock(() => ({})),
|
createGrepTools: mock(() => ({})),
|
||||||
createGlobTools: mock(() => ({})),
|
createGlobTools: mock(() => ({})),
|
||||||
createAstGrepTools: mock(() => ({})),
|
|
||||||
createSessionManagerTools: mock(() => ({})),
|
createSessionManagerTools: mock(() => ({})),
|
||||||
createDelegateTask: mock(() => fakeTool),
|
createDelegateTask: mock(() => fakeTool),
|
||||||
discoverCommandsSync: mock(() => []),
|
discoverCommandsSync: mock(() => []),
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ const toolFactories: NonNullable<Parameters<typeof createToolRegistry>[0]["toolF
|
|||||||
createSkillTool: mock(() => fakeTool),
|
createSkillTool: mock(() => fakeTool),
|
||||||
createGrepTools: mock(() => ({})),
|
createGrepTools: mock(() => ({})),
|
||||||
createGlobTools: mock(() => ({})),
|
createGlobTools: mock(() => ({})),
|
||||||
createAstGrepTools: mock(() => ({})),
|
|
||||||
createSessionManagerTools: mock(() => ({})),
|
createSessionManagerTools: mock(() => ({})),
|
||||||
createDelegateTask: mock((options: { onSyncSessionCreated?: typeof syncSessionCreatedCallbacks[number] }) => {
|
createDelegateTask: mock((options: { onSyncSessionCreated?: typeof syncSessionCreatedCallbacks[number] }) => {
|
||||||
syncSessionCreatedCallbacks.push(options.onSyncSessionCreated)
|
syncSessionCreatedCallbacks.push(options.onSyncSessionCreated)
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
createSkillTool,
|
createSkillTool,
|
||||||
createGrepTools,
|
createGrepTools,
|
||||||
createGlobTools,
|
createGlobTools,
|
||||||
createAstGrepTools,
|
|
||||||
createSessionManagerTools,
|
createSessionManagerTools,
|
||||||
createDelegateTask,
|
createDelegateTask,
|
||||||
discoverCommandsSync,
|
discoverCommandsSync,
|
||||||
@@ -59,7 +58,6 @@ type ToolRegistryFactories = {
|
|||||||
createSkillTool: typeof createSkillTool
|
createSkillTool: typeof createSkillTool
|
||||||
createGrepTools: typeof createGrepTools
|
createGrepTools: typeof createGrepTools
|
||||||
createGlobTools: typeof createGlobTools
|
createGlobTools: typeof createGlobTools
|
||||||
createAstGrepTools: typeof createAstGrepTools
|
|
||||||
createSessionManagerTools: typeof createSessionManagerTools
|
createSessionManagerTools: typeof createSessionManagerTools
|
||||||
createDelegateTask: typeof createDelegateTask
|
createDelegateTask: typeof createDelegateTask
|
||||||
discoverCommandsSync: typeof discoverCommandsSync
|
discoverCommandsSync: typeof discoverCommandsSync
|
||||||
@@ -91,7 +89,6 @@ const defaultToolRegistryFactories: ToolRegistryFactories = {
|
|||||||
createSkillTool,
|
createSkillTool,
|
||||||
createGrepTools,
|
createGrepTools,
|
||||||
createGlobTools,
|
createGlobTools,
|
||||||
createAstGrepTools,
|
|
||||||
createSessionManagerTools,
|
createSessionManagerTools,
|
||||||
createDelegateTask,
|
createDelegateTask,
|
||||||
discoverCommandsSync,
|
discoverCommandsSync,
|
||||||
@@ -338,7 +335,6 @@ export function createToolRegistry(args: {
|
|||||||
const allTools: Record<string, ToolDefinition> = {
|
const allTools: Record<string, ToolDefinition> = {
|
||||||
...factories.createGrepTools(ctx),
|
...factories.createGrepTools(ctx),
|
||||||
...factories.createGlobTools(ctx),
|
...factories.createGlobTools(ctx),
|
||||||
...factories.createAstGrepTools(ctx),
|
|
||||||
...factories.createSessionManagerTools(ctx),
|
...factories.createSessionManagerTools(ctx),
|
||||||
...backgroundTools,
|
...backgroundTools,
|
||||||
call_omo_agent: callOmoAgent,
|
call_omo_agent: callOmoAgent,
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ import ts from "typescript"
|
|||||||
const SOURCE_ROOT = path.resolve(import.meta.dir, "..")
|
const SOURCE_ROOT = path.resolve(import.meta.dir, "..")
|
||||||
const MOCK_MODULE_TOKEN = "mock.module"
|
const MOCK_MODULE_TOKEN = "mock.module"
|
||||||
const MOCK_MODULE_LIFECYCLE_ALLOWLIST = new Map<string, string>([
|
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.
|
// TODO(MOCK-MODULE-AUDIT): add cleanup for team mailbox inbox module mocks.
|
||||||
[
|
[
|
||||||
path.join(SOURCE_ROOT, "features", "team-mode", "team-mailbox", "inbox.test.ts"),
|
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)) {
|
if (!contents.includes(MOCK_MODULE_TOKEN)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceFile = ts.createSourceFile(filePath, contents, ts.ScriptTarget.Latest, true)
|
const sourceFile = ts.createSourceFile(filePath, contents, ts.ScriptTarget.Latest, true)
|
||||||
if (hasMockModuleCall(sourceFile) && !hasCleanupPattern(sourceFile)) {
|
if (hasMockModuleCall(sourceFile) && !hasCleanupPattern(sourceFile)) {
|
||||||
offenders.push(relativeSourcePath(filePath))
|
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
|
**Generated:** 2026-05-15
|
||||||
|
|
||||||
## OVERVIEW
|
## 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
|
## TOOL CATALOG
|
||||||
|
|
||||||
### Always On (14 native tools)
|
### Always On (12 native tools)
|
||||||
|
|
||||||
| Group | 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` |
|
| **Sessions** (4) | `session_list`, `session_read`, `session_search`, `session_info` |
|
||||||
| **Background tasks** (2) | `background_output`, `background_cancel` |
|
| **Background tasks** (2) | `background_output`, `background_cancel` |
|
||||||
| **Delegation** (2) | `task` (delegate, full skill+category support), `call_omo_agent` (named agent only: explore, librarian) |
|
| **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) |
|
| **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)
|
### Conditional (up to +19 native tools)
|
||||||
|
|
||||||
@@ -68,7 +68,6 @@ User-defined categories declared in `categories: { ... }` config override and ex
|
|||||||
|
|
||||||
```
|
```
|
||||||
tools/
|
tools/
|
||||||
├── ast-grep/ # ast_grep_search, ast_grep_replace
|
|
||||||
├── background-task/ # background_output, background_cancel (LLM interface; engine in features/background-agent)
|
├── background-task/ # background_output, background_cancel (LLM interface; engine in features/background-agent)
|
||||||
├── call-omo-agent/ # call_omo_agent (explore + librarian only)
|
├── call-omo-agent/ # call_omo_agent (explore + librarian only)
|
||||||
├── delegate-task/ # task — full delegation with categories + skills
|
├── 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 { createGrepTools } from "./grep"
|
||||||
export { createGlobTools } from "./glob"
|
export { createGlobTools } from "./glob"
|
||||||
export { createSkillTool } from "./skill"
|
export { createSkillTool } from "./skill"
|
||||||
|
|||||||
Reference in New Issue
Block a user